Merge branch 'stable-2.12' into stable-2.13

* stable-2.12:
  Set version to 2.11.11
  Replace links to code.google.com/p/gerrit
  Update issue tracker URL in documentation
  Update issue tracker URL in POM files
  Bump jsch to 0.54
  Update jsch to 0.1.53
  Bump Jsch to 1.52

Change-Id: Ie20058fcee683d51b186cdc3ca5e288b01bff537
diff --git a/.bazelrc b/.bazelrc
new file mode 100644
index 0000000..00acd27
--- /dev/null
+++ b/.bazelrc
@@ -0,0 +1 @@
+build --strategy=Javac=worker
diff --git a/.buckconfig b/.buckconfig
index 51318f3..b347a96 100644
--- a/.buckconfig
+++ b/.buckconfig
@@ -1,15 +1,14 @@
 [alias]
   api = //:api
-  api_deploy = //tools/maven:api_deploy
-  api_install = //tools/maven:api_install
-  war_deploy = //tools/maven:war_deploy
-  war_install = //tools/maven:war_install
   chrome = //:chrome
   docs = //Documentation:searchfree
   firefox = //:firefox
   gerrit = //:gerrit
+  gwtgerrit = //:gwtgerrit
   headless = //:headless
+  polygerrit = //:polygerrit
   release = //:release
+  releasenotes = //ReleaseNotes:html
   safari = //:safari
   soyc = //gerrit-gwtui:ui_soyc
   soyc_r = //gerrit-gwtui:ui_soyc_r
@@ -19,11 +18,16 @@
   includes = //tools/default.defs
 
 [java]
-  src_roots = java, resources
+  jar_spool_mode = direct_to_jar
+  src_roots = java, resources, src
 
 [project]
-  ignore = .git
+  ignore = .git, eclipse-out, bazel-gerrit, bin
+  parallel_parsing = true
 
 [cache]
   mode = dir
   dir = ~/.gerritcodereview/buck-cache/locally-built-artifacts
+
+[test]
+  excluded_labels = manual
diff --git a/.buckversion b/.buckversion
index 9daac2c..f5fe016 100644
--- a/.buckversion
+++ b/.buckversion
@@ -1 +1 @@
-1b03b4313b91b634bd604fc3487a05f877e59dee
+e64a2e2ada022f81e42be750b774024469551398
diff --git a/.editorconfig b/.editorconfig
index 1f149cf..cb18523 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -9,3 +9,4 @@
 charset = utf-8
 indent_style = space
 indent_size = 2
+continuation_indent_size = 4
diff --git a/.gitignore b/.gitignore
index 32a1826..0fe7572 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,14 +7,22 @@
 /.settings/org.eclipse.ltk.core.refactoring.prefs
 /test_site
 /.idea
-/gerrit-parent.iml
+*.iml
+*.eml
 *.sublime-*
 /gerrit-package-plugins
+/.bazel_path
 /.buckconfig.local
 /.buckjavaargs
 /.buckd
+/bazel-bin
+/bazel-genfiles
+/bazel-gerrit
+/bazel-out
+/bazel-testlogs
 /buck-cache
 /buck-out
+/eclipse-out
 /extras
 /local.properties
 *.pyc
@@ -23,3 +31,6 @@
 *.swp
 *.asc
 /bin/
+*~
+.primary_build_tool
+.gwt_work_dir
diff --git a/.gitmodules b/.gitmodules
index d75c98c..6c4d53c 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,23 +1,34 @@
 [submodule "plugins/commit-message-length-validator"]
 	path = plugins/commit-message-length-validator
 	url = ../plugins/commit-message-length-validator
+	branch = .
 
 [submodule "plugins/cookbook-plugin"]
 	path = plugins/cookbook-plugin
 	url = ../plugins/cookbook-plugin
+	branch = .
 
 [submodule "plugins/download-commands"]
 	path = plugins/download-commands
 	url = ../plugins/download-commands
+	branch = .
+
+[submodule "plugins/hooks"]
+	path = plugins/hooks
+	url = ../plugins/hooks
+	branch = .
 
 [submodule "plugins/replication"]
 	path = plugins/replication
 	url = ../plugins/replication
+	branch = .
 
 [submodule "plugins/reviewnotes"]
 	path = plugins/reviewnotes
 	url = ../plugins/reviewnotes
+	branch = .
 
 [submodule "plugins/singleusergroup"]
 	path = plugins/singleusergroup
 	url = ../plugins/singleusergroup
+	branch = .
diff --git a/.mailmap b/.mailmap
index c8e2f82..598d52d 100644
--- a/.mailmap
+++ b/.mailmap
@@ -9,7 +9,8 @@
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
-Edwin Kempin <edwin.kempin@sap.com>                                                         Edwin Kempin <edwin.kempin@gmail.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
 Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 8f5678f..828234b 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -46,7 +46,7 @@
 org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
 org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
 org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
+org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=enabled
 org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=warning
 org.eclipse.jdt.core.compiler.problem.missingJavadocComments=ignore
 org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsOverriding=disabled
@@ -92,7 +92,7 @@
 org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
 org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
 org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.unnecessaryElse=ignore
+org.eclipse.jdt.core.compiler.problem.unnecessaryElse=warning
 org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=warning
 org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
 org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=warning
diff --git a/.watchmanconfig b/.watchmanconfig
index b1869ba..4467aec 100644
--- a/.watchmanconfig
+++ b/.watchmanconfig
@@ -1,6 +1,7 @@
 {
   "ignore_dirs": [
-    "buck-out"
+    "buck-out",
+    "eclipse-out"
   ],
   "ignore_vcs": [
     ".git"
diff --git a/BUCK b/BUCK
index c986874..9657ff3 100644
--- a/BUCK
+++ b/BUCK
@@ -1,10 +1,12 @@
 include_defs('//tools/build.defs')
 
 gerrit_war(name = 'gerrit')
-gerrit_war(name = 'headless', ui = None)
-gerrit_war(name = 'chrome',   ui = 'ui_chrome')
-gerrit_war(name = 'firefox',  ui = 'ui_firefox')
-gerrit_war(name = 'safari',   ui = 'ui_safari')
+gerrit_war(name = 'gwtgerrit',   ui = 'ui_dbg')
+gerrit_war(name = 'headless',    ui = None)
+gerrit_war(name = 'chrome',      ui = 'ui_chrome')
+gerrit_war(name = 'firefox',     ui = 'ui_firefox')
+gerrit_war(name = 'safari',      ui = 'ui_safari')
+gerrit_war(name = 'polygerrit',  ui = 'polygerrit')
 gerrit_war(name = 'withdocs', docs = True)
 gerrit_war(name = 'release',  ui = 'ui_optdbg_r', docs = True, context = ['//plugins:core'],  visibility = ['//tools/maven:'])
 
diff --git a/Documentation/BUCK b/Documentation/BUCK
index 126bf1f..48ca579 100644
--- a/Documentation/BUCK
+++ b/Documentation/BUCK
@@ -1,16 +1,20 @@
 include_defs('//Documentation/asciidoc.defs')
 include_defs('//Documentation/config.defs')
+include_defs('//Documentation/license.defs')
 include_defs('//tools/git.defs')
 
 DOC_DIR = 'Documentation'
-JSUI = '//gerrit-gwtui:ui_module'
-MAIN = '//gerrit-pgm:pgm'
+
+JSUI_JAVA_DEPS = ['//gerrit-gwtui:ui_module']
+JSUI_NON_JAVA_DEPS = ['//polygerrit-ui/app:polygerrit_ui']
+MAIN_JAVA_DEPS = ['//gerrit-pgm:pgm']
 SRCS = glob(['*.txt'], excludes = ['licenses.txt'])
 
+
 genasciidoc(
   name = 'html',
   out = 'html.zip',
-  docdir = DOC_DIR,
+  directory = DOC_DIR,
   srcs = SRCS + [':licenses.txt'],
   attributes = documentation_attributes(git_describe()),
   backend = 'html5',
@@ -20,7 +24,7 @@
 genasciidoc(
   name = 'searchfree',
   out = 'searchfree.zip',
-  docdir = DOC_DIR,
+  directory = DOC_DIR,
   srcs = SRCS + [':licenses.txt'],
   attributes = documentation_attributes(git_describe()),
   backend = 'html5',
@@ -28,31 +32,23 @@
   visibility = ['PUBLIC'],
 )
 
-genrule(
+genlicenses(
   name = 'licenses.txt',
-  cmd = '$(exe :gen_licenses) --asciidoc '
-    + '--classpath $(classpath %s) ' % MAIN
-    + '--classpath $(classpath %s) ' % JSUI
-    + MAIN + ' ' + JSUI + ' >$OUT',
+  opts = ['--asciidoc'],
+  java_deps = JSUI_JAVA_DEPS + MAIN_JAVA_DEPS,
+  non_java_deps = JSUI_NON_JAVA_DEPS,
   out = 'licenses.txt',
 )
 
 # Required by Google for gerrit-review.
-genrule(
+genlicenses(
   name = 'js_licenses.txt',
-  cmd = '$(exe :gen_licenses) --partial '
-    + '--classpath $(classpath %s) ' % JSUI
-    + JSUI + ' >$OUT',
+  opts = ['--partial'],
+  java_deps = JSUI_JAVA_DEPS,
+  non_java_deps = JSUI_NON_JAVA_DEPS,
   out = 'js_licenses.txt',
 )
 
-genrule(
-  name = 'doc.css',
-  srcs = ['doc.css.in'],
-  cmd = 'cp $SRCS $OUT',
-  out = 'doc.css',
-)
-
 python_binary(
   name = 'gen_licenses',
   main = 'gen_licenses.py',
@@ -61,6 +57,7 @@
 python_binary(
   name = 'replace_macros',
   main = 'replace_macros.py',
+  visibility = ['//ReleaseNotes:'],
 )
 
 genrule(
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 0758c5c..2cc8c05 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -99,21 +99,14 @@
 [[administrators]]
 === Administrators
 
-This is the Gerrit "root" identity. The capability
-link:access-control.html#capability_administrateServer['Administrate Server']
-is assigned to this predefined group on Gerrit site creation.
+This is a predefined group, created on Gerrit site initialization, that
+has the capability link:access-control.html#capability_administrateServer[
+'Administrate Server'] assigned.
 
-Users in the 'Administrators' group can perform any action under
-the Admin menu, to any group or project, without further validation
-or any other access controls.  In most installations only those
-users who have direct filesystem and database access would be
-placed into this group.
-
-Membership in the 'Administrators' group does not imply any other
-access rights.  Administrators do not automatically get code review
-approval or submit rights in projects.  This is a feature designed
-to permit administrative users to otherwise access Gerrit as any
-other normal user would, without needing two different accounts.
+It is a normal Gerrit group without magic. This means if you remove
+the 'Administrate Server' capability from it, its members are no longer
+Gerrit administrators, despite the group name. The group may also be
+renamed.
 
 
 [[non-interactive_users]]
@@ -211,8 +204,8 @@
 Permissions can be set on a single reference name to match one
 branch (e.g. `refs/heads/master`), or on a reference namespace
 (e.g. `+refs/heads/*+`) to match any branch starting with that
-prefix. So a permission with `+refs/heads/*+` will match
-`refs/heads/master` and `refs/heads/experimental`, etc.
+prefix. So a permission with `+refs/heads/*+` will match all of
+`refs/heads/master`, `refs/heads/experimental`, `refs/heads/release/1.0` etc.
 
 Reference names can also be described with a regular expression
 by prefixing the reference name with `^`.  For example
@@ -222,13 +215,21 @@
 The link:http://www.brics.dk/automaton/[dk.brics.automaton library]
 is used for evaluation of regular expression access control
 rules. See the library documentation for details on this
-particular regular expression flavor.
+particular regular expression flavor. One quirk is that the
+shortest possible pattern expansion must be a valid ref name:
+thus `^refs/heads/.*/name` will fail because `refs/heads//name`
+is not a valid reference, but `^refs/heads/.+/name` will work.
 
-References can have the current user name automatically included,
-creating dynamic access controls that change to match the currently
-logged in user.  For example to provide a personal sandbox space
-to all developers, `+refs/heads/sandbox/${username}/*+` allowing
-the user 'joe' to use 'refs/heads/sandbox/joe/foo'.
+References can have the user name or the sharded account ID of the
+current user automatically included, creating dynamic access controls
+that change to match the currently logged in user.  For example to
+provide a personal sandbox space to all developers,
+`+refs/heads/sandbox/${username}/*+` allows the user 'joe' to use
+'refs/heads/sandbox/joe/foo'. The sharded account ID can be used to
+give users access to their user branch in the `All-Users` repository,
+for example `+refs/users/${shardeduserid}+` is resolved to
+'refs/users/23/1011123' if the account ID of the current user is
+`1011123`.
 
 When evaluating a reference-level access right, Gerrit will use
 the full set of access rights to determine if the user
@@ -420,10 +421,10 @@
 To block push permission to `+refs/drafts/*+` the following permission rule can
 be configured:
 
-====
+----
   [access "refs/drafts/*"]
     push = block group Anonymous Users
-====
+----
 
 
 [[access_categories]]
@@ -563,7 +564,6 @@
 new changes for code review, this depends on which namespace the
 permission is granted to.
 
-
 [[category_push_direct]]
 ==== Direct Push
 
@@ -611,6 +611,20 @@
 `+refs/for/refs/heads/*+` namespace.
 
 
+[[category_add_patch_set]]
+=== Add Patch Set
+
+This category controls which users are allowed to upload new patch sets to
+existing changes. Irrespective of this permission, change owners are always
+allowed to upload new patch sets for their changes. This permission needs to be
+set on `refs/for/*`.
+
+By default, this permission is granted to `Registered Users` on `refs/for/*`,
+allowing all registered users to upload a new patch set to any change. Revoking
+this permission (by granting it to no groups and setting the "Exclusive" flag)
+will prevent users from uploading a patch set to a change they do not own.
+
+
 [[category_push_merge]]
 === Push Merge Commits
 
@@ -636,15 +650,15 @@
 project's repository.  Typically this would be done with a command line
 such as:
 
-====
+----
   git push ssh://USER@HOST:PORT/PROJECT tag v1.0
-====
+----
 
 Or:
 
-====
+----
   git push https://HOST/PROJECT tag v1.0
-====
+----
 
 Tags must be annotated (created with `git tag -a`), should exist in
 the `refs/tags/` namespace, and should be new.
@@ -676,15 +690,15 @@
 project's repository.  Typically this would be done with a command
 line such as:
 
-====
+----
   git push ssh://USER@HOST:PORT/PROJECT tag v1.0
-====
+----
 
 Or:
 
-====
+----
   git push https://HOST/PROJECT tag v1.0
-====
+----
 
 Tags must be signed (created with `git tag -s`), should exist in the
 `refs/tags/` namespace, and should be new.
@@ -786,7 +800,9 @@
 === Submit (On Behalf Of)
 
 This category permits users who have also been granted the `Submit`
-permission to submit changes on behalf of another user.
+permission to submit changes on behalf of another user, by using the
+`on_behalf_of` field in link:rest-api-changes.html#submit-input[SubmitInput]
+when link:rest-api-changes.html#submit-change[submitting using the REST API].
 
 Note that this permission is named `submitAs` in the `project.config`
 file.
@@ -1066,10 +1082,10 @@
 '-2' and '+2', but keep their existing voting permissions for the '-1..+1'
 range intact we would define:
 
-====
+----
   [access "refs/heads/*"]
     label-Code-Review = block -2..+2 group X
-====
+----
 
 The interpretation of the 'min..max' range in case of a blocking rule is: block
 every vote from '-INFINITE..min' and 'max..INFINITE'. For the example above it
@@ -1080,16 +1096,17 @@
 When an access section of a project contains a 'BLOCK' and an 'ALLOW' rule for
 the same permission then this 'ALLOW' rule overrides the 'BLOCK' rule:
 
-====
+----
   [access "refs/heads/*"]
     push = block group X
     push = group Y
-====
+----
 
 In this case a user which is a member of the group 'Y' will still be allowed to
 push to 'refs/heads/*' even if it is a member of the group 'X'.
 
-NOTE: An 'ALLOW' rule overrides a 'BLOCK' rule only when both of them are
+[NOTE]
+An 'ALLOW' rule overrides a 'BLOCK' rule only when both of them are
 inside the same access section of the same project. An 'ALLOW' rule in a
 different access section of the same project or in any access section in an
 inheriting project cannot override a 'BLOCK' rule.
@@ -1105,22 +1122,22 @@
 reproducibility of a build must be guaranteed. To achieve that we block 'push'
 permission for the <<anonymous_users,'Anonymous Users'>> in "`All-Projects`":
 
-====
+----
   [access "refs/tags/*"]
     push = block group Anonymous Users
-====
+----
 
 By blocking the <<anonymous_users,'Anonymous Users'>> we effectively block
 everyone as everyone is a member of that group. Note that the permission to
 create a tag is still necessary. Assuming that only <<category_owner,project
 owners>> are allowed to create tags, we would extend the example above:
 
-====
+----
   [access "refs/tags/*"]
     push = block group Anonymous Users
     create = group Project Owners
     pushTag = group Project Owners
-====
+----
 
 
 ==== Let only a dedicated group vote in a special category
@@ -1133,11 +1150,11 @@
 in this category and, of course, allow 'Release Engineers' to vote in that
 category. In the "`All-Projects`" we define the following rules:
 
-====
+----
   [access "refs/heads/stable*"]
     label-Release-Process = block -1..+1 group Anonymous Users
     label-Release-Process = -1..+1 group Release Engineers
-====
+----
 
 [[global_capabilities]]
 == Global Capabilities
@@ -1170,10 +1187,19 @@
 === Administrate Server
 
 This is in effect the owner and administrator role of the Gerrit
-instance.  Any members of a group granted this capability will be
+instance. Any members of a group granted this capability will be
 able to grant any access right to any group. They will also have all
 capabilities granted to them automatically.
 
+In most installations only those users who have direct filesystem and
+database access should be granted this capability.
+
+This capability does not imply any other access rights. Users that have
+this capability do not automatically get code review approval or submit
+rights in projects. This is a feature designed to permit administrative
+users to otherwise access Gerrit as any other normal user would,
+without needing two different accounts.
+
 
 [[capability_batchChangesLimit]]
 === Batch Changes Limit
diff --git a/Documentation/asciidoc.defs b/Documentation/asciidoc.defs
index 2caf725..4b17071 100644
--- a/Documentation/asciidoc.defs
+++ b/Documentation/asciidoc.defs
@@ -35,7 +35,7 @@
   for attribute in attributes:
     asciidoc.extend(['-a', attribute])
   asciidoc.append('$SRCS')
-  newsrcs = [":doc.css"]
+  newsrcs = []
   for src in srcs:
     fn = src
     # We have two cases: regular source files and generated files.
@@ -52,15 +52,13 @@
 
     genrule(
       name = ex,
-      cmd = '$(exe :replace_macros) --suffix="%s"' % EXPN +
+      cmd = '$(exe //Documentation:replace_macros) --suffix="%s"' % EXPN +
         ' -s ' + passed_src + ' -o $OUT' +
         (' --searchbox' if searchbox else ' --no-searchbox'),
       srcs = srcs,
       out = ex,
     )
 
-    # The new AsciiDoctor requires both the css file and include files are under
-    # the same directory. Luckily Buck allows us to use :target as SRCS now.
     newsrcs.append(':%s' % ex)
 
   genrule(
@@ -74,41 +72,42 @@
 def genasciidoc(
     name,
     out,
-    docdir,
+    directory,
     srcs = [],
     attributes = [],
     backend = None,
     searchbox = True,
+    resources = True,
     visibility = []):
   SUFFIX = '_htmlonly'
 
   genasciidoc_htmlonly(
-    name = name + SUFFIX,
+    name = name + SUFFIX if resources else name,
     srcs = srcs,
     attributes = attributes,
     backend = backend,
     searchbox = searchbox,
-    out = name + SUFFIX + '.zip',
+    out = (name + SUFFIX + '.zip') if resources else (name + '.zip'),
   )
 
-  genrule(
-    name = name,
-    cmd = 'cd $TMP;' +
-      'mkdir -p %s/images;' % docdir +
-      'unzip -q $(location %s) -d %s/;'
-      % (':' + name + SUFFIX, docdir) +
-      'for s in $SRCS;do ln -s $s %s;done;' % docdir +
-      'mv %s/*.{jpg,png} %s/images;' % (docdir, docdir) +
-      'cp $(location %s) LICENSES.txt;' % ':licenses.txt' +
-      'zip -qr $OUT *',
-    srcs = glob([
-        'images/*.jpg',
-        'images/*.png',
-      ]) + [
-        ':doc.css',
-        '//gerrit-prettify:prettify.min.css',
-        '//gerrit-prettify:prettify.min.js',
-      ],
-    out = out,
-    visibility = visibility,
-  )
+  if resources:
+    genrule(
+      name = name,
+      cmd = 'cd $TMP;' +
+        'mkdir -p %s/images;' % directory +
+        'unzip -q $(location %s) -d %s/;'
+        % (':' + name + SUFFIX, directory) +
+        'for s in $SRCS;do ln -s $s %s/;done;' % directory +
+        'mv %s/*.{jpg,png} %s/images;' % (directory, directory) +
+        'cp $(location %s) LICENSES.txt;' % ':licenses.txt' +
+        'zip -qr $OUT *',
+      srcs = glob([
+          'images/*.jpg',
+          'images/*.png',
+        ]) + [
+          '//gerrit-prettify:prettify.min.css',
+          '//gerrit-prettify:prettify.min.js',
+        ],
+      out = out,
+      visibility = visibility,
+    )
diff --git a/Documentation/cmd-apropos.txt b/Documentation/cmd-apropos.txt
index 8882af18..31d21c1 100644
--- a/Documentation/cmd-apropos.txt
+++ b/Documentation/cmd-apropos.txt
@@ -4,8 +4,9 @@
 gerrit apropos - Search Gerrit documentation index
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit apropos'
+_ssh_ -p <port> <host> _gerrit apropos_
   <query>
 --
 
@@ -19,12 +20,14 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
-Note: this feature is only available if documentation index was built.
+[NOTE]
+This feature is only available if documentation index was built.
 
 == EXAMPLES
 
-=====
+----
 $ ssh -p 29418 review.example.com gerrit apropos capabilities
+
     Gerrit Code Review - /config/ REST API:
     http://localhost:8080/Documentation/rest-api-config.html
 
@@ -45,7 +48,7 @@
 
     Gerrit Code Review - /access/ REST API:
     http://localhost:8080/Documentation/rest-api-access.html
-=====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-ban-commit.txt b/Documentation/cmd-ban-commit.txt
index d5c09af..80f41f0 100644
--- a/Documentation/cmd-ban-commit.txt
+++ b/Documentation/cmd-ban-commit.txt
@@ -4,8 +4,9 @@
 gerrit ban-commit - Bans a commit from a project's repository.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit ban-commit'
+_ssh_ -p <port> <host> _gerrit ban-commit_
   [--reason <REASON>]
   <PROJECT>
   <COMMIT> ...
@@ -43,10 +44,10 @@
 Ban commit `421919d015c062fd28901fe144a78a555d0b5984` from project
 `myproject`:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit ban-commit myproject \
 	421919d015c062fd28901fe144a78a555d0b5984
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-cherry-pick.txt b/Documentation/cmd-cherry-pick.txt
index 0c4cf91..de0b71b 100644
--- a/Documentation/cmd-cherry-pick.txt
+++ b/Documentation/cmd-cherry-pick.txt
@@ -4,10 +4,11 @@
 gerrit-cherry-pick - Download and cherry pick one or more changes
 
 == SYNOPSIS
+[verse]
 --
-'gerrit-cherry-pick' <remote> <changeid>...
-'gerrit-cherry-pick' --continue | --skip | --abort
-'gerrit-cherry-pick' --close <remote>
+_gerrit-cherry-pick_ <remote> <changeid>...
+_gerrit-cherry-pick_ --continue | --skip | --abort
+_gerrit-cherry-pick_ --close <remote>
 --
 
 == DESCRIPTION
@@ -32,11 +33,11 @@
 To obtain the 'gerrit-cherry-pick' script use scp, curl or wget to
 copy it to your local system:
 
-====
+----
   $ scp -p -P 29418 john.doe@review.example.com:bin/gerrit-cherry-pick ~/bin/
 
   $ curl -Lo ~/bin/gerrit-cherry-pick http://review.example.com/tools/bin/gerrit-cherry-pick
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-close-connection.txt b/Documentation/cmd-close-connection.txt
index 3314326..973441e 100644
--- a/Documentation/cmd-close-connection.txt
+++ b/Documentation/cmd-close-connection.txt
@@ -4,8 +4,9 @@
 gerrit close-connection - Close the specified SSH connection
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit close-connection' <SESSION_ID>
+_ssh_ -p <port> <host> _gerrit close-connection_ <SESSION_ID>
    [--wait]
 --
 
diff --git a/Documentation/cmd-create-account.txt b/Documentation/cmd-create-account.txt
index 2159e0e..62bd0aa 100644
--- a/Documentation/cmd-create-account.txt
+++ b/Documentation/cmd-create-account.txt
@@ -4,8 +4,9 @@
 gerrit create-account - Create a new user account.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit create-account'
+_ssh_ -p <port> <host> _gerrit create-account_
   [--group <GROUP>]
   [--full-name <FULLNAME>]
   [--email <EMAIL>]
@@ -67,9 +68,9 @@
 Create a new batch/role access user account called `watcher` in
 the 'Non-Interactive Users' group.
 
-====
+----
 	$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Non-Interactive Users'" --ssh-key - watcher
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-create-branch.txt b/Documentation/cmd-create-branch.txt
index 671adfe..336af56d 100644
--- a/Documentation/cmd-create-branch.txt
+++ b/Documentation/cmd-create-branch.txt
@@ -4,8 +4,9 @@
 gerrit create-branch - Create a new branch
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit create-branch'
+_ssh_ -p <port> <host> _gerrit create-branch_
   <PROJECT>
   <NAME>
   <REVISION>
@@ -38,9 +39,9 @@
 Create a new branch called 'newbranch' from the 'master' branch of
 the project 'myproject'.
 
-====
+----
     $ ssh -p 29418 review.example.com gerrit create-branch myproject newbranch master
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-create-group.txt b/Documentation/cmd-create-group.txt
index d02e2ea..7f1f463 100644
--- a/Documentation/cmd-create-group.txt
+++ b/Documentation/cmd-create-group.txt
@@ -4,8 +4,9 @@
 gerrit create-group - Create a new account group.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit create-group'
+_ssh_ -p <port> <host> _gerrit create-group_
   [--owner <GROUP> | -o <GROUP>]
   [--description <DESC> | -d <DESC>]
   [--member <USERNAME>]
@@ -66,16 +67,16 @@
 Create a new account group called `gerritdev` with two initial members
 `developer1` and `developer2`.  The group should be owned by itself:
 
-====
+----
 	$ ssh -p 29418 user@review.example.com gerrit create-group --member developer1 --member developer2 gerritdev
-====
+----
 
 Create a new account group called `Foo` owned by the `Foo-admin` group.
 Put `developer1` as the initial member and include group description:
 
-====
+----
 	$ ssh -p 29418 user@review.example.com gerrit create-group --owner Foo-admin --member developer1 --description "'Foo description'" Foo
-====
+----
 
 Note that it is necessary to quote the description twice.  The local
 shell needs double quotes around the value to ensure the single quotes
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index d1108b5..503bd12 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -4,8 +4,9 @@
 gerrit create-project - Create a new hosted project
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit create-project'
+_ssh_ -p <port> <host> _gerrit create-project_
   [--owner <GROUP> ... | -o <GROUP> ...]
   [--parent <NAME> | -p <NAME> ]
   [--suggest-parents | -S ]
@@ -170,15 +171,15 @@
 == EXAMPLES
 Create a new project called `tools/gerrit`:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit create-project tools/gerrit.git
-====
+----
 
 Create a new project with a description:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit create-project tool.git --description "'Tools used by build system'"
-====
+----
 
 Note that it is necessary to quote the description twice.  The local
 shell needs double quotes around the value to ensure the single quotes
@@ -189,9 +190,9 @@
 If the replication plugin is installed, the plugin will attempt to
 perform remote repository creation by a Bourne shell script:
 
-====
+----
   mkdir -p '/base/project.git' && cd '/base/project.git' && git init --bare && git update-ref HEAD refs/heads/master
-====
+----
 
 For this to work successfully the remote system must be able to run
 arbitrary shell scripts, and must have `git` in the user's PATH
diff --git a/Documentation/cmd-flush-caches.txt b/Documentation/cmd-flush-caches.txt
index aa9790d..4716f3b 100644
--- a/Documentation/cmd-flush-caches.txt
+++ b/Documentation/cmd-flush-caches.txt
@@ -4,10 +4,11 @@
 gerrit flush-caches - Flush some/all server caches from memory
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit flush-caches' --all
-'ssh' -p <port> <host> 'gerrit flush-caches' --list
-'ssh' -p <port> <host> 'gerrit flush-caches' --cache <NAME> ...
+_ssh_ -p <port> <host> _gerrit flush-caches_ --all
+_ssh_ -p <port> <host> _gerrit flush-caches_ --list
+_ssh_ -p <port> <host> _gerrit flush-caches_ --cache <NAME> ...
 --
 
 == DESCRIPTION
@@ -56,7 +57,7 @@
 == EXAMPLES
 List caches available for flushing:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit flush-caches --list
 	accounts
 	accounts_byemail
@@ -67,32 +68,32 @@
 	projects
 	sshkeys
 	web_sessions
-====
+----
 
 Flush all caches known to the server, forcing them to recompute:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit flush-caches --all
-====
+----
 
 or
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit flush-caches
-====
+----
 
 Flush only the "sshkeys" cache, after manually editing an SSH key
 for a user:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit flush-caches --cache sshkeys
-====
+----
 
 Flush "web_sessions", forcing all users to sign-in again:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit flush-caches --cache web_sessions
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-gc.txt b/Documentation/cmd-gc.txt
index b7388a1..1d1cc00 100644
--- a/Documentation/cmd-gc.txt
+++ b/Documentation/cmd-gc.txt
@@ -4,8 +4,9 @@
 gerrit gc - Run the Git garbage collection
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit gc'
+_ssh_ -p <port> <host> _gerrit gc_
   [--all]
   [--show-progress]
   [--aggressive]
@@ -52,7 +53,7 @@
 
 Run the Git garbage collection for the projects 'myProject' and
 'yourProject':
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit gc myProject yourProject
 	collecting garbage for "myProject":
 	...
@@ -61,12 +62,12 @@
 	collecting garbage for "yourProject":
 	...
 	done.
-=====
+----
 
 Run the Git garbage collection for all projects:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit gc --all
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-gsql.txt b/Documentation/cmd-gsql.txt
index 411eb00..d2eb783 100644
--- a/Documentation/cmd-gsql.txt
+++ b/Documentation/cmd-gsql.txt
@@ -4,8 +4,9 @@
 gerrit gsql - Administrative interface to active database
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit gsql'
+_ssh_ -p <port> <host> _gerrit gsql_
   [--format {PRETTY | JSON | JSON_SINGLE}]
   [-c QUERY]
 --
@@ -40,7 +41,7 @@
 == EXAMPLES
 To manually correct a user's SSH user name:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit gsql
 	Welcome to Gerrit Code Review v2.0.25
 	(PostgreSQL 8.3.8)
@@ -53,7 +54,7 @@
 	Bye
 
 	$ ssh -p 29418 review.example.com gerrit flush-caches --cache sshkeys --cache accounts
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index e102186..ffdd5da 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -63,26 +63,26 @@
 
 You can use either of the below commands:
 
-====
+----
   $ scp -p -P 29418 <your username>@<your Gerrit review server>:hooks/commit-msg <local path to your git>/.git/hooks/
 
   $ curl -Lo <local path to your git>/.git/hooks/commit-msg <your Gerrit http URL>/tools/hooks/commit-msg
-====
+----
 
 A specific example of this might look something like this:
 
 .Example
-====
+----
   $ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg ~/duhproject/.git/hooks/
 
   $ curl -Lo ~/duhproject/.git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
-====
+----
 
 Make sure the hook file is executable:
 
-====
+----
   $ chmod u+x ~/duhproject/.git/hooks/commit-msg
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-index-activate.txt b/Documentation/cmd-index-activate.txt
index aafbd19..418e872 100644
--- a/Documentation/cmd-index-activate.txt
+++ b/Documentation/cmd-index-activate.txt
@@ -4,8 +4,9 @@
 gerrit index activate - Activate the latest index version available
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit index activate'
+_ssh_ -p <port> <host> _gerrit index activate <INDEX>_
 --
 
 == DESCRIPTION
@@ -24,6 +25,20 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+<INDEX>::
+  The index to activate.
+  Currently supported values:
+    * changes
+    * accounts
+
+== EXAMPLES
+Activate the latest change index:
+
+----
+  $ ssh -p 29418 review.example.com gerrit activate changes
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-index-changes.txt b/Documentation/cmd-index-changes.txt
new file mode 100644
index 0000000..d38c51a
--- /dev/null
+++ b/Documentation/cmd-index-changes.txt
@@ -0,0 +1,41 @@
+= gerrit index changes
+
+== NAME
+gerrit index changes - Index one or more changes.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit index changes_ <CHANGE> [<CHANGE> ...]
+--
+
+== DESCRIPTION
+Indexes one or more changes.
+
+Changes can be specified in the link:rest-api-changes.html#change-id[same format]
+supported by the REST API.
+
+== ACCESS
+Caller must have the 'Maintain Server' capability, or be the owner of the change
+to be indexed.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+--CHANGE::
+    Required; changes to be indexed.
+
+== EXAMPLES
+Index changes with legacy ID numbers 1 and 2.
+
+----
+    $ ssh -p 29418 user@review.example.com gerrit index changes 1 2
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-index-start.txt b/Documentation/cmd-index-start.txt
index 1e4b24b..fbe4f3f 100644
--- a/Documentation/cmd-index-start.txt
+++ b/Documentation/cmd-index-start.txt
@@ -4,8 +4,9 @@
 gerrit index start - Start the online indexer
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit index start'
+_ssh_ -p <port> <host> _gerrit index start_ <INDEX> [--force]
 --
 
 == DESCRIPTION
@@ -25,6 +26,23 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+<INDEX>::
+  Restart the online indexer on this secondary index.
+  Currently supported values:
+    * changes
+    * accounts
+
+--force::
+  Force an online re-index.
+
+== EXAMPLES
+Start the online indexer for the 'changes' index:
+
+----
+  $ ssh -p 29418 review.example.com gerrit index start changes
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 90212fb..7af65ce 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -7,11 +7,13 @@
 
 To download a client command or hook, use scp or an http client:
 
+----
   $ scp -p -P 29418 john.doe@review.example.com:bin/gerrit-cherry-pick ~/bin/
   $ scp -p -P 29418 john.doe@review.example.com:hooks/commit-msg .git/hooks/
 
   $ curl -Lo ~/bin/gerrit-cherry-pick http://review.example.com/tools/bin/gerrit-cherry-pick
   $ curl -Lo .git/hooks/commit-msg http://review.example.com/tools/hooks/commit-msg
+----
 
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
@@ -38,7 +40,9 @@
 not provide an interactive shell, the commands must be triggered
 from an ssh client, for example:
 
+----
   $ ssh -p 29418 review.example.com gerrit ls-projects
+----
 
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
@@ -78,6 +82,9 @@
 link:cmd-set-head.html[gerrit set-head]::
 	Change the HEAD reference of a project.
 
+link:cmd-set-project.html[gerrit set-project]::
+	Change a project's settings.
+
 link:cmd-set-reviewers.html[gerrit set-reviewers]::
 	Add or remove reviewers on a change.
 
@@ -126,6 +133,9 @@
 link:cmd-index-start.html[gerrit index start]::
 	Start the online indexer.
 
+link:cmd-index-changes.html[gerrit index changes]::
+	Index one or more changes.
+
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
 	List loggers and their logging level.
 
@@ -162,9 +172,6 @@
 link:cmd-set-members.html[gerrit set-members]::
 	Set group members.
 
-link:cmd-set-project.html[gerrit set-project]::
-	Change a project's settings.
-
 link:cmd-set-project-parent.html[gerrit set-project-parent]::
 	Change the project permissions are inherited from.
 
diff --git a/Documentation/cmd-kill.txt b/Documentation/cmd-kill.txt
index c64c537..ac8e802 100644
--- a/Documentation/cmd-kill.txt
+++ b/Documentation/cmd-kill.txt
@@ -4,8 +4,9 @@
 kill - Cancel or abort a background task
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'kill' <ID> ...
+_ssh_ -p <port> <host> _kill_ <ID> ...
 --
 
 == DESCRIPTION
diff --git a/Documentation/cmd-logging-ls-level.txt b/Documentation/cmd-logging-ls-level.txt
index c59dc3f..ee015bb 100644
--- a/Documentation/cmd-logging-ls-level.txt
+++ b/Documentation/cmd-logging-ls-level.txt
@@ -6,8 +6,9 @@
 gerrit logging ls - view the logging level
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit logging ls-level | ls'
+_ssh_ -p <port> <host> _gerrit logging ls-level_ | _ls_
   <NAME>
 --
 
@@ -25,15 +26,15 @@
 == Examples
 
 View the logging level of the loggers in the package com.google:
-=====
+----
     $ssh -p 29418 review.example.com gerrit logging ls-level \
      com.google.
-=====
+----
 
 View the logging level of every logger
-=====
+----
     $ssh -p 29418 review.example.com gerrit logging ls-level
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-logging-set-level.txt b/Documentation/cmd-logging-set-level.txt
index 38062cb..5baa968 100644
--- a/Documentation/cmd-logging-set-level.txt
+++ b/Documentation/cmd-logging-set-level.txt
@@ -6,8 +6,9 @@
 gerrit logging set - set the logging level
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit logging set-level | set'
+_ssh_ -p <port> <host> _gerrit logging set-level_ | _set_
   <LEVEL>
   <NAME>
 --
@@ -32,16 +33,16 @@
 == Examples
 
 Change the logging level of the loggers in the package com.google to DEBUG.
-=====
+----
     $ssh -p 29418 review.example.com gerrit logging set-level \
      debug com.google.
-=====
+----
 
 Reset the logging level of every logger to what they were at deployment time.
-=====
+----
     $ssh -p 29418 review.example.com gerrit logging set-level \
      reset
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index 651cebe..d8eef8b 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -4,8 +4,9 @@
 gerrit ls-groups - List groups visible to caller
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit ls-groups'
+_ssh_ -p <port> <host> _gerrit ls-groups_
   [--project <NAME> | -p <NAME>]
   [--user <NAME> | -u <NAME>]
   [--owned]
@@ -86,55 +87,55 @@
 == EXAMPLES
 
 List visible groups:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-groups
 	Administrators
 	Anonymous Users
 	MyProject_Committers
 	Project Owners
 	Registered Users
-=====
+----
 
 List all groups for which any permission is set for the project
 "MyProject":
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-groups --project MyProject
 	MyProject_Committers
 	Project Owners
 	Registered Users
-=====
+----
 
 List all groups which are owned by the calling user:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-groups --owned
 	MyProject_Committers
 	MyProject_Verifiers
-=====
+----
 
 Check if the calling user owns the group `MyProject_Committers`. If
 `MyProject_Committers` is returned the calling user owns this group.
 If the result is empty, the calling user doesn't own the group.
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-groups --owned -q MyProject_Committers
 	MyProject_Committers
-=====
+----
 
 Extract the UUID of the 'Administrators' group:
 
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $2}'
 	ad463411db3eec4e1efb0d73f55183c1db2fd82a
-=====
+----
 
 Extract and expand the multi-line description of the 'Administrators'
 group:
 
-=====
+----
 	$ printf "$(ssh -p 29418 review.example.com gerrit ls-groups -v | awk '-F\t' '$1 == "Administrators" {print $3}')\n"
 	This is a
 	multi-line
 	description.
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-ls-members.txt b/Documentation/cmd-ls-members.txt
index f8708d3..a6d492c 100644
--- a/Documentation/cmd-ls-members.txt
+++ b/Documentation/cmd-ls-members.txt
@@ -4,8 +4,9 @@
 gerrit ls-members - Show members of a given group
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit ls-members GROUPNAME'
+_ssh_ -p <port> <host> _gerrit ls-members_ GROUPNAME
   [--recursive]
 --
 
@@ -38,19 +39,19 @@
 == EXAMPLES
 
 List members of the Administrators group:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-members Administrators
 	id      username  full name    email
 	100000  jim     Jim Bob somebody@example.com
 	100001  johnny  John Smith      n/a
 	100002  mrnoname        n/a     someoneelse@example.com
-=====
+----
 
 List members of a non-existent group:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-members BadlySpelledGroup
 	Group not found or not visible
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index 26b20a7..e2e71ff 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -4,8 +4,9 @@
 gerrit ls-projects - List projects visible to caller
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit ls-projects'
+_ssh_ -p <port> <host> _gerrit ls-projects_
   [--show-branch <BRANCH> ...]
   [--description | -d]
   [--tree | -t]
@@ -58,7 +59,7 @@
 
 --type::
 	Display only projects of the specified type.  If not
-	specified, defaults to `code`. Supported types:
+	specified, defaults to `all`. Supported types:
 +
 --
 `code`:: Any project likely to contain user files.
@@ -113,7 +114,7 @@
 == EXAMPLES
 
 List visible projects:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-projects
 	platform/manifest
 	tools/gerrit
@@ -127,16 +128,16 @@
 	$ curl http://review.example.com/projects/tools/
 	tools/gerrit
 	tools/gwtorm
-=====
+----
 
 Clone any project visible to the user:
-====
+----
 	for p in `ssh -p 29418 review.example.com gerrit ls-projects`
 	do
 	  mkdir -p `dirname "$p"`
 	  git clone --bare "ssh://review.example.com:29418/$p.git" "$p.git"
 	done
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-ls-user-refs.txt b/Documentation/cmd-ls-user-refs.txt
index 11781de..1a87fc9 100644
--- a/Documentation/cmd-ls-user-refs.txt
+++ b/Documentation/cmd-ls-user-refs.txt
@@ -4,8 +4,9 @@
 gerrit ls-user-refs - List refs visible to a specific user
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit ls-user-refs'
+_ssh_ -p <port> <host> _gerrit ls-user-refs_
   [--project PROJECT> | -p <PROJECT>]
   [--user <USER> | -u <USER>]
   [--only-refs-heads]
@@ -40,9 +41,9 @@
 == EXAMPLES
 
 List visible refs for the user "mr.developer" in project "gerrit"
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit ls-user-refs -p gerrit -u mr.developer
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-plugin-enable.txt b/Documentation/cmd-plugin-enable.txt
index c8022ef..9b52736 100644
--- a/Documentation/cmd-plugin-enable.txt
+++ b/Documentation/cmd-plugin-enable.txt
@@ -4,8 +4,9 @@
 plugin enable - Enable plugins.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit plugin enable'
+_ssh_ -p <port> <host> _gerrit plugin enable_
   <NAME> ...
 --
 
@@ -30,9 +31,9 @@
 == EXAMPLES
 Enable a plugin:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin enable my-plugin
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-plugin-install.txt b/Documentation/cmd-plugin-install.txt
index 0ce6d7d..5443613 100644
--- a/Documentation/cmd-plugin-install.txt
+++ b/Documentation/cmd-plugin-install.txt
@@ -6,8 +6,9 @@
 plugin add - Install/Add a plugin.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit plugin install | add'
+_ssh_ -p <port> <host> _gerrit plugin install_ | _add_
   [--name <NAME> | -n <NAME>]
   - | <URL> | <PATH>
 --
@@ -44,31 +45,31 @@
 == EXAMPLES
 Install a plugin from an absolute file path on the server's host:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin install -n name.jar \
 	  $(pwd)/my-plugin.jar
-====
+----
 
-Install a WebUi plugin from an absolute file path on the server's host:
+Install a WebUI plugin from an absolute file path on the server's host:
 
-====
+----
   ssh -p 29418 localhost gerrit plugin install -n name.js \
     $(pwd)/my-webui-plugin.js
-====
+----
 
 Install a plugin from an HTTP site:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin install -n name.jar \
 	  http://build-server/output/our-plugin
-====
+----
 
 Install a plugin from piped input:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin install -n name.jar \
 	  - <target/name-0.1.jar
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-plugin-ls.txt b/Documentation/cmd-plugin-ls.txt
index 234ce87..d329db5 100644
--- a/Documentation/cmd-plugin-ls.txt
+++ b/Documentation/cmd-plugin-ls.txt
@@ -4,8 +4,9 @@
 plugin ls - List the installed plugins.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit plugin ls'
+_ssh_ -p <port> <host> _gerrit plugin ls_
   [--all | -a]
   [--format {text | json | json_compact}]
 --
diff --git a/Documentation/cmd-plugin-reload.txt b/Documentation/cmd-plugin-reload.txt
index 88cb1f3..ad1e5e7 100644
--- a/Documentation/cmd-plugin-reload.txt
+++ b/Documentation/cmd-plugin-reload.txt
@@ -4,8 +4,9 @@
 plugin reload - Reload/Restart plugins.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit plugin reload'
+_ssh_ -p <port> <host> _gerrit plugin reload_
   <NAME> ...
 --
 
@@ -34,9 +35,9 @@
 == EXAMPLES
 Reload a plugin:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin reload my-plugin
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-plugin-remove.txt b/Documentation/cmd-plugin-remove.txt
index 770df85..805c7b4 100644
--- a/Documentation/cmd-plugin-remove.txt
+++ b/Documentation/cmd-plugin-remove.txt
@@ -6,8 +6,9 @@
 plugin rm - Disable plugins.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit plugin remove | rm'
+_ssh_ -p <port> <host> _gerrit plugin remove_ | _rm_
   <NAME> ...
 --
 
@@ -31,9 +32,9 @@
 == EXAMPLES
 Disable a plugin:
 
-====
+----
 	ssh -p 29418 localhost gerrit plugin remove my-plugin
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-query.txt b/Documentation/cmd-query.txt
index 090781b..1faf1b0 100644
--- a/Documentation/cmd-query.txt
+++ b/Documentation/cmd-query.txt
@@ -4,8 +4,9 @@
 gerrit query - Query the change database
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit query'
+_ssh_ -p <port> <host> _gerrit query_
   [--format {TEXT | JSON}]
   [--current-patch-set]
   [--patch-sets | --all-approvals]
@@ -115,20 +116,20 @@
 == EXAMPLES
 
 Find the 2 most recent open changes in the tools/gerrit project:
-====
+----
   $ ssh -p 29418 review.example.com gerrit query --format=JSON status:open project:tools/gerrit limit:2
   {"project":"tools/gerrit", ...}
   {"project":"tools/gerrit", ...}
   {"type":"stats","rowCount":2,"runningTimeMilliseconds:15}
-====
+----
 
 Skip number of changes:
-====
+----
   $ ssh -p 29418 review.example.com gerrit query --format=JSON --start 42 status:open project:tools/gerrit limit:2
   {"project":"tools/gerrit", ...}
   {"project":"tools/gerrit", ...}
   {"type":"stats","rowCount":1,"runningTimeMilliseconds:15}
-====
+----
 
 
 == SCHEMA
diff --git a/Documentation/cmd-receive-pack.txt b/Documentation/cmd-receive-pack.txt
index f3b4f02..798f872 100644
--- a/Documentation/cmd-receive-pack.txt
+++ b/Documentation/cmd-receive-pack.txt
@@ -4,8 +4,9 @@
 git-receive-pack - Receive what is pushed into the repository
 
 == SYNOPSIS
+[verse]
 --
-'git receive-pack'
+_git receive-pack_
   [--reviewer <address> | --re <address>]
   [--cc <address>]
   <project>
@@ -41,25 +42,25 @@
 == EXAMPLES
 
 Send a review for a change on the master branch to charlie@example.com:
-=====
+----
 	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com
-=====
+----
 
 Send reviews, but tagging them with the topic name 'bug42':
-=====
+----
 	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,topic=bug42
-=====
+----
 
 Also CC two other parties:
-=====
+----
 	git push ssh://review.example.com:29418/project HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
-=====
+----
 
 Configure a push macro to perform the last action:
-====
+----
 	git config remote.charlie.url ssh://review.example.com:29418/project
 	git config remote.charlie.push HEAD:refs/for/master%r=charlie@example.com,cc=alice@example.com,cc=bob@example.com
-====
+----
 
 afterwards `.git/config` contains the following:
 ----
@@ -70,9 +71,9 @@
 
 and now sending a new change for review to charlie, CC'ing both
 alice and bob is much easier:
-====
+----
 	git push charlie
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-rename-group.txt b/Documentation/cmd-rename-group.txt
index 9578458..a48014c 100644
--- a/Documentation/cmd-rename-group.txt
+++ b/Documentation/cmd-rename-group.txt
@@ -4,8 +4,9 @@
 gerrit rename-group - Rename an account group.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit rename-group'
+_ssh_ -p <port> <host> _gerrit rename-group_
   <GROUP>
   <NEWNAME>
 --
@@ -30,9 +31,9 @@
 == EXAMPLES
 Rename the group "MyGroup" to "MyCommitters".
 
-====
+----
 	$ ssh -p 29418 user@review.example.com gerrit rename-group MyGroup MyCommitters
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 0590337..53e2385 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -1,12 +1,12 @@
-gerrit review
-==============
+= gerrit review
 
 == NAME
 gerrit review - Apply reviews to one or more patch sets
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit review'
+_ssh_ -p <port> <host> _gerrit review_
   [--project <PROJECT> | -p <PROJECT>]
   [--branch <BRANCH> | -b <BRANCH>]
   [--message <MESSAGE> | -m <MESSAGE>]
@@ -14,11 +14,13 @@
   [--submit | -s]
   [--abandon | --restore]
   [--rebase]
+  [--move <BRANCH>]
   [--publish]
   [--json | -j]
   [--delete]
   [--verified <N>] [--code-review <N>]
   [--label Label-Name=<N>]
+  [--tag TAG]
   {COMMIT | CHANGEID,PATCHSET}...
 --
 
@@ -65,7 +67,7 @@
 	link:rest-api-changes.html#review-input[ReviewInput] entity for the
 	format.
 	(option is mutually exclusive with --submit, --restore, --publish, --delete,
-	--abandon, --message and --rebase)
+	--abandon, --message, --rebase and --move)
 
 --notify::
 -n::
@@ -87,7 +89,7 @@
 --abandon::
 	Abandon the specified change(s).
 	(option is mutually exclusive with --submit, --restore, --publish, --delete,
-	--rebase and --json)
+	--rebase, --move and --json)
 
 --restore::
 	Restore the specified abandoned change(s).
@@ -97,6 +99,10 @@
 	Rebase the specified change(s).
 	(option is mutually exclusive with --abandon, --submit, --delete and --json)
 
+--move::
+	Move the specified change(s).
+	(option is mutually exclusive with --json and --abandon)
+
 --submit::
 -s::
 	Submit the specified patch set(s) for merging.
@@ -134,6 +140,15 @@
 	permitted for the user, or the vote is on an outdated or closed patch set,
 	return an error instead of silently discarding the vote.
 
+--tag::
+-t::
+  Apply a 'TAG' to the change message, votes, and inline comments. The 'TAG'
+  can represent an external system like CI that does automated verification
+  of the change. Comments with specific 'TAG' values can be filtered out in
+  the web UI.
+  Note that to apply different tags on on different votes/comments, multiple
+  invocations of the SSH command are required.
+
 == ACCESS
 Any user who has configured an SSH key.
 
@@ -143,37 +158,37 @@
 == EXAMPLES
 
 Approve the change with commit c0ff33 as "Verified +1"
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit review --verified +1 c0ff33
-=====
+----
 
 Vote on the project specific label "mylabel":
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 c0ff33
-=====
+----
 
 Append the message "Build Successful". Notice two levels of quoting is
 required, one for the local shell, and another for the argument parser
 inside the Gerrit server:
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit review -m '"Build Successful"' c0ff33
-=====
+----
 
 Mark the unmerged commits both "Verified +1" and "Code-Review +2" and
 submit them for merging:
-====
+----
   $ ssh -p 29418 review.example.com gerrit review \
     --verified +1 \
     --code-review +2 \
     --submit \
     --project this/project \
     $(git rev-list origin/master..HEAD)
-====
+----
 
 Abandon an active change:
-====
+----
   $ ssh -p 29418 review.example.com gerrit review --abandon c0ff33
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 8fb8e0d..884c8cc 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -4,14 +4,16 @@
 gerrit set-account - Change an account's settings.
 
 == SYNOPSIS
+[verse]
 --
-set-account [--full-name <FULLNAME>] [--active|--inactive] \
-            [--add-email <EMAIL>] [--delete-email <EMAIL> | ALL] \
-            [--preferred-email <EMAIL>] \
-            [--add-ssh-key - | <KEY>] \
-            [--delete-ssh-key - | <KEY> | ALL] \
-            [--http-password <PASSWORD>] \
-            [--clear-http-password] <USER>
+_ssh_ -p <port> <host> _gerrit set-account_
+  [--full-name <FULLNAME>] [--active|--inactive]
+  [--add-email <EMAIL>] [--delete-email <EMAIL> | ALL]
+  [--preferred-email <EMAIL>]
+  [--add-ssh-key - | <KEY>]
+  [--delete-ssh-key - | <KEY> | ALL]
+  [--http-password <PASSWORD>]
+  [--clear-http-password] <USER>
 --
 
 == DESCRIPTION
@@ -100,9 +102,9 @@
 == EXAMPLES
 Add an email and SSH key to `watcher`'s account:
 
-====
+----
     $ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit set-account --add-ssh-key - --add-email mail@example.com watcher
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-set-head.txt b/Documentation/cmd-set-head.txt
index d74caaa..f444173 100644
--- a/Documentation/cmd-set-head.txt
+++ b/Documentation/cmd-set-head.txt
@@ -4,8 +4,9 @@
 gerrit set-head - Change a project's HEAD.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit set-head' <NAME>
+_ssh_ -p <port> <host> _gerrit set-head_ <NAME>
   --new-head <REF>
 --
 
@@ -33,9 +34,9 @@
 == EXAMPLES
 Change HEAD of project `example` to `stable-2.11` branch:
 
-====
+----
     $ ssh -p 29418 review.example.com gerrit set-head example --new-head stable-2.11
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-set-members.txt b/Documentation/cmd-set-members.txt
index 174a25a..ae44843 100644
--- a/Documentation/cmd-set-members.txt
+++ b/Documentation/cmd-set-members.txt
@@ -4,8 +4,9 @@
 gerrit set-members - Set group members
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit set-members'
+_ssh_ -p <port> <host> _gerrit set-members_
   [--add USER ...]
   [--remove USER ...]
   [--include GROUP ...]
@@ -57,18 +58,18 @@
 
 Add alice and bob, but remove eve from the groups my-committers and
 my-verifiers.
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit set-members \
 	  -a alice@example.com -a bob@example.com \
 	  -r eve@example.com my-committers my-verifiers
-=====
+----
 
 Include the group my-friends into the group my-committers, but
 exclude the included group my-testers from the group my-committers.
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit set-members \
 	  -i my-friends -e my-testers my-committers
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-set-project-parent.txt b/Documentation/cmd-set-project-parent.txt
index 70918b2..6e2328c 100644
--- a/Documentation/cmd-set-project-parent.txt
+++ b/Documentation/cmd-set-project-parent.txt
@@ -4,8 +4,9 @@
 gerrit set-project-parent - Change the project permissions are inherited from.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit set-project-parent'
+_ssh_ -p <port> <host> _gerrit set-project-parent_
   [--parent <NAME>]
   [--children-of <NAME>]
   [--exclude <NAME>]
@@ -45,16 +46,16 @@
 == EXAMPLES
 Configure `kernel/omap` to inherit permissions from `kernel/common`:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit set-project-parent --parent kernel/common kernel/omap
-====
+----
 
 Reparent all children of `myParent` to `myOtherParent`:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit set-project-parent \
 	  --children-of myParent --parent myOtherParent
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-set-project.txt b/Documentation/cmd-set-project.txt
index 2b64d77..62d6e92 100644
--- a/Documentation/cmd-set-project.txt
+++ b/Documentation/cmd-set-project.txt
@@ -4,8 +4,9 @@
 gerrit set-project - Change a project's settings.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit set-project'
+_ssh_ -p <port> <host> _gerrit set-project_
   [--description <DESC> | -d <DESC>]
   [--submit-type <TYPE> | -t <TYPE>]
   [--contributor-agreements <true|false|inherit>]
@@ -25,7 +26,7 @@
 previous settings are kept intact.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group.
+Caller must be an owner of the given project.
 
 == SCRIPTING
 This command is intended to be used in scripts.
@@ -102,10 +103,10 @@
 Change project `example` to be hidden, require change id, don't use content merge
 and use 'merge if necessary' as merge strategy:
 
-====
+----
     $ ssh -p 29418 review.example.com gerrit set-project example --submit-type MERGE_IF_NECESSARY\
     --change-id true --content-merge false --project-state HIDDEN
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-set-reviewers.txt b/Documentation/cmd-set-reviewers.txt
index 79f7651..3d53456 100644
--- a/Documentation/cmd-set-reviewers.txt
+++ b/Documentation/cmd-set-reviewers.txt
@@ -4,23 +4,23 @@
 gerrit set-reviewers - Add or remove reviewers to a change
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit set-reviewers'
+_ssh_ -p <port> <host> _gerrit set-reviewers_
   [--project <PROJECT> | -p <PROJECT>]
   [--add <REVIEWER> ... | -a <REVIEWER> ...]
   [--remove <REVIEWER> ... | -r <REVIEWER> ...]
   [--]
-  {COMMIT | CHANGE-ID}...
+  {CHANGE-ID}...
 --
 
 == DESCRIPTION
 Adds or removes reviewers to the specified change, sending email
 notifications when changes are made.
 
-Changes should be specified as complete or abbreviated Change-Ids
-such as 'Iac6b2ac2'.  They may also be specified by numeric change
-identifiers, such as '8242' or by complete or abbreviated commit
-SHA-1s.
+Changes can be specified in the
+link:rest-api-changes.html#change-id[same format] supported by the REST
+API.
 
 == OPTIONS
 
@@ -55,27 +55,27 @@
 == EXAMPLES
 
 Add reviewers alice and bob, but remove eve from change Iac6b2ac2.
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit set-reviewers \
 	  -a alice@example.com -a bob@example.com \
 	  -r eve@example.com \
 	  Iac6b2ac2
-=====
+----
 
 Add reviewer elvis to old-style change id 1935 specifying that the change is in project "graceland"
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit set-reviewers \
 	  --project graceland \
 	  -a elvis@example.com \
 	  1935
-=====
+----
 
 Add all project owners as reviewers to change Iac6b2ac2.
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit set-reviewers \
 	  -a "'Project Owners'" \
 	  Iac6b2ac2
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 5d6ab20..59abc1c 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -1,12 +1,14 @@
-gerrit show-caches
-===================
+= gerrit show-caches
 
 == NAME
 gerrit show-caches - Display current cache statistics
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit show-caches' [--gc] [--show-jvm]
+_ssh_ -p <port> <host> _gerrit show-caches_
+  [--gc]
+  [--show-jvm]
 --
 
 == DESCRIPTION
@@ -48,7 +50,7 @@
 
 == EXAMPLES
 
-====
+----
   $ ssh -p 29418 review.example.com gerrit show-caches
   Gerrit Code Review        2.9                       now   11:14:13   CEST
                                                    uptime    6 days 20 hrs
@@ -87,7 +89,7 @@
            107 open files
 
   Threads: 4 CPUs available, 371 threads
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/cmd-show-connections.txt b/Documentation/cmd-show-connections.txt
index 81eb174..2f70e3c 100644
--- a/Documentation/cmd-show-connections.txt
+++ b/Documentation/cmd-show-connections.txt
@@ -4,8 +4,10 @@
 gerrit show-connections - Display active client SSH connections
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit show-connections' [--numeric | -n]
+_ssh_ -p <port> <host> _gerrit show-connections_
+  [--numeric | -n]
 --
 
 == DESCRIPTION
@@ -40,7 +42,7 @@
 
 Start::
 	Time (local to the server) that this connection started.
-	Only valid for MINA backend.
+	Only shown for MINA backend.
 
 Idle::
 	Time since the last data transfer on this connection.
@@ -48,7 +50,7 @@
 	connection keep-alive, but also an encrypted keep alive
 	higher up in the SSH protocol stack.  That higher keep
 	alive resets the idle timer, about once a minute.
-	Only valid for MINA backend.
+	Only shown for MINA backend.
 
 User::
 	The username of the account that is authenticated on this
@@ -62,22 +64,22 @@
 == EXAMPLES
 
 With reverse DNS lookup (default):
-====
+----
 	$ ssh -p 29418 review.example.com gerrit show-connections
 	Session     Start     Idle   User            Remote Host
 	--------------------------------------------------------------
 	3abf31e6 20:09:02 00:00:00  jdoe            jdoe-desktop.example.com
 	--
-====
+----
 
 Without reverse DNS lookup:
-====
+----
 	$ ssh -p 29418 review.example.com gerrit show-connections -n
 	Session     Start     Idle   User            Remote Host
 	--------------------------------------------------------------
 	3abf31e6 20:09:02 00:00:00  a/1001240       10.0.0.1
 	--
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-show-queue.txt b/Documentation/cmd-show-queue.txt
index e3f44ab..02f1c5b 100644
--- a/Documentation/cmd-show-queue.txt
+++ b/Documentation/cmd-show-queue.txt
@@ -4,9 +4,10 @@
 gerrit show-queue - Display the background work queues, including replication
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit show-queue'
-'ssh' -p <port> <host> 'ps'
+_ssh_ -p <port> <host> _gerrit show-queue_
+_ssh_ -p <port> <host> _ps_
 --
 
 == DESCRIPTION
@@ -38,6 +39,10 @@
 	Do not format the output to the terminal width (default of
 	80 columns).
 
+--by-queue::
+-q::
+	Group tasks by queue and print queue info.
+
 == DISPLAY
 
 Task::
@@ -69,7 +74,7 @@
 `tools/gerrit.git` project to two different remote systems, `dst1`
 and `dst2`:
 
-====
+----
 	$ ssh -p 29418 review.example.com gerrit show-queue
 	Task     State                 Command
 	------------------------------------------------------------------------------
@@ -77,7 +82,7 @@
 	9ad09d27 14:31:25.434          mirror dst2:/var/cache/tools/gerrit.git
 	------------------------------------------------------------------------------
 	  2 tasks
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index dcdbb07..1cfb8b9 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -4,8 +4,9 @@
 gerrit stream-events - Monitor events occurring in real time
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit stream-events'
+_ssh_ -p <port> <host> _gerrit stream-events_
 --
 
 == DESCRIPTION
@@ -26,13 +27,28 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+--subscribe|-s::
+	Type of the event to subscribe to.  Multiple --subscribe options
+	may be specified to subscribe to multiple events. When this option
+	is provided, only subscribed events are emitted and all other
+	events are ignored. When this option is omitted, all events are
+	emitted.
+
 == EXAMPLES
 
-====
+----
   $ ssh -p 29418 review.example.com gerrit stream-events
   {"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
   {"type":"comment-added",change:{"project":"tools/gerrit", ...}, ...}
-====
+----
+
+Only subscribe to specific event types:
+
+----
+  $ ssh -p 29418 review.example.com gerrit stream-events \
+      -s draft-published -s patchset-created -s ref-replicated
+----
 
 == SCHEMA
 The JSON messages consist of nested objects referencing the *change*,
@@ -166,23 +182,6 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
-=== Merge Failed
-
-Sent when a change has failed to be merged into the git repository.
-
-type:: "merge-failed"
-
-change:: link:json.html#change[change attribute]
-
-patchSet:: link:json.html#patchSet[patchSet attribute]
-
-submitter:: link:json.html#account[account attribute]
-
-reason:: Reason that the merge failed.
-
-eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
-created.
-
 === Patchset Created
 
 Sent when a new change has been uploaded, or a new patch set has been uploaded
@@ -232,6 +231,27 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Reviewer Deleted
+
+Sent when a reviewer (with a vote) is removed from a change.
+
+type:: "reviewer-deleted"
+
+change:: link:json.html#change[change attribute]
+
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
+reviewer:: link:json.html#account[account attribute]
+
+author:: link:json.html#account[account attribute]
+
+approvals:: All link:json.html#approval[approval attributes] removed.
+
+comment:: Review comment cover message.
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Topic Changed
 
 Sent when the topic of a change has been changed.
diff --git a/Documentation/cmd-suexec.txt b/Documentation/cmd-suexec.txt
index f6ee753..16338ba 100644
--- a/Documentation/cmd-suexec.txt
+++ b/Documentation/cmd-suexec.txt
@@ -4,11 +4,12 @@
 suexec - Execute a command as any registered user account
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port>
+_ssh_ -p <port>
   -i SITE_PATH/etc/ssh_host_rsa_key
-  '"Gerrit Code Review@localhost"'
-  'suexec'
+  "Gerrit Code Review@localhost"
+  _suexec_
   --as <EMAIL>
   [--from HOST:PORT]
   [--]
@@ -47,7 +48,7 @@
 == EXAMPLES
 
 Approve the change with commit c0ff33 as "Verified +1" as user bob@example.com
-=====
+----
   $ sudo -u gerrit ssh -p 29418 \
     -i site_path/etc/ssh_host_rsa_key \
     "Gerrit Code Review@localhost" \
@@ -55,7 +56,7 @@
     --as bob@example.com \
     -- \
     gerrit approve --verified +1 c0ff33
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/cmd-test-submit-rule.txt b/Documentation/cmd-test-submit-rule.txt
index a9a1bc4..b8c4380 100644
--- a/Documentation/cmd-test-submit-rule.txt
+++ b/Documentation/cmd-test-submit-rule.txt
@@ -4,8 +4,9 @@
 gerrit test-submit rule - Test prolog submit rules with a chosen changeset.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit test-submit rule'
+_ssh_ -p <port> <host> _gerrit test-submit_ rule
   [-s]
   [--no-filters]
   CHANGE
@@ -27,7 +28,7 @@
 == EXAMPLES
 
 Test submit_rule from stdin and return the results as JSON.
-====
+----
  cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit rule -s I78f2c6673db24e4e92ed32f604c960dc952437d9
  [
    {
@@ -37,10 +38,10 @@
      }
    }
  ]
-====
+----
 
 Test the active submit_rule from the refs/meta/config branch, ignoring filters in the project parents.
-====
+----
  $ ssh -p 29418 review.example.com gerrit test-submit rule I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
  [
    {
@@ -51,7 +52,7 @@
      }
    }
  ]
-====
+----
 
 == SCRIPTING
 Can be used either interactively for testing new prolog submit rules, or from a script to check the submit status of a change.
diff --git a/Documentation/cmd-test-submit-type.txt b/Documentation/cmd-test-submit-type.txt
index 658d43b..508684f 100644
--- a/Documentation/cmd-test-submit-type.txt
+++ b/Documentation/cmd-test-submit-type.txt
@@ -4,8 +4,9 @@
 gerrit test-submit type - Test prolog submit type with a chosen change.
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit test-submit type'
+_ssh_ -p <port> <host> _gerrit test-submit_ type
   [-s]
   [--no-filters]
   CHANGE
@@ -27,16 +28,16 @@
 == EXAMPLES
 
 Test submit_type from stdin and return the submit type.
-====
+----
  cat rules.pl | ssh -p 29418 review.example.com gerrit test-submit type -s I78f2c6673db24e4e92ed32f604c960dc952437d9
  "MERGE_IF_NECESSARY"
-====
+----
 
 Test the active submit_type from the refs/meta/config branch, ignoring filters in the project parents.
-====
+----
  $ ssh -p 29418 review.example.com gerrit test-submit type I78f2c6673db24e4e92ed32f604c960dc952437d9 --no-filters
  "MERGE_IF_NECESSARY"
-====
+----
 
 == SCRIPTING
 Can be used either interactively for testing new prolog submit type, or from a script to check the submit type of a change.
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index d5c2263..cc797cc 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -4,8 +4,9 @@
 gerrit version - Show the version of the currently executing Gerrit server
 
 == SYNOPSIS
+[verse]
 --
-'ssh' -p <port> <host> 'gerrit version'
+_ssh_ -p <port> <host> _gerrit version_
 --
 
 == DESCRIPTION
@@ -32,10 +33,10 @@
 
 == EXAMPLES
 
-=====
+----
 	$ ssh -p 29418 review.example.com gerrit version
 	gerrit version 2.4.2
-=====
+----
 
 GERRIT
 ------
diff --git a/Documentation/config-auto-site-initialization.txt b/Documentation/config-auto-site-initialization.txt
index abd8d8f..acd03c9 100644
--- a/Documentation/config-auto-site-initialization.txt
+++ b/Documentation/config-auto-site-initialization.txt
@@ -8,53 +8,45 @@
 plugins will be installed when Gerrit is deployed in a servlet container
 and the location of the Gerrit distribution can be determined at
 runtime. It is also possible to install only a subset of packaged
-plugins or not install any plugin.
+plugins or not install any plugins.
 
 This feature may be useful for such setups where Gerrit administrators
 don't have direct access to the database and the file system of the
 server where Gerrit should be deployed and, therefore, cannot perform
 the init from their local machine prior to deploying Gerrit on such a
 server. It may also make deployment and testing in a local servlet
-container faster to setup as the init step could be skipped.
+container faster to set up as the init step could be skipped.
 
 == Gerrit Configuration
 
 The site initialization will be performed only if the `gerrit.init`
-system property exists (the value of the property is not used, only the
-existence of the property matters).
+system property exists. The value of the property is not used; only the
+existence of the property matters.
 
 If the `gerrit.site_path` system property is defined then the init is
 run for that site. The database connectivity, in that case, is defined
 in the `etc/gerrit.config`.
 
-If `gerrit.site_path` is not defined then Gerrit will try to find an
-existing site by looking into the `system_config` table in the database
-defined via the `jdbc/ReviewDb` JNDI property. If the `system_config`
-table exists then the `site_path` from that table is used for the
-initialization. The database connectivity is defined by the
-`jdbc/ReviewDb` JNDI property.
-
-Finally, if neither the `gerrit.site_path` property nor the
-`system_config` table exists, the `gerrit.init_path` system property,
-if defined, will be used to determine the site path. The database
-connectivity, also for this case, is defined by the `jdbc/ReviewDb`
-JNDI property.
+If `gerrit.site_path` is not defined then Gerrit will try to find the
+`gerrit.init_path` system property. If defined this property will be
+used to determine the site path. The database connectivity, also for
+this case, is defined by the `jdbc/ReviewDb` JNDI property.
 
 [WARNING]
 Defining the `jdbc/ReviewDb` JNDI property for an H2 database under the
 path defined by either `gerrit.site_path` or `gerrit.init_path` will
 cause an incomplete auto initialization and Gerrit will fail to start.
-Opening a connection to such database will create a subfolder under the
+Opening a connection to such a database will create a subfolder under the
 site path folder (in order to create the H2 database) and Gerrit will
-not any more consider that site path to be new and, because of that,
+no longer consider that site path to be new and, because of that,
 skip some required initialization steps (for example, Lucene index
 creation). In order to auto initialize Gerrit with an embedded H2
 database use the `gerrit.site_path` to define the location of the review
 site and don't define a JNDI resource with a URL under that path.
 
-If the 'gerrit.install_plugins' property is not defined then all packaged
+If the `gerrit.install_plugins` property is not defined then all packaged
 plugins will be installed. If it is defined then it is parsed as a
-comma separated list of plugin names to install. If the value is an
+comma-separated list of plugin names to install. If the value is an
 empty string then no plugin will be installed.
 
 === Example 1
@@ -70,18 +62,6 @@
 
 === Example 2
 
-Prepare Tomcat so that an existing site with the path defined in the
-`system_config` table is initialized (upgraded) on Gerrit startup. The
-assumption is that the `jdbc/ReviewDb` JNDI property is defined in
-Tomcat:
-
-----
-  $ export CATALINA_OPTS='-Dgerrit.init'
-  $ catalina.sh start
-----
-
-=== Example 3
-
 Assuming the database schema doesn't exist in the database defined
 via the `jdbc/ReviewDb` JNDI property, initialize a new site using that
 database and a given path:
diff --git a/Documentation/config-cla.txt b/Documentation/config-cla.txt
index 4c8d04a..c07a24f 100644
--- a/Documentation/config-cla.txt
+++ b/Documentation/config-cla.txt
@@ -12,27 +12,27 @@
 
 To retrieve the `project.config` file, initialize a temporary Git
 repository to edit the configuration:
-====
+----
   mkdir cfg_dir
   cd cfg_dir
   git init
-====
+----
 
 Download the existing configuration from Gerrit:
-====
+----
   git fetch ssh://localhost:29418/All-Projects refs/meta/config
   git checkout FETCH_HEAD
-====
+----
 
 Contributor agreements are defined as contributor-agreement sections in
 `project.config`:
-====
+----
   [contributor-agreement "Individual"]
     description = If you are going to be contributing code on your own, this is the one you want. You can sign this one online.
     agreementUrl = static/cla_individual.html
     autoVerify = group CLA Accepted - Individual
     accepted = group CLA Accepted - Individual
-====
+----
 
 Each `contributor-agreement` section within the `project.config` file must
 have a unique name. The section name will appear in the web UI.
@@ -41,10 +41,10 @@
 `autoVerify` and `accepted` variables in the groups file.
 
 Commit the configuration change, and push it back:
-====
+----
   git commit -a -m "Add Individual contributor agreement"
   git push ssh://localhost:29418/All-Projects HEAD:refs/meta/config
-====
+----
 
 [[contributor-agreement.name.description]]contributor-agreement.<name>.description::
 +
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index bc64d7f..211d174 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -19,6 +19,29 @@
   directory = /var/cache/gerrit2
 ----
 
+[[accountPatchReviewDb]]
+=== Section accountPatchReviewDb
+
+[[accountPatchReviewDb.url]]accountPatchReviewDb.url::
++
+The url of accountPatchReviewDb. Supported types are `H2`, `POSTGRESQL`, and
+`MYSQL`. Drop the driver jar in the lib folder of the site path if the Jdbc
+driver of the corresponding Database is not yet in the class path.
++
+Default is to create H2 database in the db folder of the site path.
++
+Changing this parameter requires to migrate database using the
+link:pgm-MigrateAccountPatchReviewDb.html[MigrateAccountPatchReviewDb] program.
+Migration cannot be done while the server is running.
++
+Also note that the db_name has to be a new db and not reusing gerrit's own review database,
+otherwise gerrit's init will remove the table.
+
+----
+[accountPatchReviewDb]
+  url = jdbc:postgresql://<host>:<port>/<db_name>?user=<user>&password=<password>
+----
+
 [[accounts]]
 === Section accounts
 
@@ -145,7 +168,7 @@
 The configured <<ldap.username,ldap.username>> identity is not used to obtain
 account information.
 +
-* OAUTH
+* `OAUTH`
 +
 OAuth is a protocol that lets external apps request authorization to private
 details in a user's account without getting their password. This is
@@ -383,6 +406,12 @@
 +
 If not set, HTTP request's path is used.
 
+[[auth.cookieDomain]]auth.cookieDomain::
++
+Sets "domain" attribute of the authentication cookie.
++
+If not set, HTTP request's domain is used.
+
 [[auth.cookieSecure]]auth.cookieSecure::
 +
 Sets "secure" flag of the authentication cookie.  If true, cookies
@@ -437,20 +466,73 @@
 [[auth.gitBasicAuth]]auth.gitBasicAuth::
 +
 If true then Git over HTTP and HTTP/S traffic is authenticated using
-standard BasicAuth and the credentials are validated against the randomly
-generated HTTP password or against LDAP when it is configured as Gerrit
-Web UI authentication method.
+standard BasicAuth. Depending on the configured `auth.type`, credentials
+are validated against the randomly generated HTTP password, against LDAP
+(`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
 +
 This parameter affects git over HTTP traffic and access to the REST
 API. If set to false then Gerrit will authenticate through DIGEST
 authentication and the randomly generated HTTP password in the Gerrit
 database.
 +
-When `auth.type` is `LDAP`, service users that only exist in the Gerrit
-database are still authenticated by their HTTP passwords.
+When `auth.type` is `LDAP`, users should authenticate using their LDAP passwords.
+However, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
+the randomly generated HTTP password is used exclusively. In the other hand,
+if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
+the password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the LDAP password.
+Service users that only exist in the Gerrit database are authenticated by their
+HTTP passwords.
++
+When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
+instead of passwords in the Basic authentication header. Note that provider
+specific plugins must be installed to facilitate this authentication scheme.
+If multiple OAuth 2 provider plugins are installed one of them must be
+selected as default with the `auth.gitOAuthProvider` option.
 +
 By default this is set to false.
 
+[[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
++
+When `auth.type` is `LDAP` and BasicAuth (i.e., link:#auth.gitBasicAuth[`auth.gitBasicAuth`]
+is set to true), it allows using either the generated HTTP password, the LDAP
+password or both to authenticate Git over HTTP and REST API requests. The
+supported values are:
++
+*`HTTP`
++
+Only the randomly generated HTTP password is accepted when doing Git over HTTP
+and REST API requests.
++
+*`LDAP`
++
+Only the `LDAP` password is allowed when doing Git over HTTP and REST API
+requests.
++
+*`HTTP_LDAP`
++
+The password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the `LDAP` password.
++
+By default this is set to `LDAP` when link:#auth.type[`auth.type`] is `LDAP`.
+Otherwise, the default value is `HTTP`.
+
+[[auth.gitOAuthProvider]]auth.gitOAuthProvider::
++
+Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
++
+In general there is no way to determine from an access token alone, which
+OAuth 2 provider to address to verify that token, and the BasicAuth
+scheme does not support amending such details. If multiple OAuth provider
+plugins in a system offer support for git over HTTP authentication site
+administrators must configure, which one to use as default provider.
+In case the provider cannot be determined from a request the access token
+will be sent to the default provider for verification.
++
+The value of this parameter must be the identifier of an OAuth 2 provider
+in the form `plugin-name:provider-name`. Consult the respective plugin
+documentation for details.
+
 [[auth.userNameToLowerCase]]auth.userNameToLowerCase::
 +
 If set the username that is received to authenticate a git operation
@@ -502,10 +584,40 @@
 expensive to compute information across restarts.  If the location
 does not exist, Gerrit will try to create it.
 +
+Technically, cached entities are persisted as a set of H2 databases
+inside this directory.
++
 If not absolute, the path is resolved relative to `$site_path`.
 +
 Default is unset, no disk cache.
 
+[[cache.h2CacheSize]]cache.h2CacheSize::
++
+The size of the in-memory cache for each opened H2 cache database, in bytes.
++
+Some caches of Gerrit are persistent and are backed by an H2 database.
+H2 uses memory to cache its database content. The parameter `h2CacheSize`
+allows to limit the memory used by H2 and thus prevent out-of-memory
+caused by the H2 database using too much memory.
++
+See <<database.h2.cachesize,database.h2.cachesize>> for a detailed discussion.
++
+Default is unset, using up to half of the available memory.
++
+H2 will persist this value in the database, so to unset explicitly specify 0.
++
+Common unit suffixes of 'k', 'm', or 'g' are supported.
+
+[[cache.h2AutoServer]]cache.h2AutoServer::
++
+If set to true, enable H2 autoserver mode for the H2-backed persistent cache
+databases.
++
+See link:http://www.h2database.com/html/features.html#auto_mixed_mode[here]
+for detail.
++
+Default is false.
+
 [[cache.name.maxAge]]cache.<name>.maxAge::
 +
 Maximum age to keep an entry in the cache. Entries are removed from
@@ -834,6 +946,33 @@
 +
 Default is the number of CPUs.
 
+
+[[capability]]
+=== Section capability
+
+[[capability.administrateServer]]capability.administrateServer::
++
+Names of groups of users that are allowed to exercise the
+administrateServer capability, in addition to those listed in
+All-Projects. Configuring this option can be a useful fail-safe
+to recover a server in the event an administrator removed all
+groups from the administrateServer capability, or to ensure that
+specific groups always have administration capabilities.
++
+----
+[capability]
+  administrateServer = group Fail Safe Admins
+----
++
+The configuration file uses group names, not UUIDs.  If a group is
+renamed the gerrit.config file must be updated to reflect the new
+name. If a group cannot be found for the configured name a warning
+is logged and the server will continue normal startup.
++
+If not specified (default), only the groups listed by All-Projects
+may use the administrateServer capability.
+
+
 [[change]]
 === Section change
 
@@ -867,6 +1006,12 @@
 +
 Default is 30 seconds.
 
+[[change.allowBlame]]change.allowBlame::
++
+Allow blame on side by side diff. If set to false, blame cannot be used.
++
+Default is true.
+
 [[change.allowDrafts]]change.allowDrafts::
 +
 Allow drafts workflow. If set to false, drafts cannot be created,
@@ -874,6 +1019,21 @@
 +
 Default is true.
 
+[[change.cacheAutomerge]]change.cacheAutomerge::
++
+When reviewing diff commits, the left-hand side shows the output of the
+result of JGit's automatic merge algorithm. This option controls whether
+this output is cached in the change repository, or if only the diff is
+cached in the persistent `diff` cache.
++
+If true, automerge results are stored in the repository under
+`refs/cache-automerge/*`; the results of diffing the change against its
+automerge base are stored in the diff cache. If false, no extra data is
+stored in the repository, only the diff cache. This can result in slight
+performance improvements by reducing the number of refs in the repo.
++
+Default is true.
+
 [[change.submitLabel]]change.submitLabel::
 +
 Label name for the submit button.
@@ -907,7 +1067,7 @@
 Default is "Submit all ${topicSize} changes of the same topic (${submitSize}
 changes including ancestors and other changes related by topic)".
 
-[[change.submitWholeTopic]]change.submitWholeTopic (*Experimental*)::
+[[change.submitWholeTopic]]change.submitWholeTopic::
 +
 Determines if the submit button submits the whole topic instead of
 just the current change.
@@ -924,7 +1084,7 @@
 
 [[change.submitTopicTooltip]]change.submitTopicTooltip::
 +
-If `change.submitWholeTopic` is configuerd to true and a change has a
+If `change.submitWholeTopic` is configured to true and a change has a
 topic, this configuration determines the tooltip for the submit button
 instead of `change.submitTooltip`. The variable `${topicSize}` is available
 for the number of changes in the same topic to be submitted. The number of
@@ -1038,25 +1198,6 @@
 +
 Default is 300 seconds (5 minutes).
 
-[[changeMerge.threadPoolSize]]changeMerge.threadPoolSize::
-+
-_Deprecated:_ Formerly used to control thread pool size for background
-mergeability checks. These checks were moved to the indexing threadpool,
-so this value is now used for
-link:#index.batchThreads[index.batchThreads], only if that value is not
-provided.
-+
-This option may be removed in a future version.
-
-[[changeMerge.interactiveThreadPoolSize]]changeMerge.interactiveThreadPoolSize::
-+
-_Deprecated:_ Formerly used to control thread pool size for interactive
-mergeability checks. These checks were moved to the indexing threadpool,
-so this value is now used for link:#index.threads[index.threads], only
-if that value is not provided.
-+
-This option may be removed in a future version.
-
 [[commentlink]]
 === Section commentlink
 
@@ -1199,6 +1340,12 @@
 called with the '--slave' switch, enabling slave mode. If no value is
 set (or any other value), Gerrit defaults to master mode.
 
+[[container.startupTimeout]]container.startupTimeout::
++
+The maximum time (in seconds) to wait for a gerrit.sh start command
+to run a new Gerrit daemon successfully.  If not set, defaults to
+90 seconds.
+
 [[container.user]]container.user::
 +
 Login name (or UID) of the operating system user the Gerrit JVM
@@ -1325,6 +1472,33 @@
 +
 Default is true.
 
+[[core.repositoryCacheCleanupDelay]]core.repositoryCacheCleanupDelay::
++
+Delay between each periodic cleanup of expired repositories.
++
+Values can be specified using standard time unit abbreviations (`ms`, `sec`,
+`min`, etc.).
++
+Set it to 0 in order to switch off cache expiration. If cache expiration is
+switched off, the JVM can still evict cache entries when it is running low
+on available heap memory.
++
+Set it to -1 to automatically derive cleanup delay from
+`core.repositoryCacheExpireAfter` (lowest value between 1/10 of
+`core.repositoryCacheExpireAfter` and 10 minutes).
++
+Default is -1.
+
+[[core.repositoryCacheExpireAfter]]core.repositoryCacheExpireAfter::
++
+Time an unused repository should expire and be evicted from the repository
+cache.
++
+Values can be specified using standard time unit abbreviations (`ms`, `sec`,
+`min`, etc.).
++
+Default is 1 hour.
+
 [[database]]
 === Section database
 
@@ -1435,7 +1609,8 @@
 httpd and sshd threads as some request processing code paths may
 need multiple connections.
 +
-Default is 8.
+Default is <<sshd.threads, sshd.threads>>
+ + <<httpd.maxThreads, httpd.maxThreads>> + 2.
 +
 This setting only applies if
 <<database.connectionPool,database.connectionPool>> is true.
@@ -1453,7 +1628,7 @@
 Maximum number of connections to keep idle in the pool.  If there
 are more idle connections, connections will be closed instead of
 being returned back to the pool.
-Default is 4.
+Default is min(<<database.poolLimit, database.poolLimit>>, 16).
 +
 This setting only applies if
 <<database.connectionPool,database.connectionPool>> is true.
@@ -1488,6 +1663,42 @@
 classpath, e. g. in `$gerrit_site/lib` directory. Example implementation of
 SQL monitoring can be found in javamelody-plugin.
 
+[[database.h2]]database.h2::
++
+The settings in this section are used for the reviewdb if the
+<<database.type,database.type>> is H2.
++
+Additionally gerrit uses H2 for storing reviewed flags on changes.
+
+[[database.h2.cacheSize]]database.h2.cacheSize::
++
+The size of the H2 internal database cache, in bytes. The H2 internal cache for
+persistent H2-backed caches is controlled by
+<<cache.h2CacheSize,cache.h2CacheSize>>.
++
+H2 uses memory to cache its database content. The parameter `cacheSize`
+allows to limit the memory used by H2 and thus prevent out-of-memory
+caused by the H2 database using too much memory.
++
+Technically the H2 cache size is configured using the CACHE_SIZE parameter in
+the H2 JDBC connection URL, as described
+link:http://www.h2database.com/html/features.html#cache_settings[here]
++
+Default is unset, using up to half of the available memory.
++
+H2 will persist this value in the database, so to unset explicitly specify 0.
++
+Common unit suffixes of 'k', 'm', or 'g' are supported.
+
+[[database.h2.autoServer]]database.h2.autoServer::
++
+If `true` enable the automatic mixed mode
+(see link:http://www.h2database.com/html/features.html#auto_mixed_mode[Automatic Mixed Mode]).
+This enables concurrent access to the embedded H2 database from command line
+utils (e.g. RebuildNoteDb).
++
+Default is `false`.
+
 [[download]]
 === Section download
 
@@ -1720,8 +1931,8 @@
 +
 The default URL for Gerrit to be accessed through.
 +
-Typically this would be set to "http://review.example.com/" or
-"http://example.com/gerrit/" so Gerrit can output links that point
+Typically this would be set to something like "http://review.example.com/"
+or "http://example.com:8080/gerrit/" so Gerrit can output links that point
 back to itself.
 +
 Setting this is highly recommended, as its necessary for the upload
@@ -1816,6 +2027,13 @@
 +
 If not specified, the default no-op implementation is used.
 
+[[gerrit.canLoadInIFrame]]gerrit.canLoadInIFrame::
++
+For security reasons Gerrit will always jump out of iframe.
+Setting this option to true will prevent this behavior.
++
+By default false.
+
 [[gitweb]]
 === Section gitweb
 
@@ -1830,29 +2048,31 @@
 be called by Gerrit Code Review when the URL `/gitweb` is accessed.
 Project level access controls are enforced prior to calling the CGI.
 +
-Defaults to `/usr/lib/cgi-bin/gitweb.cgi` if gitweb.url is not set.
+Defaults to `/usr/lib/cgi-bin/gitweb.cgi` if `gitweb.url` is not set.
 
 [[gitweb.url]]gitweb.url::
 +
 Optional URL of an affiliated gitweb service.  Defines the
 web location where a `gitweb.cgi` is installed to browse
-gerrit.basePath and the repositories it contains.
+`gerrit.basePath` and the repositories it contains.
 +
 Gerrit appends any necessary query arguments onto the end of this URL.
-For example, "?p=$project.git;h=$commit".
+For example, `?p=$project.git;h=$commit`.
 
 [[gitweb.type]]gitweb.type::
 +
 Optional type of affiliated gitweb service. This allows using
-alternatives to gitweb, such as cgit. If set to disabled there
-is no gitweb hyperlinking support.
+alternatives to gitweb, such as cgit.
 +
 Valid values are `gitweb`, `cgit`, `disabled` or `custom`.
++
+If not set, or set to `disabled`, there is no gitweb hyperlinking
+support.
 
 [[gitweb.revision]]gitweb.revision::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at a specific commit when `custom` is used above.
+at a specific commit when `gitweb.type` is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
 and `${commit}` for the SHA1 hash for the commit.
@@ -1860,14 +2080,14 @@
 [[gitweb.project]]gitweb.project::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at a specific project when `custom` is used above.
+at a specific project when `gitweb.type` is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit.
 
 [[gitweb.branch]]gitweb.branch::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at a specific branch when `custom` is used above.
+at a specific branch when `gitweb.type` is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
 and `${branch}` for the name of the branch.
@@ -1875,8 +2095,8 @@
 [[gitweb.roottree]]gitweb.roottree::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at the contents of the root tree in a specific commit when `custom` is
-used above.
+at the contents of the root tree in a specific commit when `gitweb.type`
+is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
 and `${commit}` for the SHA1 hash for the commit.
@@ -1884,8 +2104,8 @@
 [[gitweb.file]]gitweb.file::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at the contents of a file in a specific commit when `custom` is used
-above.
+at the contents of a file in a specific commit when `gitweb.type` is
+set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
 `${file}` for the file name and `${commit}` for the SHA1 hash for
@@ -1894,8 +2114,8 @@
 [[gitweb.filehistory]]gitweb.filehistory::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
-at the history of a file in a specific branch when `custom` is used
-above.
+at the history of a file in a specific branch when when `gitweb.type`
+is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
 `${file}` for the file name and `${branch}` for the name of the
@@ -1906,7 +2126,7 @@
 Optional setting for modifying the link name presented to the user
 in the Gerrit web-UI.
 +
-Default linkname for custom type is "gitweb".
+The default linkname for custom type is `gitweb`.
 
 [[gitweb.pathSeparator]]gitweb.pathSeparator::
 +
@@ -1921,9 +2141,9 @@
 allow using an alternative path separator character. In Gitblit, this can be
 configured through the property link:http://gitblit.com/properties.html[web.forwardSlashCharacter].
 In Gerrit, the alternative path separator can be configured correspondingly
-using the property 'gitweb.pathSeparator'.
+using the property `gitweb.pathSeparator`.
 +
-Valid values are the characters '*', '(' and ')'.
+Valid values are the characters `*`, `(` and `)`.
 
 [[gitweb.urlEncode]]gitweb.urlEncode::
 +
@@ -1931,22 +2151,12 @@
 +
 Gerrit composes the viewer URL using information about the project, branch, file
 or commit of the target object to be displayed. Typically viewers such as CGit
-and gitweb do need those parts to be encoded, including the '/' in project's name,
+and gitweb do need those parts to be encoded, including the `/` in project's name,
 for being correctly parsed.
 However other viewers could instead require an unencoded URL (e.g. GitHub web
-based viewer)
+based viewer).
 +
-Valid values are "true" and "false," default is "true."
-
-[[gitweb.linkDrafts]]gitweb.linkDrafts::
-+
-Whether or not Gerrit should provide links to gitweb on draft patch sets.
-+
-By default, Gerrit will show links to gitweb on all patch sets. If gitweb
-only allows publicly viewable references, set this to false to remove
-the links to draft patch sets from the change review screen.
-+
-Valid values are "true" and "false," default is "true".
+Valid values are `true` and `false`. The default is `true`.
 
 [[groups]]
 === Section groups
@@ -1958,90 +2168,6 @@
 +
 By default, false.
 
-[[hooks]]
-=== Section hooks
-
-See also link:config-hooks.html[Hooks].
-
-[[hooks.path]]hooks.path::
-+
-Optional path to hooks, if not specified then `'$site_path'/hooks` will be used.
-
-[[hooks.syncHookTimeout]]hooks.syncHookTimeout::
-+
-Optional timeout value in seconds for synchronous hooks, if not specified
-then 30 seconds will be used.
-
-[[hooks.changeAbandonedHook]]hooks.changeAbandonedHook::
-+
-Optional filename for the change abandoned hook, if not specified then
-`change-abandoned` will be used.
-
-[[hooks.changeMergedHook]]hooks.changeMergedHook::
-+
-Optional filename for the change merged hook, if not specified then
-`change-merged` will be used.
-
-[[hooks.changeRestoredHook]]hooks.changeRestoredHook::
-+
-Optional filename for the change restored hook, if not specified then
-`change-restored` will be used.
-
-[[hooks.claSignedHook]]hooks.claSignedHook::
-+
-Optional filename for the CLA signed hook, if not specified then
-`cla-signed` will be used.
-
-[[hooks.commentAddedHook]]hooks.commentAddedHook::
-+
-Optional filename for the comment added hook, if not specified then
-`comment-added` will be used.
-
-[[hooks.draftPublishedHook]]hooks.draftPublishedHook::
-+
-Optional filename for the draft published hook, if not specified then
-`draft-published` will be used.
-
-[[hooks.hashtagsChangedHook]]hooks.hashtagsChangedHook::
-+
-Optional filename for the hashtags changed hook, if not specified then
-`hashtags-changed` will be used.
-
-[[hooks.projectCreatedHook]]hooks.projectCreatedHook::
-+
-Optional filename for the project created hook, if not specified then
-`project-created` will be used.
-
-[[hooks.mergeFailedHook]]hooks.mergeFailedHook::
-+
-Optional filename for the merge failed hook, if not specified then
-`merge-failed` will be used.
-
-[[hooks.patchsetCreatedHook]]hooks.patchsetCreatedHook::
-+
-Optional filename for the patchset created hook, if not specified then
-`patchset-created` will be used.
-
-[[hooks.refUpdateHook]]hooks.refUpdateHook::
-+
-Optional filename for the ref update hook, if not specified then
-`ref-update` will be used.
-
-[[hooks.refUpdatedHook]]hooks.refUpdatedHook::
-+
-Optional filename for the ref updated hook, if not specified then
-`ref-updated` will be used.
-
-[[hooks.reviewerAddedHook]]hooks.reviewerAddedHook::
-+
-Optional filename for the reviewer added hook, if not specified then
-`reviewer-added` will be used.
-
-[[hooks.topicChangedHook]]hooks.topicChangedHook::
-+
-Optional filename for the topic changed hook, if not specified then
-`topic-changed` will be used.
-
 [[http]]
 === Section http
 
@@ -2151,6 +2277,14 @@
 +
 By default, true.
 
+[[httpd.inheritChannel]]httpd.inheritChannel::
++
+If true, permits the daemon to inherit its server socket channel
+from fd0/1(stdin/stdout). When set to true, the server can be socket
+activated via systemd or xinetd.
++
+By default, false.
+
 [[httpd.requestHeaderSize]]httpd.requestHeaderSize::
 +
 Size, in bytes, of the buffer used to parse the HTTP headers of an
@@ -2309,6 +2443,23 @@
 	filterClass = org.anyorg.MySecureFilter
 ----
 
+[[httpd.idleTimeout]]httpd.idleTimeout::
++
+Maximum idle time for a connection, which roughly translates to the
+TCP socket `SO_TIMEOUT`.
++
+This value is interpreted as the maximum time between some progress
+being made on the connection. So if a single byte is read or written,
+then the timeout is reset.
++
+The max idle time is applied:
++
+* When waiting for a new message to be received on a connection
+* When waiting for a new message to be sent on a connection
+
++
+By default, 30 seconds.
+
 [[httpd.robotsFile]]httpd.robotsFile::
 +
 Location of an external robots.txt file to be used instead of the one
@@ -2353,9 +2504,8 @@
 it to 0 disables the dedicated thread pool and indexing will be done in the same
 thread as the operation.
 +
-Defaults to 0 if not set, or set to a negative value (unless
-link:#changeMerge.interactiveThreadPoolSize[changeMerge.interactiveThreadPoolSize]
-is iset).
+If not set or set to a negative value, defaults to 1 plus half of the number of
+logical CPUs as returned by the JVM.
 
 [[index.batchThreads]]index.batchThreads::
 +
@@ -2363,8 +2513,7 @@
 online schema upgrades.
 +
 If not set or set to a negative value, defaults to the number of logical
-CPUs as returned by the JVM (unless
-link:#changeMerge.threadPoolSize[changeMerge.threadPoolSize] is set).
+CPUs as returned by the JVM.
 
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
@@ -2823,6 +2972,19 @@
   javaOptions = -Dcom.sun.jndi.ldap.connect.pool.timeout=300000
 ----
 
+[[lfs]]
+=== Section lfs
+
+[[lfs.plugin]]lfs.plugin::
++
+The name of a plugin which serves the
+link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
+LFS protocol] on the `<project-name>/info/lfs/objects/batch` endpoint. When
+not configured Gerrit will respond with `501 Not Implemented` on LFS protocol
+requests.
++
+By default unset.
+
 [[log]]
 === Section log
 
@@ -2867,6 +3029,31 @@
   safe = true
 ----
 
+[[oauth]]
+=== Section oauth
+
+OAuth integration is only enabled if `auth.type` is set to `OAUTH`. See
+link:#auth.type[above] for a detailed description of the `auth.type` settings
+and their implications.
+
+By default, contact information, like the full name and email address,
+is retrieved from the selected OAuth provider when a user account is created,
+or when a user requests to reload that information in the settings UI. If
+that is not supported by the OAuth provider, users can be allowed to edit
+their contact information manually.
+
+[[oauth.allowEditFullName]]oauth.allowEditFullName::
++
+If true, the full name can be edited in the contact information.
++
+Default is false.
+
+[[oauth.allowRegisterNewEmail]]oauth.allowRegisterNewEmail::
++
+If true, additional email addresses can be registered in the contact
+information.
++
+Default is false.
 
 [[pack]]
 === Section pack
@@ -3117,10 +3304,22 @@
   defaultSubmitType = CHERRY_PICK
 ----
 
-[NOTE] All properties are used from the matching repository configuration. In
+[NOTE]
+All properties are used from the matching repository configuration. In
 the previous example, all properties will be used from `project/plugins/\*`
 section and no properties will be inherited nor overridden from `project/*`.
 
+[[repository.name.basePath]]repository.<name>.basePath::
++
+Alternate to <<gerrit.basePath,gerrit.basePath>>. The repository will be created
+and used from this location instead: ${alternateBasePath}/${projectName}.git.
++
+If configuring the basePath for an existing project in gerrit, make sure to stop
+gerrit, move the repository in the alternate basePath, configure basePath for
+this repository and then start Gerrit.
++
+Path must be absolute.
+
 [[repository.name.defaultSubmitType]]repository.<name>.defaultSubmitType::
 +
 The default submit type for newly created projects. Supported values
@@ -3171,6 +3370,28 @@
 +
 Default is 10x reductionLimit (1,000,000).
 
+[[rules.maxSourceBytes]]rules.maxSourceBytes::
++
+Maximum input size (in bytes) of a Prolog rules.pl file.  Larger
+source files may need a larger rules.compileReductionLimit.  Consider
+using link:pgm-rulec.html[rulec] to precompile larger rule files.
++
+A size of 0 bytes disables rules, same as rules.enable = false.
++
+Common unit suffixes of 'k', 'm', or 'g' are supported.
++
+Default is 128 KiB.
+
+[[rules.maxPrologDatabaseSize]]rules.maxPrologDatabaseSize::
++
+Number of predicate clauses allowed to be defined in the Prolog
+database by project rules.  Very complex rules may need more than the
+default 256 limit, but cost more memory and may need more time to
+evaluate.  Consider using link:pgm-rulec.html[rulec] to precompile
+larger rule files.
++
+Default is 256.
+
 [[execution]]
 === Section execution
 
@@ -3375,9 +3596,9 @@
 +
 Starting from version 0.9.0 Apache SSHD project added support for NIO2
 IoSession. To use the new NIO2 session the `backend` option must be set
-to `NIO2`.
+to `NIO2`. Otherwise, this option must be set to `MINA`.
 +
-By default, `MINA`.
+By default, `NIO2`.
 
 [[sshd.listenAddress]]sshd.listenAddress::
 +
@@ -3436,7 +3657,7 @@
 If additional requests are received while all threads are busy they
 are queued and serviced in a first-come-first-served order.
 +
-By default, 1.5x the number of CPUs available to the JVM.
+By default, 2x the number of CPUs available to the JVM.
 
 [[sshd.batchThreads]]sshd.batchThreads::
 +
@@ -3500,8 +3721,9 @@
 [[sshd.idleTimeout]]sshd.idleTimeout::
 +
 Time in seconds after which the server automatically terminates idle
-connections (or 0 to disable closing of idle connections).  Values
-should use common unit suffixes to express their setting:
+connections (or 0 to disable closing of idle connections) not waiting for
+any server operation to complete.
+Values should use common unit suffixes to express their setting:
 +
 * s, sec, second, seconds
 * m, min, minute, minutes
@@ -3511,6 +3733,21 @@
 +
 By default, 0.
 
+[[sshd.waitTimeout]]sshd.waitTimeout::
++
+Time in seconds after which the server automatically terminates
+connections waiting for a server operation to complete, like for instance
+cloning a very large repo with lots of refs.
+Values should use common unit suffixes to express their setting:
++
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
+* d, day, days
+
++
+By default, 30s.
+
 [[sshd.maxConnectionsPerUser]]sshd.maxConnectionsPerUser::
 +
 Maximum number of concurrent SSH sessions that a user account
@@ -3547,6 +3784,40 @@
 +
 By default, all supported MACs are available.
 
+[[sshd.kex]]sshd.kex::
++
+--
+Available key exchange algorithms. To permit multiple algorithms,
+specify multiple `sshd.kex` keys in the configuration file, one key
+exchange algorithm per key.  Key exchange algorithm names starting
+with `+` are enabled in addition to the default key exchange
+algorithms, key exchange algorithm names starting with `-` are
+removed from the default key exchange algorithms.
+
+In the following example configuration, support for the 1024-bit
+`diffie-hellman-group1-sha1` key exchange is disabled while leaving
+all of the other default algorithms enabled:
+
+----
+[sshd]
+  kex = -diffie-hellman-group1-sha1
+----
+
+Supported key exchange algorithms:
+
+* `ecdh-sha2-nistp521`
+* `ecdh-sha2-nistp384`
+* `ecdh-sha2-nistp256`
+* `diffie-hellman-group-exchange-sha256`
+* `diffie-hellman-group-exchange-sha1`
+* `diffie-hellman-group14-sha1`
+* `diffie-hellman-group1-sha1`
+
+By default, all supported key exchange algorithms are available.
+Without Bouncy Castle, `diffie-hellman-group1-sha1` is the only
+available algorithm.
+--
+
 [[sshd.kerberosKeytab]]sshd.kerberosKeytab::
 +
 Enable kerberos authentication for SSH connections.  To permit
@@ -3606,35 +3877,12 @@
 [[suggest]]
 === Section suggest
 
-[[suggest.accounts]]suggest.accounts::
-+
-If `true`, visible user accounts (according to the value of
-`accounts.visibility`) will be offered as completion suggestions
-when adding a reviewer to a change, or a user to a group.
-+
-If `false`, account suggestion is disabled.
-+
-Older configurations may also have one of the `accounts.visibility`
-values for this field, including `OFF` as a synonym for `NONE`. If
-`accounts.visibility` is also set, that value overrides this one;
-otherwise, this value applies to both `suggest.accounts` and
-`accounts.visibility`.
-+
-New configurations should prefer the boolean value for this field
-and an enum value for `accounts.visibility`.
-
 [[suggest.maxSuggestedReviewers]]suggest.maxSuggestedReviewers::
 +
 The maximum numbers of reviewers suggested.
 +
 By default 10.
 
-[[suggest.fullTextSearch]]suggest.fullTextSearch::
-+
-If `true` the reviewer completion suggestions will be based on a full text search.
-+
-By default `false`.
-
 [[suggest.from]]suggest.from::
 +
 The number of characters that a user must have typed before suggestions
@@ -3642,18 +3890,6 @@
 +
 By default 0.
 
-[[suggest.fullTextSearchMaxMatches]]suggest.fullTextSearchMaxMatches::
-+
-The maximum number of matches evaluated for change access when using full text search.
-+
-By default 100.
-
-[[suggest.fullTextSearchRefresh]]suggest.fullTextSearchRefresh::
-+
-Refresh interval for the in-memory account search index.
-+
-By default 1 hour.
-
 
 [[theme]]
 === Section theme
@@ -3894,14 +4130,25 @@
 [[submodule]]
 === Section submodule
 
-[[submodule.verbosesuperprojectupdate]]submodule.verboseSuperprojectUpdate
+[[submodule.verbosesuperprojectupdate]]submodule.verboseSuperprojectUpdate::
 +
 When using link:user-submodules.html#automatic_update[automatic superproject updates]
-this option will determine if the submodule commit messages are included into
+this option will determine how the submodule commit messages are included into
 the commit message of the superproject update.
 +
-By default this is true.
+If `FALSE`, will not include any commit messages for the gitlink update.
++
+If `SUBJECT_ONLY`, will include only the commit subjects.
++
+If `TRUE`, will include full commit messages.
++
+By default this is `TRUE`.
 
+[[submodule.enableSuperProjectSubscriptions]]submodule.enableSuperProjectSubscriptions::
++
+This allows to enable the superproject subscription mechanism.
++
+By default this is true.
 
 [[user]]
 === Section user
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 63eaffd..fcfd0e1 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -16,10 +16,10 @@
 which is a common installation path for the 'gitweb' package on
 Linux distributions.
 
-====
+----
   git config --file $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
   git config --file $site_path/etc/gerrit.config --unset gitweb.url
-====
+----
 
 Alternatively, if Gerrit is served behind reverse proxy, it can
 generate different URLs for gitweb's links (they need to be
@@ -27,10 +27,10 @@
 for serving gitweb under a different URL than the Gerrit instance.
 To enable this feature, set both: `gitweb.cgi` and `gitweb.url`.
 
-====
+----
   git config --file $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
   git config --file $site_path/etc/gerrit.config gitweb.url /pretty/path/to/gitweb
-====
+----
 
 After updating `'$site_path'/etc/gerrit.config`, the Gerrit server must
 be restarted and clients must reload the host page to see the change.
@@ -76,15 +76,15 @@
 
 On Ubuntu:
 
-====
-  sudo apt-get install gitweb
-====
+----
+  $ sudo apt-get install gitweb
+----
 
 With Yum:
 
-====
+----
   $ yum install gitweb
-====
+----
 
 ===== Configure Gitweb
 
@@ -124,16 +124,16 @@
 
 Link gitweb to `/var/www/gitweb`, check `/etc/gitweb.conf` if unsure of paths:
 
-====
+----
   $ sudo ln -s /usr/share/gitweb /var/www/gitweb
-====
+----
 
 Add the gitweb directory to the Apache configuration by creating a "gitweb"
 file inside the Apache conf.d directory:
 
-====
+----
   $ touch /etc/apache/conf.d/gitweb
-====
+----
 
 Add the following to /etc/apache/conf.d/gitweb:
 
@@ -145,14 +145,15 @@
 AllowOverride None
 ----
 
-*NOTE* This may have already been added by yum/apt-get. If that's the case, leave as
+[NOTE]
+This may have already been added by yum/apt-get. If that's the case, leave as
 is.
 
 ===== Restart the Apache Web Server
 
-====
-$ sudo /etc/init.d/apache2 restart
-====
+----
+  $ sudo /etc/init.d/apache2 restart
+----
 
 Now you should be able to view your repository projects online:
 
@@ -182,9 +183,9 @@
 verify by checking for perl modules. From an msys console, execute the
 following to check:
 
-====
+----
 $ perl -mCGI -mEncode -mFcntl -mFile::Find -mFile::Basename -e ""
-====
+----
 
 You may encounter the following exception:
 
diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt
index 3068cc5..a71595f 100644
--- a/Documentation/config-hooks.txt
+++ b/Documentation/config-hooks.txt
@@ -1,190 +1,9 @@
 = Gerrit Code Review - Hooks
 
-Gerrit does not run any of the standard git hooks in the
-repositories it works with, but it does have its own hook mechanism
-included. Gerrit looks in `'$site_path'/hooks` for executables with
-names listed below.
-
-The environment will have GIT_DIR set to the full path of the
-affected git repository so that git commands can be easily run.
-
-Make sure your hook scripts are executable if running on *nix.
-
-With the exception of the ref-update hook, hooks are run in the background
-after the relevant change has taken place so are unable to affect
-the outcome of any given change. Because of the fact the hooks are
-run in the background after the activity, a hook might not be notified
-about an event if the server is shutdown before the hook can be invoked.
-
-== Supported Hooks
-
-=== ref-update
-
-This is called when a push request is received by Gerrit. It allows
-a push to be rejected before it is committed to the Gerrit repository.
-If the script exits with non-zero return code the push will be rejected.
-Any output from the script will be returned to the user, regardless of the
-return code.
-
-This hook is called synchronously so it is recommended that
-it not block.  A default timeout on the hook is set to 30 seconds to avoid
-"runaway" hooks using up server threads.  See link:config-gerrit.html#hooks.syncHookTimeout[hooks.syncHookTimeout]
-for configuration details.
-
-====
-  ref-update --project <project name> --refname <refname> --uploader <uploader> --oldrev <sha1> --newrev <sha1>
-====
-
-=== patchset-created
-
-This is called whenever a patchset is created (this includes new
-changes and drafts).
-
-====
-  patchset-created --change <change id> --is-draft <boolean> --kind <change kind> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --uploader <uploader> --commit <sha1> --patchset <patchset id>
-====
-
-kind:: change kind represents the kind of change uploaded, also represented in link:json.html#patchSet[patchSet]
-
-  REWORK;; Nontrivial content changes.
-
-  TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set.
-
-  NO_CODE_CHANGE;; No code changed; same tree and same parent tree.
-
-  NO_CHANGE;; No changes; same commit message, same tree and same parent tree.
-
-=== draft-published
-
-This is called whenever a draft change is published.
-
-====
-  draft-published --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --uploader <uploader> --commit <sha1> --patchset <patchset id>
-====
-
-=== comment-added
-
-This is called whenever a comment is added to a change.
-
-====
-  comment-added --change <change id> --is-draft <boolean> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --author <comment author> --commit <commit> --comment <comment> [--<approval category id> <score> --<approval category id> <score> ...]
-====
-
-=== change-merged
-
-Called whenever a change has been merged.
-
-====
-  change-merged --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --submitter <submitter> --commit <sha1> --newrev <sha1>
-====
-
-=== merge-failed
-
-Called whenever a change has failed to merge.
-
-====
-  merge-failed --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --submitter <submitter> --commit <sha1> --reason <reason>
-====
-
-=== change-abandoned
-
-Called whenever a change has been abandoned.
-
-====
-  change-abandoned --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --abandoner <abandoner> --commit <sha1> --reason <reason>
-====
-
-=== change-restored
-
-Called whenever a change has been restored.
-
-====
-  change-restored --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --restorer <restorer> --commit <sha1> --reason <reason>
-====
-
-=== ref-updated
-
-Called whenever a ref has been updated.
-
-====
-  ref-updated --oldrev <old rev> --newrev <new rev> --refname <ref name> --project <project name> --submitter <submitter>
-====
-
-=== project-created
-
-Called whenever a project has been created.
-
-====
-  project-created --project <project name> --head <head name>
-====
-
-=== reviewer-added
-
-Called whenever a reviewer is added to a change.
-
-====
-  reviewer-added --change <change id> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --reviewer <reviewer>
-====
-
-=== topic-changed
-
-Called whenever a change's topic is changed from the Web UI or via the REST API.
-
-====
-  topic-changed --change <change id> --change-owner <change owner> --project <project name> --branch <branch> --changer <changer> --old-topic <old topic> --new-topic <new topic>
-====
-
-=== hashtags-changed
-
-Called whenever hashtags are added to or removed from a change from the Web UI
-or via the REST API.
-
-====
-  hashtags-changed --change <change id>  --change-owner <change owner> --project <project name> --branch <branch> --editor <editor> --added <hashtag> --removed <hashtag> --hashtag <hashtag>
-====
-
-The `--added` parameter may be passed multiple times, once for each
-hashtag that was added to the change.
-
-The `--removed` parameter may be passed multiple times, once for each
-hashtag that was removed from the change.
-
-The `--hashtag` parameter may be passed multiple times, once for each
-hashtag remaining on the change after the add or remove operation has
-been performed.
-
-=== cla-signed
-
-Called whenever a user signs a contributor license agreement.
-
-====
-  cla-signed --submitter <submitter> --user-id <user_id> --cla-id <cla_id>
-====
-
-
-== Configuration Settings
-
-It is possible to change where Gerrit looks for hooks, and what
-filenames it looks for, by adding a [hooks] section in gerrit.config.
-
-Gerrit will use the value of hooks.path for the hooks directory.
-
-For the hook filenames, Gerrit will use the values of hooks.patchsetCreatedHook,
-hooks.draftPublishedHook, hooks.commentAddedHook, hooks.changeMergedHook,
-hooks.changeAbandonedHook, hooks.changeRestoredHook, hooks.refUpdatedHook,
-hooks.refUpdateHook, hooks.reviewerAddedHook and hooks.claSignedHook.
-
-== Missing Change URLs
-
-If link:config-gerrit.html#gerrit.canonicalWebUrl[gerrit.canonicalWebUrl]
-is not set in `gerrit.config` the `--change-url` flag may not be
-passed to all hooks.  Hooks started out of an SSH context (for example
-the patchset-created hook) don't know the server's web URL, unless
-this variable is configured.
-
-== SEE ALSO
-
-* link:config-gerrit.html#hooks[Section hooks]
+Gerrit does not run any of the standard git hooks in the repositories
+it works with, but it does have its own hook mechanism included via
+the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+hooks plugin].
 
 GERRIT
 ------
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index a40c5f3..1f9dd33 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -90,13 +90,13 @@
 Administrators can install the Verified label by adding the following
 text to `project.config`:
 
-====
+----
   [label "Verified"]
       function = MaxWithBlock
       value = -1 Fails
       value =  0 No score
       value = +1 Verified
-====
+----
 
 The range of values is:
 
@@ -217,6 +217,19 @@
 The label is purely informational and values are not considered when
 determining whether a change is submittable.
 
+* `PatchSetLock`
++
+The `PatchSetLock` function provides a locking mechanism for patch
+sets.  This function's values are not considered when determining
+whether a change is submittable. When set, no new patchsets can be
+created and rebase and abandon are blocked.
++
+This function is designed to allow overlapping locks, so several lock
+accounts could lock the same change.
++
+Allowed range of values are 0 (Patch Set Unlocked) to 1 (Patch Set
+Locked).
+
 
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
@@ -233,6 +246,23 @@
 sticky approvals, reducing turn-around for trivial cleanups prior to
 submitting a change. Defaults to false.
 
+[[label_copyAllScoresOnMergeCommitFirstParentUpdate]]
+=== `label.Label-Name.copyAllScoresOnMergeCommitFirstParentUpdate`
+
+This policy is useful if you don't want to trigger CI or human
+verification again if your target branch moved on but the feature
+branch being merged into the target branch did not change. It only
+applies if the patch set is a merge commit.
+
+If true, all scores for the label are copied forward when a new
+patch set is uploaded that is a new merge commit which only
+differs from the previous patch set in its first parent, or has
+identical parents. The first parent would be the parent of the merge
+commit that is part of the change's target branch, whereas the other
+parent(s) refer to the feature branch(es) to be merged.
+
+Defaults to false.
+
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
@@ -240,10 +270,13 @@
 set is uploaded that is a trivial rebase. A new patch set is considered
 as trivial rebase if the commit message is the same as in the previous
 patch set and if it has the same code delta as the previous patch set.
-This is the case if the change was rebased onto a different parent.
+This is the case if the change was rebased onto a different parent, or
+if the parent did not change at all.
+
 This can be used to enable sticky approvals, reducing turn-around for
 trivial rebases prior to submitting a change.
 For the pre-installed Code-Review label this is enabled by default.
+
 Defaults to false.
 
 [[label_copyAllScoresIfNoCodeChange]]
@@ -257,6 +290,7 @@
 if only the commit message is changed prior to submitting a change.
 For the Verified label that is installed by the link:pgm-init.html[init]
 site program this is enabled by default.
+
 Defaults to false.
 
 [[label_copyAllScoresIfNoChange]]
@@ -268,7 +302,9 @@
 set SHA1 is different. This can be used to enable sticky
 approvals, reducing turn-around for this special case.
 It is recommended to leave this enabled for both Verified and
-Code-Review labels. Defaults to true.
+Code-Review labels.
+
+Defaults to true.
 
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
@@ -287,16 +323,17 @@
 E.g. create a label `Video-Qualify` on parent project and configure
 the `branch` as:
 
-====
+----
   [label "Video-Qualify"]
       branch = refs/heads/video-1.0/*
       branch = refs/heads/video-1.1/Kino
-====
+----
 
 Then *only* changes in above branch scope of parent project and child
 projects will be affected by `Video-Qualify`.
 
-NOTE: The `branch` is independent from the branch scope defined in `access`
+[NOTE]
+The `branch` is independent from the branch scope defined in `access`
 parts in `project.config` file. That means from the UI a user can always
 assign permissions for that label on a branch, but this permission is then
 ignored if the label doesn't apply for that branch.
@@ -307,13 +344,13 @@
 To define a new 3-valued category that behaves exactly like `Verified`,
 but has different names/labels:
 
-====
+----
   [label "Copyright-Check"]
       function = MaxWithBlock
       value = -1 Do not have copyright
       value =  0 No score
       value = +1 Copyright clear
-====
+----
 
 The new column will appear at the end of the table, and `-1 Do not have
 copyright` will block submit, while `+1 Copyright clear` is required to
@@ -324,7 +361,7 @@
 This example attempts to describe how a label default value works with the
 user permissions.  Assume the configuration below.
 
-====
+----
   [access "refs/heads/*"]
       label-Snarky-Review = -3..+3 group Administrators
       label-Snarky-Review = -2..+2 group Project Owners
@@ -338,7 +375,7 @@
       value = +2 Hmm, this is pretty nice
       value = +3 Ohh, hell yes!
       defaultValue = -3
-====
+----
 
 Upon clicking the Reply button:
 
@@ -346,6 +383,20 @@
 * Project Owners have limited scores (-2..+2) available, -2 is set as the default.
 * Registered Users have limited scores (-1..+1) available, -1 is set as the default.
 
+=== Patch Set Lock Example
+
+This example shows how a label can be configured to have a standard patch set lock.
+
+----
+  [access "refs/heads/*"]
+      label-Patch-Set-Lock = +0..+1 group Administrators
+  [label "Patch-Set-Lock"]
+      function = PatchSetLock
+      value =  0 Patch Set Unlocked
+      value = +1 Patch Set Locked
+      defaultValue = 0
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index 2e775b4..ffeae62 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -106,7 +106,8 @@
   user@host:~$
 ----
 
-IMPORTANT: Please take note of the extra line-breaks introduced in the key above
+[IMPORTANT]
+Please take note of the extra line-breaks introduced in the key above
 for formatting purposes. Please be sure to copy and paste your key without
 line-breaks.
 
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index da213a8..51ea9c5 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -56,6 +56,18 @@
 text that will be appended to emails related to a user submitting comments on
 changes.  See `ChangeSubject.vm`, `Comment.vm` and `ChangeFooter.vm`.
 
+=== DeleteVote.vm
+
+The `DeleteVote.vm` template will determine the contents of the email related
+to removing votes on changes.  It is a `ChangeEmail`: see `ChangeSubject.vm`
+and `ChangeFooter.vm`.
+
+=== DeleteReviewer.vm
+
+The `DeleteReviewer.vm` template will determine the contents of the email related
+to a user removing a reviewer (with a vote) from a change.  It is a
+`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
+
 === Footer.vm
 
 The `Footer.vm` template will determine the contents of the footer text
@@ -68,12 +80,6 @@
 a change successfully merged to the head.  It is a `ChangeEmail`: see
 `ChangeSubject.vm` and `ChangeFooter.vm`.
 
-=== MergeFail.vm
-
-The `MergeFail.vm` template will determine the contents of the email related
-to a failure upon attempting to merge a change to the head.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
-
 === NewChange.vm
 
 The `NewChange.vm` template will determine the contents of the email related
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index ca95099..b7c1415 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -76,6 +76,18 @@
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[hooks]]
+=== hooks
+
+This plugin runs server-side hooks on events.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+Project] |
+link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[replication]]
 === replication
 
@@ -128,14 +140,12 @@
 these plugins.
 
 The Gerrit Project doesn't provide binaries for these plugins, but
-there are some public services that offer the download of pre-built
+there is one public service that offers the download of pre-built
 plugin jars:
 
 * link:https://gerrit-ci.gerritforge.com[CI Server from GerritForge]
-* link:http://builds.quelltextlich.at/gerrit/nightly/index.html[
-  CI Server from Quelltextlich]
 
-The following list gives an overview about available plugins, but the
+The following list gives an overview of available plugins, but the
 list may not be complete. You may discover more plugins on
 link:https://gerrit-review.googlesource.com/#/admin/projects/?filter=plugins%252F[
 gerrit-review].
@@ -154,24 +164,24 @@
 Documentation]
 
 [[avatars-external]]
-=== avatars/external
+=== avatars-external
 
 This plugin allows to use an external url to load the avatar images
 from.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/external[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-external[
 Project] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/about.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/config.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
 [[avatars-gravatar]]
-=== avatars/gravatar
+=== avatars-gravatar
 
 Plugin to display user icons from Gravatar.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/gravatar[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-gravatar[
 Project]
 
 [[branch-network]]
@@ -197,20 +207,10 @@
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/changemessage[
 Project] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/about.md[
-Plugin Documenatation] |
+Plugin Documentation] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[codenvy]]
-=== codenvy
-
-Plugin to allow to edit code on-line on either an existing branch or an
-active change using the link:http://codenvy.com[Codenvy] cloud
-development platform.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/codenvy[
-Project]
-
 [[delete-project]]
 === delete-project
 
@@ -235,6 +235,18 @@
 link:https://gerrit.googlesource.com/plugins/egit/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
+[[emoticons]]
+=== emoticons
+
+This plugin allows users to see emoticons in comments as images.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/emoticons[
+Project] |
+link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[force-draft]]
 === force-draft
 
@@ -406,6 +418,30 @@
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[metrics-reporter-elasticsearch]]
+=== metrics-reporter-elasticsearch
+
+This plugin reports Gerrit metrics to Elasticsearch.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
+Project].
+
+[[metrics-reporter-graphite]]
+=== metrics-reporter-graphite
+
+This plugin reports Gerrit metrics to Graphite.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
+Project].
+
+[[metrics-reporter-jmx]]
+=== metrics-reporter-jmx
+
+This plugin reports Gerrit metrics to JMX.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
+Project].
+
 [[motd]]
 === motd
 
@@ -546,6 +582,18 @@
 link:https://gerrit.googlesource.com/plugins/scripting/scala-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
+[[scripts]]
+=== scripts
+
+Repository containing a collection of Gerrit scripting plugins that are intended
+to provide simple and useful extensions.
+
+Groovy and Scala scripts require the installation of the corresponding
+scripting/*-provider plugin in order to be loaded into Gerrit.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripts[Project]
+link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation]
+
 [[server-config]]
 === server-config
 
@@ -555,7 +603,7 @@
 where Gerrit's config files are stored is difficult or impossible to
 get.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/server-config[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/server-config[
 Project]
 
 [[serviceuser]]
@@ -568,7 +616,7 @@
 Plugin in Jenkins. A service user is not able to login into the Gerrit
 WebUI and it cannot push commits or tags.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/serviceuser[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/serviceuser[
 Project] |
 link:https://gerrit.googlesource.com/plugins/serviceuser/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -584,22 +632,36 @@
 and a maximum allowed path length. Pushes of commits that violate these
 settings are rejected by Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/uploadvalidator[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/uploadvalidator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[verify-status]]
+=== verify-status
+
+This plugin adds a separate channel for Gerrit to store test metadata and
+view them on the Gerrit UI.  The metadata can be stored in the Gerrit database
+or in a completely separate datastore.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/verify-status[
+Project] |
+link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/database.md[
+Configuration]
+
 [[websession-flatfile]]
 === websession-flatfile
 
 This plugin replaces the built-in Gerrit H2 based websession cache with
-a flatfile based implementation. This implemantation is shareable
+a flatfile based implementation. This implementation is shareable
 amongst multiple Gerrit servers, making it useful for multi-master
 Gerrit installations.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/websession-flatfile[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
 Project] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -617,7 +679,7 @@
 Requests. Pushing a new patchset will reset the change to Review In
 Progress.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/wip[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/wip[
 Project] |
 link:https://gerrit.googlesource.com/plugins/wip/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -629,7 +691,7 @@
 
 This plugin serves project documentation as HTML pages.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/x-docs[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/x-docs[
 Project] |
 link:https://gerrit.googlesource.com/plugins/x-docs/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 7d03681..7121265 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -65,8 +65,7 @@
 
 The link:#access-section[+access+ section] appears once per reference pattern,
 such as `+refs/*+` or `+refs/heads/*+`.  Only one access section per pattern is
-allowed.  You will find examples of keys and values in each category section
-<<access_category,below>>.
+allowed.
 
 The link:#receive-section[+receive+ section] appears once per project.
 
@@ -74,8 +73,7 @@
 
 The link:#capability-section[+capability+] section only appears once, and only
 in the +All-Projects+ repository.  It controls core features that are configured
-on a global level.  You can find examples of these
-<<capability_category,below>>.
+on a global level.
 
 The link:#label-section[+label+] section can appear multiple times. You can
 also redefine the text and behavior of the built in label types `Code-Review`
@@ -175,6 +173,22 @@
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
 
+[[receive.rejectImplicitMerges]]receive.rejectImplicitMerges::
++
+Controls whether a check for implicit merges will be performed when changes are
+pushed for review. An implicit merge is a case where merging an open change
+would implicitly merge another branch into the target branch. Typically, this
+happens when a change is done on master and, by mistake, pushed to a stable branch
+for review. When submitting such change, master would be implicitly merged into
+stable without anyone noticing that. When this option is set to 'true' Gerrit
+will reject the push if an implicit merge is detected.
++
+This check is only done for non-merge commits, merge commits are not subject of
+the implicit merge check.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
 [[submit-section]]
 === Submit section
 
diff --git a/Documentation/config-sso.txt b/Documentation/config-sso.txt
index 897ca29..684b87c 100644
--- a/Documentation/config-sso.txt
+++ b/Documentation/config-sso.txt
@@ -10,9 +10,9 @@
 user authentication services.  To enable OpenID, the auth.type
 setting should be `OpenID`:
 
-====
+----
   git config --file $site_path/etc/gerrit.config auth.type OpenID
-====
+----
 
 As this is the default setting there is nothing required from the
 site administrator to make use of the OpenID authentication services.
@@ -24,9 +24,9 @@
 Add the following to `$JETTY_HOME/etc/jetty.xml` under
 `org.mortbay.jetty.nio.SelectChannelConnector`:
 
-====
+----
   <Set name="headerBufferSize">16384</Set>
-====
+----
 
 In order to use permissions beyond those granted to the
 `Anonymous Users` and `Registered Users` groups, an account
@@ -44,9 +44,9 @@
 * `https://` -- trust all OpenID providers using the HTTPS protocol
 
 To trust only Yahoo!:
-====
+----
   git config --file $site_path/etc/gerrit.config auth.trustedOpenID https://me.yahoo.com
-====
+----
 
 === Database Schema
 
@@ -100,11 +100,11 @@
 
 To enable this form of authentication:
 
-====
+----
   git config --file $site_path/etc/gerrit.config auth.type HTTP
   git config --file $site_path/etc/gerrit.config --unset auth.httpHeader
   git config --file $site_path/etc/gerrit.config auth.emailFormat '{0}@example.com'
-====
+----
 
 The auth.type must always be HTTP, indicating the user identity
 will be obtained from the HTTP authorization data.
@@ -124,14 +124,14 @@
 such as the following is recommended to ensure Apache performs the
 authentication at the proper time:
 
-====
+----
   <Location "/login/">
     AuthType Basic
     AuthName "Gerrit Code Review"
     Require valid-user
     ...
   </Location>
-====
+----
 
 === Database Schema
 
@@ -161,11 +161,11 @@
 
 To enable this form of authentication:
 
-====
+----
   git config --file $site_path/etc/gerrit.config auth.type HTTP
   git config --file $site_path/etc/gerrit.config auth.httpHeader SM_USER
   git config --file $site_path/etc/gerrit.config auth.emailFormat '{0}@example.com'
-====
+----
 
 The auth.type must always be HTTP, indicating the user identity
 will be obtained from the HTTP authorization data.
@@ -186,9 +186,9 @@
 Add the following to `$JETTY_HOME/etc/jetty.xml` under
 `org.mortbay.jetty.nio.SelectChannelConnector`:
 
-====
+----
   <Set name="headerBufferSize">16384</Set>
-====
+----
 
 
 === Database Schema
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index b165e37..dcfd711 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -88,7 +88,7 @@
 To connect Gerrit to Google Analytics add the following to your
 `GerritSiteFooter.html`:
 
-====
+----
   <div>
   <!-- standard analytics code -->
     <script type="text/javascript">
@@ -110,7 +110,7 @@
     };
   </script>
   </div>
-====
+----
 
 Please consult the Google Analytics documentation for the correct
 setup code (the first two script tags).  The above is shown only
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 27b39eb..2707e5c 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -21,16 +21,18 @@
 Out of the box, Gerrit includes a plugin that checks the length of the
 subject and body lines of commit messages on uploaded commits.
 
-[[ref-operation-validation]]
-== Ref operation validation
+[[user-ref-operations-validation]]
+== User ref operations validation
 
 
 Plugins implementing the `RefOperationValidationListener` interface can
-perform additional validation checks against ref creation/deletion operation
-before it is applied to the git repository.
+perform additional validation checks against user ref operations (resulting
+from either push or corresponding Gerrit REST/SSH endpoints call e.g.
+create branch etc.). Namely including ref creation, deletion and update
+(also non-fast-forward) before they are applied to the git repository.
 
-If the ref operation fails the validation, the plugin can throw an exception
-which will cause the operation to fail.
+The plugin can throw an exception which will cause the operation to fail,
+and prevent the ref update from being applied.
 
 [[pre-merge-validation]]
 == Pre-merge validation
diff --git a/Documentation/config.defs b/Documentation/config.defs
index 380080f..7f814d3 100644
--- a/Documentation/config.defs
+++ b/Documentation/config.defs
@@ -15,7 +15,7 @@
     'tilde="&#126;"',
     'last-update-label!',
     'source-highlighter=prettify',
-    'stylesheet=doc.css',
+    'stylesheet=DEFAULT',
     'linkcss=true',
     'prettifydir=.',
     'revnumber="%s"' % revision,
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index d720287..0f73bd3 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -40,7 +40,7 @@
 for larger installations. It's the database backend with the largest userbase
 in the Gerrit community.
 
-Create a user for the web application within Postgres, assign it a
+Create a user for the web application within PostgreSQL, assign it a
 password, create a database to store the metadata, and grant the user
 full rights on the newly created database:
 
@@ -207,6 +207,51 @@
         password = secret_pasword
 ----
 
+[[createdb_hana]]
+=== SAP HANA
+
+SAP HANA is a supported database for running Gerrit Code Review. However it is
+recommended only for environments where you intend to run Gerrit on an existing
+HANA installation to reduce administrative overhead.
+
+In the HANA studio or the SAP HANA Web-based Development Workbench create a user
+'GERRIT2' with the role 'RESTRICTED_USER_JDBC_ACCESS' and a password
+<secret password>. This will also create an associated schema on the database.
+As this user would be required to change the password upon first login you might
+want to to disable the password lifetime check by executing
+'ALTER USER GERRIT2 DISABLE PASSWORD LIFETIME'.
+
+To run Gerrit on HANA, you need to obtain the HANA JDBC driver. It can be found
+as described
+link:http://help.sap.com/saphelp_hanaplatform/helpdata/en/ff/15928cf5594d78b841fbbe649f04b4/frameset.htm[here].
+It needs to be stored in the 'lib' folder of the review site.
+
+In the following sample database section it is assumed that HANA is running on
+the host 'hana.host' with the instance number 00 where a schema/user GERRIT2
+was created:
+
+In $site_path/etc/gerrit.config:
+
+----
+[database]
+        type = hana
+        instance = 00
+        hostname = hana.host
+        username = GERRIT2
+
+----
+
+In $site_path/etc/secure.config:
+
+----
+[database]
+        password = <secret password>
+----
+
+Visit SAP HANA's link:http://help.sap.com/hana_appliance/[documentation] for
+further information regarding using SAP HANA.
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-buck.txt b/Documentation/dev-buck.txt
index ec8515f..315c0b0 100644
--- a/Documentation/dev-buck.txt
+++ b/Documentation/dev-buck.txt
@@ -3,11 +3,11 @@
 
 == Installation
 
-Note that you need to use Java 7 for building gerrit.
+You need to use Java 7 and Node.js for building gerrit.
 
 There is currently no binary distribution of Buck, so it has to be manually
-built and installed.  Apache Ant is required.  Currently only Linux and Mac
-OS are supported.
+built and installed.  Apache Ant and gcc are required.  Currently only Linux
+and Mac OS are supported.
 
 Clone the git and build it:
 
@@ -47,7 +47,7 @@
 ----
 
 To enable autocompletion of buck commands, install the autocompletion
-script from `./scripts/buck_completion.bash` in the buck project.  Refer
+script from `./scripts/buck-completion.bash` in the buck project.  Refer
 to the script's header comments for installation instructions.
 
 == Prerequisites
@@ -81,13 +81,15 @@
 
 === Attaching Sources
 
-To save time and bandwidth source JARs are only downloaded by the buck
-build where necessary to compile Java source into JavaScript using the
-GWT compiler.  Additional sources may be obtained, allowing Eclipse to
-show documentation or dive into the implementation of a library JAR:
+Source JARs are downloaded by default. This allows Eclipse to show
+documentation or dive into the implementation of a library JAR.
+
+To save time and bandwidth, download of source JARs can be restricted
+to only those that are necessary to compile Java source into JavaScript
+using the GWT compiler:
 
 ----
-  tools/eclipse/project.py --src
+  tools/eclipse/project.py --no-src
 ----
 
 
@@ -97,18 +99,34 @@
 
 === Gerrit Development WAR File
 
-To build the Gerrit web application:
+To build the Gerrit web application that includes GWT UI and PolyGerrit UI:
 
 ----
   buck build gerrit
 ----
 
+[NOTE]
+PolyGerrit UI may require additional tools (such as npm). Please read
+the polygerrit-ui/README.md for more info.
+
 The output executable WAR will be placed in:
 
 ----
   buck-out/gen/gerrit/gerrit.war
 ----
 
+To build the Gerrit web application that includes only GWT UI:
+
+----
+  buck build gwtgerrit
+----
+
+The output executable WAR will be placed in:
+
+----
+  buck-out/gen/gwtgerrit/gwtgerrit.war
+----
+
 
 === Headless Mode
 
@@ -144,13 +162,13 @@
 Install {extension,plugin,gwt}-api to the local maven repository:
 
 ----
-  buck build api_install
+  tools/maven/api.sh install
 ----
 
 Install gerrit.war to the local maven repository:
 
 ----
-  buck build war_install
+  tools/maven/api.sh war_install
 ----
 
 === Plugins
@@ -596,7 +614,7 @@
 The following tests should be executed, when Buck version is upgraded:
 
 * buck build release
-* buck build api_install
+* tools/maven/api.sh install
 * buck test
 * buck build gerrit, change some sources in gerrit-server project,
   repeat buck build gerrit and verify that gerrit.war was updated
@@ -617,7 +635,7 @@
 [known bug](https://github.com/facebook/buck/issues/341) related to
 symbolic links. The symbolic links are used very often with external
 plugins, that are linked per symbolic link to the plugins directory.
-With this use case Buck is failing to rebuild the plugin artefact
+With this use case Buck is failing to rebuild the plugin artifact
 after it was built. All attempts to convince Buck to rebuild will fail.
 The only known way to recover is to weep out `buck-out` directory. The
 better workaround is to avoid using Watchman in this specific use case.
@@ -625,30 +643,6 @@
 link:#buck-daemon[Using Buck daemon] section above how to temporarily
 disable `buckd`.
 
-=== Re-triggering rule execution
-
-There is no way to re-trigger custom rules with side effects, like
-`api_{deploy|install}`. This is a `genrule()` that depends on Java sources
-and is deploying the Plugin API through custom Python script to the local or
-remote Maven repositories. When for some reasons the deployment was undone,
-there is no supported way to re-trigger the execution of `api_{deploy|install}`
-targets. That's because `--no-cache` option will ignore the `Buck` cache, but
-there is no way to ignore `buck-out` directory. To overcome this Buck's design
-limitation new `tools/maven/api.py` script was added, that always re-triggers
-installation or deployment of Plugin API to local or Central Maven repository.
-
-```
-  tools/maven/api.py {deploy|install}
-```
-
-Dry run mode is also supported:
-
-```
-  tools/maven/api.py -n {deploy|install}
-```
-
-With this script the deployment would re-trigger on every invocation.
-
 == Troubleshooting Buck
 
 In some cases problems with Buck itself need to be investigated. See for example
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 2c04d17..13071df 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -36,7 +36,7 @@
 * build and install `SNAPSHOT` version of plugin API in local Maven repository:
 
 ----
-buck build api_install
+./tools/maven/api.sh install
 ----
 
 === Exception 2:
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 2d96b84..775fe21 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -164,7 +164,8 @@
     contributors may also like to open several editors side by
     side while editing new changes.
   * Use 2 spaces for indent (no tabs)
-  * Use brackets in all ifs, spaces before/after if parens.
+  * Use braces in all if/else/for/do/while/catch blocks, spaces before/after
+    if/for/while/catch parens.
   * Use /** */ style Javadocs for variables.
 
 Additionally, you will notice that most of the newline spacing
@@ -182,7 +183,7 @@
 Always:
 
   * final fields: marking fields as final forces them to be
-  initialised in the constructor or at declaration
+  initialized in the constructor or at declaration
   * final static fields: clearly communicates the intent
   * to use final variables in inner anonymous classes
 
@@ -360,7 +361,7 @@
 * Determine the sha1 hash of the zip file:
 +
 ----
- openssl sha1 4.10.0-6-gd0a2dda.zip
+ openssl sha1 codemirror-4.10.0-6-gd0a2dda.zip
 ----
 
 * Upload the zip file to the
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index b8d01e8..4fa542d 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -24,6 +24,12 @@
   Could not write generated class ... javax.annotation.processing.FilerException: Source file already created
 ----
 
+and
+
+----
+  AutoAnnotation_Commands_named cannot be resolved to a type
+----
+
 In Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
diff --git a/Documentation/dev-inspector.txt b/Documentation/dev-inspector.txt
index 7c13a7d..2134f2f 100644
--- a/Documentation/dev-inspector.txt
+++ b/Documentation/dev-inspector.txt
@@ -4,14 +4,15 @@
 Gerrit Inspector - Interactive Jython environment for Gerrit
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'daemon'
-	-d <SITE_PATH>
-	[\--enable-httpd | \--disable-httpd]
-	[\--enable-sshd | \--disable-sshd]
-	[\--console-log]
-	[\--slave]
-	-s
+_java_ -jar gerrit.war _daemon_
+  -d <SITE_PATH>
+  [--enable-httpd | --disable-httpd]
+  [--enable-sshd | --disable-sshd]
+  [--console-log]
+  [--slave]
+  -s
 --
 
 == DESCRIPTION
@@ -283,7 +284,8 @@
 == KNOWN ISSUES
 The Inspector does not yet recognize Google Guice bindings.
 
-IMPORTANT: Using the Inspector may void your warranty.
+[IMPORTANT]
+Using the Inspector may void your warranty.
 
 GERRIT
 ------
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8b56937..3716c40 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -36,7 +36,7 @@
 ----
 mvn archetype:generate -DarchetypeGroupId=com.google.gerrit \
     -DarchetypeArtifactId=gerrit-plugin-archetype \
-    -DarchetypeVersion=2.12.8 \
+    -DarchetypeVersion=2.13.10 \
     -DgroupId=com.googlesource.gerrit.plugins.testplugin \
     -DartifactId=testplugin
 ----
@@ -90,12 +90,11 @@
 Plugins may provide optional description information with standard
 manifest fields:
 
-====
+----
   Implementation-Title: Example plugin showing examples
   Implementation-Version: 1.0
   Implementation-Vendor: Example, Inc.
-  Implementation-URL: http://example.com/opensource/plugin-foo/
-====
+----
 
 === ApiType
 
@@ -105,9 +104,9 @@
 API will be assumed. This may cause ClassNotFoundExceptions when
 loading a plugin that needs the plugin API.
 
-====
+----
   Gerrit-ApiType: plugin
-====
+----
 
 === Explicit Registration
 
@@ -120,20 +119,20 @@
 will be performed by scanning all classes in the plugin JAR for
 `@Listen` and `@Export("")` annotations.
 
-====
+----
   Gerrit-Module:     tld.example.project.CoreModuleClassName
   Gerrit-SshModule:  tld.example.project.SshModuleClassName
   Gerrit-HttpModule: tld.example.project.HttpModuleClassName
-====
+----
 
 [[plugin_name]]
 === Plugin Name
 
 A plugin can optionally provide its own plugin name.
 
-====
+----
   Gerrit-PluginName: replication
-====
+----
 
 This is useful for plugins that contribute plugin-owned capabilities that
 are stored in the `project.config` file. Another use case is to be able to put
@@ -217,9 +216,9 @@
 be used, as it enables the server to hot-patch an updated plugin
 with no down time.
 
-====
+----
   Gerrit-ReloadMode: restart
-====
+----
 
 In either mode ('restart' or 'reload') any plugin or extension can
 be updated without restarting the Gerrit server. The difference is
@@ -260,9 +259,9 @@
 contribute their own "init step" to allow configuring the Jira URL,
 credentials and possibly verify connectivity to validate them.
 
-====
+----
   Gerrit-InitStep: tld.example.project.MyInitStep
-====
+----
 
 MyInitStep needs to follow the standard Gerrit InitStep syntax
 and behavior: writing to the console using the injected ConsoleUI
@@ -381,10 +380,16 @@
 
 * `com.google.gerrit.common.EventListener`:
 +
-Allows to listen to events. These are the same
-link:cmd-stream-events.html#events[events] that are also streamed by
+Allows to listen to events without user visibility restrictions. These
+are the same link:cmd-stream-events.html#events[events] that are also streamed by
 the link:cmd-stream-events.html[gerrit stream-events] command.
 
+* `com.google.gerrit.common.UserScopedEventListener`:
++
+Allows to listen to events visible to the specified user. These are the
+same link:cmd-stream-events.html#events[events] that are also streamed
+by the link:cmd-stream-events.html[gerrit stream-events] command.
+
 * `com.google.gerrit.extensions.events.LifecycleListener`:
 +
 Plugin start and stop
@@ -409,6 +414,14 @@
 +
 Garbage collection ran on a project
 
+* `com.google.gerrit.server.extensions.events.ChangeIndexedListener`:
++
+Update of the change secondary index
+
+* `com.google.gerrit.server.extensions.events.AccountIndexedListener`:
++
+Update of the account secondary index
+
 [[stream-events]]
 == Sending Events to the Events Stream
 
@@ -416,17 +429,49 @@
 Gerrit's `stream-events` ssh command will receive them.
 
 To send an event, the plugin must invoke one of the `postEvent`
-methods in the `ChangeHookRunner` class, passing an instance of
+methods in the `EventDispatcher` interface, passing an instance of
 its own custom event class derived from
 `com.google.gerrit.server.events.Event`.
 
+[source,java]
+----
+import com.google.gerrit.common.EventDispatcher;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+class MyPlugin {
+  private final DynamicItem<EventDispatcher> eventDispatcher;
+
+  @Inject
+  myPlugin(DynamicItem<EventDispatcher> eventDispatcher) {
+    this.eventDispatcher = eventDispatcher;
+  }
+
+  private void postEvent(MyPluginEvent event) {
+    try {
+      eventDispatcher.get().postEvent(event);
+    } catch (OrmException e) {
+      // error handling
+    }
+  }
+}
+----
+
 Plugins which define new Events should register them via the
 `com.google.gerrit.server.events.EventTypes.registerClass()`
 method. This will make the EventType known to the system.
-Deserialzing events with the
+Deserializing events with the
 `com.google.gerrit.server.events.EventDeserializer` class requires
 that the event be registered in EventTypes.
 
+== Modifying the Stream Event Flow
+
+It is possible to modify the stream event flow from plugins by registering
+an `com.google.gerrit.server.events.EventDispatcher`. A plugin may register
+a Dispatcher class to replace the internal Dispatcher. EventDispatcher is
+a DynamicItem, so Gerrit may only have one copy.
+
 [[validation]]
 == Validation Listeners
 
@@ -457,6 +502,12 @@
 for those plugins which would like to monitor usage in Git
 repositories.
 
+[[post-upload-hook]]
+== Post Upload-Pack Hooks
+
+Plugins may register PostUploadHook instances in order to get notified after
+JGit is done uploading a pack.
+
 [[ssh]]
 == SSH Commands
 
@@ -587,6 +638,48 @@
 $ ssh -p 29418 review.example.com sh ps
 ----
 
+[[search_operators]]
+=== Search Operators ===
+
+Plugins can define new search operators to extend change searching by
+implementing the `ChangeQueryBuilder.ChangeOperatorFactory` interface
+and registering it to an operator name in the plugin module's
+`configure()` method.  The search operator name is defined during
+registration via the DynamicMap annotation mechanism.  The plugin
+name will get appended to the annotated name, with an underscore
+in between, leading to the final operator name.  An example
+registration looks like this:
+
+    bind(ChangeOperatorFactory.class)
+       .annotatedWith(Exports.named("sample"))
+       .to(SampleOperator.class);
+
+If this is registered in the `myplugin` plugin, then the resulting
+operator will be named `sample_myplugin`.
+
+The search operator itself is implemented by ensuring that the
+`create()` method of the class implementing the
+`ChangeQueryBuilder.ChangeOperatorFactory` interface returns a
+`Predicate<ChangeData>`.  Here is a sample operator factory
+definition which creates a `MyPredicate`:
+
+[source,java]
+----
+@Singleton
+public class SampleOperator
+    implements ChangeQueryBuilder.ChangeOperatorFactory {
+  public static class MyPredicate extends OperatorChangePredicate<ChangeData> {
+    ...
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder, String value)
+      throws QueryParseException {
+    return new MyPredicate(value);
+  }
+}
+----
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
@@ -654,6 +747,18 @@
   reviewer = My Info Developers
 ----
 
+Plugins that have sensitive configuration settings can store those settings in
+an own secure configuration file. The plugin's secure configuration file must be
+named after the plugin and must be located in the `etc` folder of the review
+site. For example a secure configuration file for a `default-reviewer` plugin
+could look like this:
+
+.$site_path/etc/default-reviewer.secure.config
+----
+[auth]
+  password = secret
+----
+
 Via the `com.google.gerrit.server.config.PluginConfigFactory` class a
 plugin can easily access its configuration:
 
@@ -666,6 +771,8 @@
 
 String[] reviewers = cfg.getGlobalPluginConfig("default-reviewer")
                         .getStringList("branch", "refs/heads/master", "reviewer");
+String password = cfg.getGlobalPluginConfig("default-reviewer")
+                     .getString("auth", null, "password");
 ----
 
 
@@ -997,15 +1104,26 @@
 Panel will be shown in the header bar on the right side of the pop down
 buttons.
 
+** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK`:
++
+Panel will be shown below the commit info block.
+
 ** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK`:
 +
 Panel will be shown below the change info block.
 
+** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK`:
++
+Panel will be shown below the related info block.
+
 ** The following parameters are provided:
 *** `GerritUiExtensionPoint.Key.CHANGE_INFO`:
 +
 The link:rest-api-changes.html#change-info[ChangeInfo] entity for the
 current change.
++
+The link:rest-api-changes.html#revision-info[RevisionInfo] entity for
+the current patch set.
 
 * Project Info Screen:
 ** `GerritUiExtensionPoint.PROJECT_INFO_SCREEN_TOP`:
@@ -1158,20 +1276,15 @@
   [...]
   // update change
   ReviewDb db = dbProvider.get();
-  db.changes().beginTransaction(change.getId());
-  try {
-    change = db.changes().atomicUpdate(
-      change.getId(),
-      new AtomicUpdate<Change>() {
-        @Override
-        public Change update(Change change) {
-          ChangeUtil.updated(change);
-          return change;
-        }
-      });
-    db.commit();
-  } finally {
-    db.rollback();
+  try (BatchUpdate bu = batchUpdateFactory.create(
+      db, project.getNameKey(), user, TimeUtil.nowTs())) {
+    bu.addOp(change.getId(), new BatchUpdate.Op() {
+      @Override
+      public boolean updateChange(BatchUpdate.ChangeContext ctx) {
+        return true;
+      }
+    });
+    bu.execute();
   }
   [...]
 }
@@ -1284,7 +1397,7 @@
 doesn't have to set `UiAction.Description.setVisible()` explicitly in this
 case.
 
-The following prerequisities must be met, to satisfy the capability check:
+The following prerequisites must be met, to satisfy the capability check:
 
 * user is authenticated
 * user is a member of a group which has the `Administrate Server` capability, or
@@ -1298,13 +1411,13 @@
 Every `UiAction` exposes a REST API endpoint. The endpoint from the example above
 can be accessed from any REST client, i. e.:
 
-====
+----
   curl -X POST -H "Content-Type: application/json" \
     -d '{message: "François", french: true}' \
     --digest --user joe:secret \
     http://host:port/a/changes/1/revisions/1/cookbook~say-hello
   "Bonjour François from change 1, patch set 1!"
-====
+----
 
 A special case is to bind an endpoint without a view name.  This is
 particularly useful for `DELETE` requests:
@@ -1746,7 +1859,7 @@
 
   @Inject
   public MyMenu(@PluginName String name) {
-    menuEntries = Lists.newArrayList();
+    menuEntries = new ArrayList<>();
     menuEntries.add(new MenuEntry("My Menu", Collections.singletonList(
       new MenuItem("My Screen", "#/x/" + name + "/my-screen", ""))));
   }
@@ -1912,6 +2025,32 @@
 No Guice bindings or modules are required. Gerrit will automatically
 discover and bind the implementation.
 
+[[accountcreation]]
+== Account Creation
+
+Plugins can hook into the
+link:rest-api-accounts.html#create-account[account creation] REST API and
+inject additional external identifiers for an account that represents a user
+in some external user store. For that, an implementation of the extension
+point `com.google.gerrit.server.api.accounts.AccountExternalIdCreator`
+must be registered.
+
+[source,java]
+----
+class MyExternalIdCreator implements AccountExternalIdCreator {
+  @Override
+  public List<AccountExternalId> create(Account.Id id, String username,
+      String email) {
+    // your code
+  }
+}
+
+bind(AccountExternalIdCreator.class)
+  .annotatedWith(UniqueAnnotations.create())
+  .to(MyExternalIdCreator.class);
+}
+----
+
 [[download-commands]]
 == Download Commands
 
@@ -1971,6 +2110,12 @@
 }
 ----
 
+ParentWebLinks will appear to the right of the SHA1 of the parent
+revisions in the UI. The implementation should in most use cases direct
+to the same external service as PatchSetWebLink; it is provided as a
+separate interface because not all users want to have links for the
+parent revisions.
+
 FileWebLinks will appear in the side-by-side diff screen on the right
 side of the patch selection on each side.
 
@@ -1982,6 +2127,144 @@
 
 BranchWebLinks will appear in the branch list in the last column.
 
+FileHistoryWebLinks will appear on the access rights screen.
+
+[[lfs-extension]]
+== LFS Storage Plugins
+
+Gerrit provides an extension point that enables development of
+link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
+LFS (Large File Storage)] storage plugins. Gerrit core exposes the default LFS
+protocol endpoint `<project-name>/info/lfs/objects/batch` and forwards the requests
+to the configured link:config-gerrit.html#lfs[lfs.plugin] plugin which implements
+the LFS protocol. By exposing the default LFS endpoint, the git-lfs client can be
+used without any configuration.
+
+[source, java]
+----
+/** Provide an LFS protocol implementation */
+import org.eclipse.jgit.lfs.server.LargeFileRepository;
+import org.eclipse.jgit.lfs.server.LfsProtocolServlet;
+
+@Singleton
+public class LfsApiServlet extends LfsProtocolServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final S3LargeFileRepository repository;
+
+  @Inject
+  LfsApiServlet(S3LargeFileRepository repository) {
+    this.repository = repository;
+  }
+
+  @Override
+  protected LargeFileRepository getLargeFileRepository() {
+    return repository;
+  }
+}
+
+/** Register the LfsApiServlet to listen on the default LFS protocol endpoint */
+import static com.google.gerrit.httpd.plugins.LfsPluginServlet.URL_REGEX;
+
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+
+public class HttpModule extends HttpPluginModule {
+
+  @Override
+  protected void configureServlets() {
+    serveRegex(URL_REGEX).with(LfsApiServlet.class);
+  }
+}
+
+/** Provide an implementation of the LargeFileRepository */
+import org.eclipse.jgit.lfs.server.s3.S3Repository;
+
+public class S3LargeFileRepository extends S3Repository {
+...
+}
+----
+
+[[metrics]]
+== Metrics
+
+=== Metrics Reporting
+
+To send Gerrit's metrics data to an external reporting backend, a plugin can
+get a `MetricRegistry` injected and register an instance of a class that
+implements the `Reporter` interface from link:http://metrics.dropwizard.io/[
+DropWizard Metrics].
+
+Metric reporting plugin implementations are provided for
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX],
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search],
+and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite].
+
+There is also a working example of reporting metrics to the console in the
+link:https://gerrit.googlesource.com/plugins/cookbook-plugin/+/master/src/main/java/com/googlesource/gerrit/plugins/cookbook/ConsoleMetricReporter.java[
+cookbook plugin].
+
+=== Providing own metrics
+
+Plugins may provide metrics to be dispatched to external reporting services by
+getting a `MetricMaker` injected and creating instances of specific types of
+metric:
+
+* Counter
++
+Metric whose value increments during the life of the process.
+
+* Timer
++
+Metric recording time spent on an operation.
+
+* Histogram
++
+Metric recording statistical distribution (rate) of values.
+
+Note that metrics cannot be recorded from plugin init steps that
+are run during site initialization.
+
+By default, plugin metrics are recorded under
+`plugins/${plugin-name}/${metric-name}`. This can be changed by
+setting `plugins.${plugin-name}.metricsPrefix` in the `gerrit.config`
+file. For example:
+
+----
+  [plugin "my-plugin"]
+    metricsPrefix = my-metrics
+----
+
+will cause the metrics to be recorded under `my-metrics/${metric-name}`.
+
+See the replication metrics in the
+link:https://gerrit.googlesource.com/plugins/replication/+/master/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationMetrics.java[
+replication plugin] for an example of usage.
+
+[[account-patch-review-store]]
+== AccountPatchReviewStore
+
+The AccountPatchReviewStore is used to store reviewed flags on changes.
+A reviewed flag is a tuple of (patch set ID, file, account ID) and
+records whether the user has reviewed a file in a patch set. Each user
+can easily have thousands of reviewed flags and the number of reviewed
+flags is growing without bound. The store must be able handle this data
+volume efficiently.
+
+Gerrit implements this extension point, but plugins may bind another
+implementation, e.g. one that supports multi-master.
+
+----
+DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+    .to(MultiMasterAccountPatchReviewStore.class);
+
+...
+
+public class MultiMasterAccountPatchReviewStore
+    implements AccountPatchReviewStore {
+  ...
+}
+----
+
 [[documentation]]
 == Documentation
 
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index b2c5358..921244f 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -142,8 +142,22 @@
   </distributionManagement>
 ----
 
+[NOTE]
+In case of JGit the `pom.xml` already contains a distributionManagement
+section.  Replace the existing distributionManagement section with this snippet
+in order to deploy the artifacts only in the gerrit-maven repository.
 
-* Add this to the `pom.xml` to enable the wagon provider:
+
+* Add these two snippets to the `pom.xml` to enable the wagon provider:
+
+----
+  <pluginRepositories>
+    <pluginRepository>
+      <id>gerrit-maven</id>
+      <url>https://gerrit-maven.commondatastorage.googleapis.com</url>
+    </pluginRepository>
+  </pluginRepositories>
+----
 
 ----
   <build>
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
new file mode 100644
index 0000000..f6d4d68
--- /dev/null
+++ b/Documentation/dev-release-jgit.txt
@@ -0,0 +1,41 @@
+= Making a Release of JGit
+
+This step is only necessary if we need to create an unofficial JGit
+snapshot release and publish it to the
+link:https://developers.google.com/storage/[Google Cloud Storage].
+
+
+[[prepare-release]]
+== Prepare the Release
+
+Since JGit has its own release process we do not push any release tags
+for JGit. Instead we will use the output of the `git describe` as the
+version of the current JGit snapshot.
+
+----
+  ./tools/version.sh --release $(git describe)
+----
+
+
+[[publish-release]]
+== Publish the Release
+
+* Make sure you have done the configuration needed for deployment:
+** link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
+Configuration in Maven `settings.xml`]
+** link:dev-release-deploy-config.html#deploy-configuration-subprojects[
+Configuration for Subprojects in `pom.xml`]
+
+* Deploy the new snapshot. From JGit workspace execute:
++
+----
+  mvn deploy
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-release-subproject.txt b/Documentation/dev-release-subproject.txt
index 9571edb..fcafea5 100644
--- a/Documentation/dev-release-subproject.txt
+++ b/Documentation/dev-release-subproject.txt
@@ -6,9 +6,9 @@
 * Build the latest snapshot and install it into the local Maven
 repository:
 +
-====
+----
   mvn clean install
-====
+----
 
 * Test Gerrit with this snapshot locally
 
@@ -27,9 +27,9 @@
 
 * Deploy the new snapshot:
 +
-====
+----
   mvn deploy
-====
+----
 
 * Change the `id`, `bin_sha1`, and `src_sha1` values in the `maven_jar`
 for the subproject in `/lib/BUCK` to the `SNAPSHOT` version.
@@ -50,15 +50,15 @@
 
 * Create the Release Tag
 +
-====
- git tag -a -m "prolog-cafe 1.3" v1.3
-====
+----
+  git tag -a -m "prolog-cafe 1.3" v1.3
+----
 
 * Build and install into local Maven repository:
 +
-====
+----
   mvn clean install
-====
+----
 
 
 [[publish-release]]
@@ -72,18 +72,18 @@
 
 * Deploy the new release:
 +
-====
+----
   mvn deploy
-====
+----
 
 * Push the pom change(s) to the project's repository
 `refs/for/<master|stable>`
 
 * Push the Release Tag
 +
-====
+----
   git push gerrit-review refs/tags/v1.3:refs/tags/v1.3
-====
+----
 
 
 GERRIT
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 3157214..96695db 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -1,12 +1,10 @@
 = Making a Gerrit Release
 
 [NOTE]
-========================================================================
 This document is meant primarily for Gerrit maintainers
 who have been given approval and submit status to the Gerrit
 projects.  Additionally, maintainers should be given owner
 status to the Gerrit web site.
-========================================================================
 
 To make a Gerrit release involves a great deal of complex
 tasks and it is easy to miss a step so this document should
@@ -34,16 +32,12 @@
 * If needed create a Gerrit `RC1`
 
 [NOTE]
-========================================================================
 You may let in a few features to this release
-========================================================================
 
 * If needed create a Gerrit `RC2`
 
 [NOTE]
-========================================================================
 There should be no new features in this release, only bug fixes
-========================================================================
 
 * Finally create the `stable` release (no `RC`)
 
@@ -154,17 +148,26 @@
 [[build-gerrit]]
 === Build Gerrit
 
-* Build the Gerrit WAR and API JARs
+* Build the Gerrit WAR, API JARs and documentation
 +
 ----
   buck clean
-  buck build --no-cache release
-  buck build api_install
+  buck build --no-cache release docs
+  ./tools/maven/api.sh install
 ----
 
 * Sanity check WAR
 * Test the new Gerrit version
 
+* Verify plugin versions
++
+Sometimes `buck` doesn't rebuild plugins after they are tagged, and the
+versions don't reflect the tag. Verify the versions:
++
+----
+  java -jar ./buck-out/gen/release/release.war init --list-plugins
+----
+
 [[publish-gerrit]]
 === Publish the Gerrit Release
 
@@ -182,26 +185,19 @@
 * Push the WAR to Maven Central:
 +
 ----
-  buck build war_deploy
+  ./tools/maven/api.sh war_deploy
 ----
 
 * Push the plugin artifacts to Maven Central:
 +
 ----
-  buck build api_deploy
-----
-+
-For troubleshooting, the environment variable `VERBOSE` can be set. This
-prints out the commands that are executed by the Buck build process:
-+
-----
-  VERBOSE=1 buck build api_deploy
+  ./tools/maven/api.sh deploy
 ----
 +
 If no artifacts are uploaded, clean the `buck-out` folder and retry:
 +
 ----
-  rm -rf buck-out
+  buck clean ; rm -rf buck-out
 ----
 
 * Push the plugin Maven archetypes to Maven Central:
@@ -296,7 +292,8 @@
 [[publish-to-google-storage]]
 ==== Publish the Gerrit WAR to the Google Cloud Storage
 
-* go to https://console.developers.google.com/project/164060093628/storage/gerrit-releases/
+* go to the link:https://console.cloud.google.com/storage/browser/gerrit-releases/?project=api-project-164060093628[
+gerrit-releases bucket in the Google cloud storage console]
 * make sure you are signed in with your Gmail account
 * manually upload the Gerrit WAR file by using the `Upload` button
 
@@ -333,20 +330,18 @@
 * Build the release notes:
 +
 ----
-  make -C ReleaseNotes
+  buck build releasenotes
 ----
 
-* Build the documentation:
-+
-----
-  buck build docs
-----
+* Extract the release notes files from the zip file generated from the previous
+step: `buck-out/gen/ReleaseNotes/html/html.zip`.
 
-* Extract the documentation html files from the generated zip file
-`buck-out/gen/Documentation/searchfree.zip`.
+* Extract the documentation files from the zip file generated from
+`buck build docs`: `buck-out/gen/Documentation/searchfree/searchfree.zip`.
 
-* Upload the html files manually via web browser to the
-link:https://console.developers.google.com/project/164060093628/storage/gerrit-documentation/[
+* Upload the files manually via web browser to the appropriate folder
+in the
+link:https://console.cloud.google.com/storage/browser/gerrit-documentation/?project=api-project-164060093628[
 gerrit-documentation] storage bucket.
 
 [[update-links]]
@@ -358,20 +353,15 @@
 [[update-issues]]
 ==== Update the Issues
 
-====
- How do the issues get updated?  Do you run a script to do
- this?  When do you do it, after the final 2.5 is released?
-====
-
-By hand.
+Update the issues by hand. There is no script for this.
 
 Our current process is an issue should be updated to say `Status =
 Submitted, FixedIn-2.5` once the change is submitted, but before the
 release.
 
 After the release is actually made, you can search in Google Code for
-``Status=Submitted FixedIn=2.5'' and then batch update these changes
-to say `Status=Released`. Make sure the pulldown says ``All Issues''
+`Status=Submitted FixedIn=2.5` and then batch update these changes
+to say `Status=Released`. Make sure the pulldown says `All Issues`
 because `Status=Submitted` is considered a closed issue.
 
 
@@ -383,13 +373,12 @@
 ** A link to the release and the release notes (if a final release)
 ** A link to the docs
 ** Describe the type of release (stable, bug fix, RC)
-
-* Add an entry to the `NEWS` section of the main Gerrit project web page
-** Go to: http://code.google.com/p/gerrit/admin
-** Add entry like:
-----
- * Jun 14, 2012 - Gerrit 2.4.1 [https://groups.google.com/d/topic/repo-discuss/jHg43gixqzs/discussion Released]
-----
+** Hash values (SHA1, SHA256, MD5) for the release WAR file.
++
+The SHA1 and MD5 can be taken from the artifact page on Sonatype. The
+SHA256 can be generated with
+`openssl sha -sha256 buck-out/gen/release/release.war` or an equivalent
+command.
 
 * Update the new discussion group announcement to be sticky
 ** Go to: http://groups.google.com/group/repo-discuss/topics
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
new file mode 100644
index 0000000..dfcbb6f
--- /dev/null
+++ b/Documentation/dev-stars.txt
@@ -0,0 +1,91 @@
+= Gerrit Code Review - Stars
+
+== Description
+
+Changes can be starred with labels that behave like private hashtags.
+Any label can be applied to a change, but these labels are only visible
+to the user for which the labels have been set.
+
+Stars allow users to categorize changes by self-defined criteria and
+then build link:user-dashboards.html[dashboards] for them by making use
+of the link:#query-stars[star query operators].
+
+[[star-api]]
+== Star API
+
+The link:rest-api-accounts.html#star-endpoints[star REST API] supports:
+
+* link:rest-api-accounts.html#get-stars[
+  get star labels from a change]
+* link:rest-api-accounts.html#set-stars[
+  update star labels on a change]
+* link:rest-api-accounts.html#get-starred-changes[
+  list changes that are starred by any label]
+
+Star labels are also included in
+link:rest-api-changes.html#change-info[ChangeInfo] entities that are
+returned by the link:rest-api-changes.html[changes REST API].
+
+There are link:rest-api-accounts.html#default-star-endpoints[
+additional REST endpoints] for the link:#default-star[default star].
+
+Only the link:#default-star[default star] is shown in the WebUI and
+can be updated from there. Other stars do not show up in the WebUI.
+
+[[default-star]]
+== Default Star
+
+If the default star is set by a user, this user is automatically
+notified by email whenever updates are made to that change.
+
+The default star is the star that is shown in the WebUI and which can
+be updated from there.
+
+The default star is represented by the special star label 'star'.
+
+[[ignore-star]]
+== Ignore Star
+
+If the ignore star is set by a user, this user gets no email
+notifications for updates of that change, even if this user is a
+reviewer of the change or the change is matched by a project watch of
+the user.
+
+Since changes can only be ignored once they are created, users that
+watch a project will always get the email notifications for the change
+creation. Only then the change can be ignored.
+
+Users that are added as reviewer to a change that they have ignored
+will be notified about this, so that they know about the review
+request. They can the decide to remove the ignore star.
+
+The ignore star is represented by the special star label 'ignore'.
+
+[[query-stars]]
+== Query Stars
+
+There are several query operators to find changes with stars:
+
+* link:user-search.html#star[star:<LABEL>]:
+  Matches any change that was starred by the current user with the
+  label `<LABEL>`.
+* link:user-search.html#has-stars[has:stars]:
+  Matches any change that was starred by the current user with any
+  label.
+* link:user-search.html#is-starred[is:starred] /
+  link:user-search.html#has-star[has:star]:
+  Matches any change that was starred by the current user with the
+  link:#default-star[default star].
+
+[[syntax]]
+== Syntax
+
+Star labels cannot contain whitespace characters. All other characters
+are allowed.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/doc.css.in b/Documentation/doc.css.in
deleted file mode 100644
index 429e81c..0000000
--- a/Documentation/doc.css.in
+++ /dev/null
@@ -1,60 +0,0 @@
-body {
-  margin: 1em auto;
-  width: 900px;
-}
-
-#toctitle {
-  margin-top: 0.5em;
-  font-weight: bold;
-}
-
-h1, h2, h3, h4, h5, h6, #toctitle {
-  color: #527bbd;
-  font-family: sans-serif;
-}
-
-h1, h2, h3 {
-  border-bottom: 2px solid silver;
-}
-
-h1 {
-  margin-top: 1.5em;
-}
-
-p {
-  margin: 0.5em 0 0.5em 0;
-}
-li p {
-  margin: 0.2em 0 0.2em 0;
-}
-
-#license > .content,
-.listingblock > .content {
-  border: 2px solid silver;
-  background: #ebebeb;
-  margin-left: 2em;
-  color: darkgreen;
-  padding: 2px;
-  overflow: auto;
-}
-
-#license > .content pre,
-.listingblock > .content pre {
-  background: none;
-  border: 0 solid silver;
-  padding: 0 0 0 0;
-}
-
-dl dt {
-  margin-top: 1em;
-}
-
-table.tableblock {
-  border-collapse: collapse;
-}
-
-table.tableblock,
-th.tableblock,
-td.tableblock {
-  border: 1px solid #EEE;
-}
diff --git a/Documentation/error-has-duplicates.txt b/Documentation/error-has-duplicates.txt
index 8294c12..a520f5d 100644
--- a/Documentation/error-has-duplicates.txt
+++ b/Documentation/error-has-duplicates.txt
@@ -1,4 +1,4 @@
-= \... has duplicates
+= ... has duplicates
 
 With this error message Gerrit rejects to push a commit if its commit
 message contains a Change-Id for which multiple changes can be found
diff --git a/Documentation/error-messages.txt b/Documentation/error-messages.txt
index 16bc37b..2632254 100644
--- a/Documentation/error-messages.txt
+++ b/Documentation/error-messages.txt
@@ -20,7 +20,6 @@
 * link:error-missing-changeid.html[missing Change-Id in commit message footer]
 * link:error-missing-subject.html[missing subject; Change-Id must be in commit message footer]
 * link:error-multiple-changeid-lines.html[multiple Change-Id lines in commit message footer]
-* link:error-no-changes-made.html[no changes made]
 * link:error-no-common-ancestry.html[no common ancestry]
 * link:error-no-new-changes.html[no new changes]
 * link:error-non-fast-forward.html[non-fast forward]
@@ -32,7 +31,7 @@
 * link:error-permission-denied.html[Permission denied (publickey)]
 * link:error-prohibited-by-gerrit.html[prohibited by Gerrit]
 * link:error-project-not-found.html[Project not found: ...]
-* link:error-squash-commits-first.html[squash commits first]
+* link:error-same-change-id-in-multiple-changes.html[same Change-Id in multiple changes]
 * link:error-upload-denied.html[Upload denied for project \'...']
 * link:error-not-allowed-to-upload-merges.html[you are not allowed to upload merges]
 
diff --git a/Documentation/error-no-changes-made.txt b/Documentation/error-no-changes-made.txt
deleted file mode 100644
index 6182fcf..0000000
--- a/Documentation/error-no-changes-made.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-= no changes made
-
-With this error message Gerrit rejects to push a commit as a new
-patch set for a change, if the pushed commit is identical to the
-current patch set of this change.
-
-A pushed commit is considered to be identical to the current patch
-set if
-
-- the files in the commit,
-- the commit message,
-- the author of the commit and
-- the parents of the commit
-
-are all identical.
-
-
-GERRIT
-------
-Part of link:error-messages.html[Gerrit Error Messages]
-
-SEARCHBOX
----------
diff --git a/Documentation/error-same-change-id-in-multiple-changes.txt b/Documentation/error-same-change-id-in-multiple-changes.txt
new file mode 100644
index 0000000..b6aad69
--- /dev/null
+++ b/Documentation/error-same-change-id-in-multiple-changes.txt
@@ -0,0 +1,112 @@
+= same Change-Id in multiple changes
+
+With this error message Gerrit rejects to push a commit if it
+contains the same Change-Id as a predecessor commit.
+
+The reason for rejecting such a commit is that it would introduce, for
+the corresponding change in Gerrit, a dependency upon itself. Gerrit
+prevents such dependencies between patch sets within the same change
+to keep the review process simple. Otherwise reviewers would not only
+have to review the latest patch set but also all the patch sets the
+latest one depends on.
+
+This error is quite common, it appears when a user tries to address
+review comments and creates a new commit instead of amending the
+existing commit. Another possibility for this error, although less
+likely, is that the user tried to create a patch series with multiple
+changes to be reviewed and accidentally included the same Change-Id
+into the different commit messages.
+
+
+== Example
+
+Here an example about how the push is failing. Please note that the
+two commits 'one commit' and 'another commit' both have the same
+Change-Id (of course in real life it can happen that there are more
+than two commits that have the same Change-Id).
+
+----
+  $ git log
+  commit 13d381265ffff88088e1af88d0e2c2c1143743cd
+  Author: John Doe <john.doe@example.com>
+  Date:   Thu Dec 16 10:15:48 2010 +0100
+
+      another commit
+
+      Change-Id: I93478acac09965af91f03c82e55346214811ac79
+
+  commit ca45e125145b12fe9681864b123bc9daea501bf7
+  Author: John Doe <john.doe@example.com>
+  Date:   Thu Dec 16 10:12:54 2010 +0100
+
+      one commit
+
+      Change-Id: I93478acac09965af91f03c82e55346214811ac79
+
+  $ git push ssh://JohnDoe@host:29418/myProject HEAD:refs/for/master
+  Counting objects: 8, done.
+  Delta compression using up to 2 threads.
+  Compressing objects: 100% (2/2), done.
+  Writing objects: 100% (6/6), 558 bytes, done.
+  Total 6 (delta 0), reused 0 (delta 0)
+  To ssh://JohnDoe@host:29418/myProject
+  ! [remote rejected] HEAD -> refs/for/master (same Change-Id in multiple changes.
+  Squash the commits with the same Change-Id or ensure Change-Ids are unique for each commit)
+  error: failed to push some refs to 'ssh://JohnDoe@host:29418/myProject'
+
+----
+
+If it was the intention to rework a change and push a new patch
+set, the problem can be fixed by squashing the commits that contain the
+same Change-Id. The squashed commit can then be pushed to Gerrit.
+
+To squash the commits, use `git rebase -i` to do an interactive rebase. For
+the example above where the last two commits have the same Change-Id,
+this means an interactive rebase for the last two commits should be
+done. For further details about the git rebase command please check
+the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation for rebase].
+
+----
+  $ git rebase -i HEAD~2
+
+  pick ca45e12 one commit
+  squash 13d3812 another commit
+
+  [detached HEAD ab37207] squashed commit
+   1 files changed, 3 insertions(+), 0 deletions(-)
+  Successfully rebased and updated refs/heads/master.
+
+  $ git log
+  commit ab37207d33647685801dba36cb4fd51f3eb73507
+  Author: John Doe <john.doe@example.com>
+  Date:   Thu Dec 16 10:12:54 2010 +0100
+
+      squashed commit
+
+      Change-Id: I93478acac09965af91f03c82e55346214811ac79
+
+  $ git push ssh://JohnDoe@host:29418/myProject HEAD:refs/for/master
+  Counting objects: 5, done.
+  Writing objects: 100% (3/3), 307 bytes, done.
+  Total 3 (delta 0), reused 0 (delta 0)
+  To ssh://JohnDoe@host:29418/myProject
+   * [new branch]      HEAD -> refs/for/master
+----
+
+If it was the intention to create a patch series with multiple
+changes to be reviewed, each commit message should contain the
+Change-Id of the corresponding change in Gerrit.  If a change in
+Gerrit does not exist yet, the Change-Id should be generated (either
+by using a link:cmd-hook-commit-msg.html[commit hook] or by using EGit) or the Change-Id could be
+removed (not recommended since then amending this commit to create
+subsequent patch sets is more error prone). To change the Change-Id
+of an existing commit do an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase] and fix the
+affected commit messages.
+
+
+GERRIT
+------
+Part of link:error-messages.html[Gerrit Error Messages]
+
+SEARCHBOX
+---------
diff --git a/Documentation/error-squash-commits-first.txt b/Documentation/error-squash-commits-first.txt
deleted file mode 100644
index 4069d5b..0000000
--- a/Documentation/error-squash-commits-first.txt
+++ /dev/null
@@ -1,110 +0,0 @@
-= squash commits first
-
-With this error message Gerrit rejects to push a commit if it
-contains the same Change-Id as a predecessor commit.
-
-The reason for rejecting such a commit is that it would introduce, for
-the corresponding change in Gerrit, a dependency upon itself. Gerrit
-prevents such dependencies between patch sets within the same change
-to keep the review process simple. Otherwise reviewers would not only
-have to review the latest patch set but also all the patch sets the
-latest one depends on.
-
-This error is quite common, it appears when a user tries to address
-review comments and creates a new commit instead of amending the
-existing commit. Another possibility for this error, although less
-likely, is that the user tried to create a patch series with multiple
-changes to be reviewed and accidentally included the same Change-Id
-into the different commit messages.
-
-
-== Example
-
-Here an example about how the push is failing. Please note that the
-two commits 'one commit' and 'another commit' both have the same
-Change-Id (of course in real life it can happen that there are more
-than two commits that have the same Change-Id).
-
-----
-  $ git log
-  commit 13d381265ffff88088e1af88d0e2c2c1143743cd
-  Author: John Doe <john.doe@example.com>
-  Date:   Thu Dec 16 10:15:48 2010 +0100
-
-      another commit
-
-      Change-Id: I93478acac09965af91f03c82e55346214811ac79
-
-  commit ca45e125145b12fe9681864b123bc9daea501bf7
-  Author: John Doe <john.doe@example.com>
-  Date:   Thu Dec 16 10:12:54 2010 +0100
-
-      one commit
-
-      Change-Id: I93478acac09965af91f03c82e55346214811ac79
-
-  $ git push ssh://JohnDoe@host:29418/myProject HEAD:refs/for/master
-  Counting objects: 8, done.
-  Delta compression using up to 2 threads.
-  Compressing objects: 100% (2/2), done.
-  Writing objects: 100% (6/6), 558 bytes, done.
-  Total 6 (delta 0), reused 0 (delta 0)
-  To ssh://JohnDoe@host:29418/myProject
-   ! [remote rejected] HEAD -> refs/for/master (squash commits first)
-  error: failed to push some refs to 'ssh://JohnDoe@host:29418/myProject'
-----
-
-If it was the intention to rework a change and push a new patch
-set, the problem can be fixed by squashing the commits that contain the
-same Change-Id. The squashed commit can then be pushed to Gerrit.
-
-To squash the commits, use `git rebase -i` to do an interactive rebase. For
-the example above where the last two commits have the same Change-Id,
-this means an interactive rebase for the last two commits should be
-done. For further details about the git rebase command please check
-the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation for rebase].
-
-----
-  $ git rebase -i HEAD~2
-
-  pick ca45e12 one commit
-  squash 13d3812 another commit
-
-  [detached HEAD ab37207] squashed commit
-   1 files changed, 3 insertions(+), 0 deletions(-)
-  Successfully rebased and updated refs/heads/master.
-
-  $ git log
-  commit ab37207d33647685801dba36cb4fd51f3eb73507
-  Author: John Doe <john.doe@example.com>
-  Date:   Thu Dec 16 10:12:54 2010 +0100
-
-      squashed commit
-
-      Change-Id: I93478acac09965af91f03c82e55346214811ac79
-
-  $ git push ssh://JohnDoe@host:29418/myProject HEAD:refs/for/master
-  Counting objects: 5, done.
-  Writing objects: 100% (3/3), 307 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  To ssh://JohnDoe@host:29418/myProject
-   * [new branch]      HEAD -> refs/for/master
-----
-
-If it was the intention to create a patch series with multiple
-changes to be reviewed, each commit message should contain the
-Change-Id of the corresponding change in Gerrit.  If a change in
-Gerrit does not exist yet, the Change-Id should be generated (either
-by using a link:cmd-hook-commit-msg.html[commit hook] or by using EGit) or the Change-Id could be
-removed (not recommended since then amending this commit to create
-subsequent patch sets is more error prone). To change the Change-Id
-of an existing commit do an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase] and fix the
-affected commit messages.
-
-
-GERRIT
-------
-Part of link:error-messages.html[Gerrit Error Messages]
-
-SEARCHBOX
----------
diff --git a/Documentation/error-upload-denied.txt b/Documentation/error-upload-denied.txt
index 6de94b4..30c5f2d 100644
--- a/Documentation/error-upload-denied.txt
+++ b/Documentation/error-upload-denied.txt
@@ -1,5 +1,4 @@
-Upload denied for project \'...'
-=================================
+= Upload denied for project ...
 
 With this error message Gerrit rejects to push a commit if the
 pushing user has no upload permissions for the project to which the
diff --git a/Documentation/gen_licenses.py b/Documentation/gen_licenses.py
index db3480b..15f470c 100755
--- a/Documentation/gen_licenses.py
+++ b/Documentation/gen_licenses.py
@@ -19,8 +19,8 @@
 
 import argparse
 from collections import defaultdict, deque
+import json
 from os import chdir, path
-import re
 from shutil import copyfileobj
 from subprocess import Popen, PIPE
 from sys import stdout, stderr
@@ -28,7 +28,6 @@
 parser = argparse.ArgumentParser()
 parser.add_argument('--asciidoc', action='store_true')
 parser.add_argument('--partial', action='store_true')
-parser.add_argument('--classpath', action='append')
 parser.add_argument('targets', nargs='+')
 args = parser.parse_args()
 
@@ -38,37 +37,30 @@
   '//lib/bouncycastle:bcprov',
 ]
 
+for target in args.targets:
+  if not target.startswith('//'):
+    print('Target must be absolute: %s' % target, file=stderr)
+
 def parse_graph():
   graph = defaultdict(list)
   while not path.isfile('.buckconfig'):
     chdir('..')
-  # TODO(davido): use passed in classpath from Buck instead
-  p = Popen(
-    ['buck', 'audit', 'classpath', '--dot'] + args.targets,
-    stdout = PIPE)
-  for line in p.stdout:
-    m = re.search(r'"(//.*?)" -> "(//.*?)";', line)
-    if not m:
-      continue
-    target, dep = m.group(1), m.group(2)
-    if args.partial:
-      if dep == '//lib/codemirror:js_minifier':
-        if target == '//lib/codemirror:js':
-          continue
-        if target.startswith('//lib/codemirror:mode_'):
-          continue
-      if target == '//gerrit-gwtui:ui_module' and \
-         dep == '//gerrit-gwtexpui:CSS':
+  query = ' + '.join('deps(%s)' % t for t in args.targets)
+  p = Popen([
+      'buck', 'query', query,
+      '--output-attributes=buck.direct_dependencies'], stdout=PIPE)
+  obj = json.load(p.stdout)
+  for target, attrs in obj.iteritems():
+    for dep in attrs['buck.direct_dependencies']:
+
+      if target in KNOWN_PROVIDED_DEPS:
         continue
 
-    # Dependencies included in provided_deps set are contained in audit
-    # classpath and must be sorted out. That's safe thing to do because
-    # they are not included in the final artifact.
-    if "DO_NOT_DISTRIBUTE" in dep:
-      if not target in KNOWN_PROVIDED_DEPS:
-        print('DO_NOT_DISTRIBUTE license for target: %s' % target, file=stderr)
-        exit(1)
-    else:
+      if (args.partial
+          and dep == '//gerrit-gwtexpui:CSS'
+          and target == '//gerrit-gwtui:ui_module'):
+        continue
+
       graph[target].append(dep)
   r = p.wait()
   if r != 0:
@@ -78,28 +70,39 @@
 graph = parse_graph()
 licenses = defaultdict(set)
 
+do_not_distribute = False
 queue = deque(args.targets)
 while queue:
   target = queue.popleft()
   for dep in graph[target]:
     if not dep.startswith('//lib:LICENSE-'):
       continue
+    if 'DO_NOT_DISTRIBUTE' in dep:
+      do_not_distribute = True
     licenses[dep].add(target)
   queue.extend(graph[target])
+
+if do_not_distribute:
+  print('DO_NOT_DISTRIBUTE license found', file=stderr)
+  for target in args.targets:
+    print('...via %s:' % target)
+    Popen(['buck', 'query',
+           'allpaths(%s, //lib:LICENSE-DO_NOT_DISTRIBUTE)' % target],
+          stdout=stderr).communicate()
+  exit(1)
+
 used = sorted(licenses.keys())
 
 if args.asciidoc:
   print("""\
-Gerrit Code Review - Licenses
-=============================
+= Gerrit Code Review - Licenses
 
 Gerrit open source software is licensed under the <<Apache2_0,Apache
 License 2.0>>.  Executable distributions also include other software
 components that are provided under additional licenses.
 
 [[cryptography]]
-Cryptography Notice
--------------------
+== Cryptography Notice
 
 This distribution includes cryptographic software.  The country
 in which you currently reside may have restrictions on the import,
@@ -134,8 +137,7 @@
 link:http://www.bouncycastle.org/java.html[Bouncy Castle Crypto API]
 to be installed by the end-user.
 
-Licenses
---------
+== Licenses
 """)
 
 for n in used:
@@ -144,27 +146,29 @@
   if args.asciidoc:
     print()
     print('[[%s]]' % name.replace('.', '_'))
-    print(name)
-    print('~' * len(name))
+    print("=== " + name)
     print()
   else:
     print()
     print(name)
-    print('--')
+    print()
+    print('----')
   for d in libs:
     if d.startswith('//lib:') or d.startswith('//lib/'):
       p = d[len('//lib:'):]
     else:
       p = d[d.index(':')+1:].lower()
+    if '__' in p:
+      p = p[:p.index('__')]
     print('* ' + p)
   if args.asciidoc:
     print()
-    print('[[license]]')
-    print('[verse]')
-    print('--')
+    print('[[%s_license]]' % name.replace('.', '_'))
+    print('----')
   with open(n[2:].replace(':', '/')) as fd:
     copyfileobj(fd, stdout)
-  print('--')
+  print()
+  print('----')
 
 if args.asciidoc:
   print("""
diff --git a/Documentation/images/link.png b/Documentation/images/link.png
deleted file mode 100644
index 621443e..0000000
--- a/Documentation/images/link.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-not-current.png b/Documentation/images/user-review-ui-change-screen-not-current.png
new file mode 100644
index 0000000..9a87c67
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-not-current.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 63e3be6..f53463c 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -25,16 +25,15 @@
 
 == Project Management
 . link:project-configuration.html[Project Configuration]
-. link:access-control.html[Access Controls]
 .. link:config-labels.html[Review Labels]
-.. link:config-project-config.html[Access Controls Configuration Format]
+.. link:config-project-config.html[Project Configuration File Format]
+. link:access-control.html[Access Controls]
 . Multi-project management
 .. link:user-submodules.html[Submodules]
 .. link:https://source.android.com/source/using-repo.html[Repo] (external)
 . Prolog rules
 .. link:prolog-cookbook.html[Prolog Cookbook]
 .. link:prolog-change-facts.html[Prolog Facts for Gerrit Changes]
-. link:user-submodules.html[Subscribing to Git Submodules]
 . link:intro-project-owner.html#project-deletion[Project deletion]
 
 == Customization and Integration
@@ -53,6 +52,7 @@
 . link:cmd-index.html[Command Line Tools]
 . link:config-plugins.html#replication[Replication]
 . link:config-plugins.html[Plugins]
+. link:metrics.html[Metrics]
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
@@ -68,12 +68,14 @@
 .. link:dev-build-plugins.html[Building Gerrit plugins]
 .. link:js-api.html[JavaScript Plugin API]
 .. link:config-validation.html[Validation Interfaces]
+.. link:dev-stars.html[Starring Changes]
 . link:dev-design.html[System Design]
 . link:i18n-readme.html[i18n Support]
 
 == Maintainer
-. link:dev-release.html[Developer Release]
-. link:dev-release-subproject.html[Developer Subproject Release]
+. link:dev-release.html[Making a Gerrit Release]
+. link:dev-release-subproject.html[Making a Release of a Gerrit Subproject]
+. link:dev-release-jgit.html[Making a Release of JGit]
 
 == Resources
 * link:licenses.html[Licenses and Notices]
@@ -83,5 +85,9 @@
 * link:https://gerrit.googlesource.com/gerrit[Source Code]
 * link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review]
 
+GERRIT
+------
+Part of link:https://www.gerritcodereview.com/[Gerrit Code Review]
+
 SEARCHBOX
 ---------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 3f7d1c1..e3fb28d 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -17,7 +17,8 @@
 Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files_
 from Oracle and installing them into your JRE.
 
-NOTE: Installing JCE extensions is optional and export restrictions may apply.
+[NOTE]
+Installing JCE extensions is optional and export restrictions may apply.
 
 . Download the unlimited strength JCE policy files.
 +
@@ -82,7 +83,8 @@
   java -jar gerrit.war init -d /path/to/your/gerrit_application_directory
 ----
 
-'Please note:' If you choose a location where your new user doesn't
+[NOTE]
+If you choose a location where your new user doesn't
 have any privileges, you may have to manually create the directory first and
 then give ownership of that location to the `'gerrit2'` user.
 
@@ -137,11 +139,11 @@
 To control the Gerrit Code Review daemon that is running in the
 background, use the rc.d style start script created by 'init':
 
-====
+----
   review_site/bin/gerrit.sh start
   review_site/bin/gerrit.sh stop
   review_site/bin/gerrit.sh restart
-====
+----
 
 ('Optional') Configure the daemon to automatically start and stop
 with the operating system.
@@ -149,18 +151,18 @@
 Uncomment the following 3 lines in the `'$site_path/bin/gerrit.sh'`
 script:
 
-====
+----
  chkconfig: 3 99 99
  description: Gerrit Code Review
  processname: gerrit
-====
+----
 
 Then link the `gerrit.sh` script into `rc3.d`:
 
-====
+----
   sudo ln -snf `pwd`/review_site/bin/gerrit.sh /etc/init.d/gerrit
   sudo ln -snf /etc/init.d/gerrit /etc/rc3.d/S90gerrit
-====
+----
 
 ('Optional') To enable autocompletion of the gerrit.sh commands, install
 autocompletion from the `/contrib/bash_completion` script.  Refer to the
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index dfffe57..7a724f7 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -69,11 +69,11 @@
 cloned the repository you can do this by executing the following
 commands:
 
-====
+----
   $ git fetch origin refs/meta/config:config
   $ git checkout config
   $ git log project.config
-====
+----
 
 Non project owners may still edit the access rights and propose the
 modifications to the project owners by clicking on the `Save for
@@ -519,11 +519,11 @@
 to an issue in an issue tracker system. For example, to link the ID
 from the `Bug` footer to Jira the following configuration can be used:
 
-====
+----
   [commentlink "myjira"]
     match = ([Bb][Uu][Gg]:\\s+)(\\S+)
     link =  https://myjira/browse/$2
-====
+----
 
 [[reviewers]]
 == Reviewers
@@ -594,11 +594,11 @@
 The project-specific download commands must be configured in the
 `project.config` file in the `refs/meta/config` branch of the project:
 +
-====
+----
   [plugin "project-download-commands"]
     Build = git fetch ${url} ${ref} && git checkout FETCH_HEAD && buck build ${project}
     Update = git fetch ${url} ${ref} && git checkout FETCH_HEAD && git submodule update
-====
+----
 +
 Project-specific download commands that are defined on a parent project
 are inherited by the child projects. A child project can overwrite an
@@ -652,7 +652,7 @@
 How to develop a Gerrit plugin is described in the link:dev-plugins.html[
 Plugin Development] section.
 
-[[prject-lifecycle]]
+[[project-lifecycle]]
 == Project Lifecycle
 
 [[project-creation]]
@@ -705,9 +705,9 @@
 commits (the author information that records who was writing the code
 stays intact; signed tags will lose their signature):
 
-====
+----
   $ git filter-branch --tag-name-filter cat --env-filter 'GIT_COMMITTER_NAME="John Doe"; GIT_COMMITTER_EMAIL="john.doe@example.com";' -- --all
-====
+----
 
 If a link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] is configured on the server you may need to remove large objects
@@ -715,20 +715,22 @@
 the history of your project you can use the `reposize.sh` script which
 you can download from Gerrit:
 
+----
   $ curl -Lo reposize.sh http://review.example.com:8080/tools/scripts/reposize.sh
 
 or
 
   $ scp -p -P 29418 john.doe@review.example.com:scripts/reposize.sh .
+----
 
 You can then use the
 link:https://www.kernel.org/pub/software/scm/git/docs/git-filter-branch.html[
 git filter-branch] command to remove the large objects from the history
 of all branches:
 
-====
+----
   $ git filter-branch -f --index-filter 'git rm --cached --ignore-unmatch path/to/large-file.jar' -- --all
-====
+----
 
 Since this command rewrites all commits in the repository it's a good
 idea to create a fresh clone from this rewritten repository before
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index b9bdad0..9bf6842 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -583,22 +583,6 @@
 
 The following preferences can be configured:
 
-- [[show-site-header]]`Show Site Header`:
-+
-Whether the site header should be shown.
-
-- [[use-flash]]`Use Flash Clipboard Widget`:
-+
-Whether the Flash clipboard widget should be used. If enabled Gerrit
-offers a copy-to-clipboard icon next to IDs and commands that need to
-be copied frequently, such as the Change-Ids, commit IDs and download
-commands.
-
-- [[cc-me]]`CC Me On Comments I Write`:
-+
-Whether you get notified by email as CC on comments that you write
-yourself.
-
 - [[review-category]]`Display In Review Category`:
 +
 This setting controls how the values of the review labels in change
@@ -607,7 +591,7 @@
 ** `None`:
 +
 For each review label only the voting value is shown. Approvals are
-rendered as a green check mark icon, vetos as a red X icon.
+rendered as a green check mark icon, vetoes as a red X icon.
 +
 ** `Show Name`:
 +
@@ -638,6 +622,32 @@
 +
 The format that should be used to render dates and timestamps.
 
+- [[email-notifications]]`Email Notifications`:
++
+This setting controls the email notifications.
++
+** `Enabled`:
++
+Email notifications are enabled.
++
+** [[cc-me]]`CC Me On Comments I Write`:
++
+Email notifications are enabled and you get notified by email as CC
+on comments that you write yourself.
++
+** `Disabled`:
++
+Email notifications are disabled.
+
+- [[diff-view]]`Diff View`:
++
+Whether the Side-by-Side diff view or the Unified diff view should be
+shown when clicking on a file path in the change screen.
+
+- [[show-site-header]]`Show Site Header / Footer`:
++
+Whether the site header and footer should be shown.
+
 - [[relative-dates]]`Show Relative Dates In Changes Table`:
 +
 Whether timestamps in change lists and dashboards should be shown as
@@ -660,10 +670,20 @@
 Whether common path prefixes in the file list on the change screen
 should be link:user-review-ui.html#repeating-path-segments[grayed out].
 
-- [[diff-view]]`Diff View`:
+- [[inline-signed-off]]`Insert Signed-off-by Footer For Inline Edit Changes`:
 +
-Whether the Side-by-Side diff view or the Unified diff view should be
-shown when clicking on a file path in the change screen.
+Whether a `Signed-off-by` footer should be automatically inserted into
+changes that are created from the web UI (e.g. by the `Create Change`
+and `Edit Config` buttons on the project screen, and the `Follow-Up`
+button on the change screen).
+
+- [[use-flash]]`Use Flash Clipboard Widget`:
++
+Whether the Flash clipboard widget should be used. If enabled and the Flash
+plugin is available, Gerrit offers a copy-to-clipboard icon next to IDs and
+commands that need to be copied frequently, such as the Change-Ids, commit IDs
+and download commands. Note that this option is only shown if the Flash plugin
+is available and the JavaScript Clipboard API is unavailable.
 
 [[my-menu]]
 In addition it is possible to customize the menu entries of the `My`
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 03ff5a5..8c9950e 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -176,6 +176,13 @@
   function must return true to allow the operation to continue, or
   false to prevent it.
 
+* `comment`: Invoked when a DOM element that represents a comment is
+  created. This DOM element is passed as argument. This DOM element
+  contains nested elements that Gerrit uses to format the comment. The
+  DOM structure may differ between comment types such as inline
+  comments, file-level comments and summary comments, and it may change
+  with new Gerrit versions.
+
 [[self_onAction]]
 === self.onAction()
 Register a JavaScript callback to be invoked when the user clicks
@@ -219,6 +226,50 @@
 * callback: JavaScript function to invoke when the user navigates to
   the screen. The function will be passed a link:#ScreenContext[screen context].
 
+[[self_settingsScreen]]
+=== self.settingsScreen()
+Register a JavaScript callback to be invoked when the user navigates
+to an extension settings screen provided by the plugin. Extension settings
+screens are automatically linked from the settings menu under the given
+menu entry.
+The callback can populate the DOM with the screen's contents.
+
+.Signature
+[source,javascript]
+----
+self.settingsScreen(path, menu, callback);
+----
+
+* path: URL path to identify the settings screen.
+
+* menu: The name of the menu entry in the settings menu that should
+  link to the settings screen.
+
+* callback: JavaScript function to invoke when the user navigates to
+  the settings screen. The function will be passed a
+  link:#SettingsScreenContext[settings screen context].
+
+[[self_panel]]
+=== self.panel()
+Register a JavaScript callback to be invoked when a screen with the
+given extension point is loaded.
+The callback can populate the DOM with the panel's contents.
+
+.Signature
+[source,javascript]
+----
+self.panel(extensionpoint, callback);
+----
+
+* extensionpoint: The name of the extension point that marks the
+  position where the panel is added to an existing screen. The
+  available extension points are described in the
+  link:dev-plugins.html#panels[plugin development documentation].
+
+* callback: JavaScript function to invoke when a screen with the
+  extension point is loaded. The function will be passed a
+  link:#PanelContext[panel context].
+
 [[self_url]]
 === self.url()
 Returns a URL within the plugin's URL space. If invoked with no
@@ -562,6 +613,86 @@
 Destroy the currently visible screen and display the plugin's screen.
 This method must be called after adding content to `screen.body`.
 
+[[SettingsScreenContext]]
+== Settings Screen Context
+A new settings screen context is passed to the `settingsScreen` callback
+function each time the user navigates to a matching URL.
+
+[[settingsScreen_body]]
+=== settingsScreen.body
+Empty HTML `<div>` node the plugin should add its content to.  The
+node is already attached to the document, but is invisible.  Plugins
+must call `settingsScreen.show()` to display the DOM node.  Deferred
+display allows an implementor to partially populate the DOM, make
+remote HTTP requests, finish populating when the callbacks arrive, and
+only then make the view visible to the user.
+
+[[settingsScreen_onUnload]]
+=== settingsScreen.onUnload()
+Configures an optional callback to be invoked just before the screen
+is deleted from the browser DOM.  Plugins can use this callback to
+remove event listeners from DOM nodes, preventing memory leaks.
+
+.Signature
+[source,javascript]
+----
+settingsScreen.onUnload(callback)
+----
+
+* callback: JavaScript function to be invoked just before the
+  `settingsScreen.body` DOM element is removed from the browser DOM.
+  This event happens when the user navigates to another screen.
+
+[[settingsScreen.setTitle]]
+=== settingsScreen.setTitle()
+Sets the heading text to be displayed when the screen is visible.
+This is presented in a large bold font below the menus, but above the
+content in `settingsScreen.body`. Setting the title also sets the
+window title to the same string, if it has not already been set.
+
+.Signature
+[source,javascript]
+----
+settingsScreen.setPageTitle(titleText)
+----
+
+[[settingsScreen.setWindowTitle]]
+=== settingsScreen.setWindowTitle()
+Sets the text to be displayed in the browser's title bar when the
+screen is visible.  Plugins should always prefer this method over
+trying to set `window.title` directly.  The window title defaults to
+the title given to `setTitle`.
+
+.Signature
+[source,javascript]
+----
+settingsScreen.setWindowTitle(titleText)
+----
+
+[[settingsScreen_show]]
+=== settingsScreen.show()
+Destroy the currently visible screen and display the plugin's screen.
+This method must be called after adding content to
+`settingsScreen.body`.
+
+[[PanelContext]]
+== Panel Context
+A new panel context is passed to the `panel` callback function each
+time a screen with the given extension point is loaded.
+
+[[panel_body]]
+=== panel.body
+Empty HTML `<div>` node the plugin should add the panel content to.
+The node is already attached to the document.
+
+[[PanelProperties]]
+=== Properties
+
+The extension panel parameters that are described in the
+link:dev-plugins.html#panels[plugin development documentation] are
+contained in the context as properties. Which properties are available
+depends on the extension point.
+
 [[Gerrit]]
 == Gerrit
 
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 32fa472..ef40aee 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -116,6 +116,8 @@
 
   TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set.
 
+  MERGE_FIRST_PARENT_UPDATE;; Conflict-free change of first (left) parent of a merge commit.
+
   NO_CODE_CHANGE;; No code changed; same tree and same parent tree.
 
   NO_CHANGE;; No changes; same commit message, same tree and same parent tree.
@@ -140,6 +142,8 @@
 
 value:: Value assigned by the approval, usually a numerical score.
 
+oldValue:: The previous approval score, only present if the value changed as a result of this event.
+
 grantedOn:: Time in seconds since the UNIX epoch when this approval
 was added or last updated.
 
diff --git a/Documentation/license.defs b/Documentation/license.defs
new file mode 100644
index 0000000..42dd3eb
--- /dev/null
+++ b/Documentation/license.defs
@@ -0,0 +1,29 @@
+def genlicenses(
+    name,
+    out,
+    opts = [],
+    java_deps = [],
+    non_java_deps = [],
+    visibility = []):
+  cmd = ['$(exe :gen_licenses)']
+  cmd.extend(opts)
+  cmd.append('>$OUT')
+  cmd.extend(java_deps)
+  cmd.extend(non_java_deps)
+
+  # Must use $(classpath) for Java deps, since transitive dependencies are not
+  # first-order dependencies of the output jar, so changes would not cause
+  # invalidation of the build cache key for the genrule.
+  cmd.extend('; true $(classpath %s)' % d for d in java_deps)
+
+  # Must use $(location) for non-Java deps, since $(classpath) will fail with an
+  # error. This is ok, because transitive dependencies are included in the
+  # output artifacts for everything _except_ Java libraries.
+  cmd.extend('; true $(location %s)' % d for d in non_java_deps)
+
+  genrule(
+    name = name,
+    out = out,
+    cmd = ' '.join(cmd),
+    visibility = visibility,
+  )
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
new file mode 100644
index 0000000..1270971
--- /dev/null
+++ b/Documentation/metrics.txt
@@ -0,0 +1,110 @@
+= Gerrit Code Review - Metrics
+
+Metrics about Gerrit's internal state can be sent to external monitoring systems
+via plugins. See the link:dev-plugins.html#metrics[plugin documentation] for
+details of plugin implementations.
+
+== Metrics
+
+The following metrics are reported.
+
+=== General
+
+* `build/label`: Version of Gerrit server software.
+* `events`: Triggered events.
+
+=== Process
+
+* `proc/birth_timestamp`: Time at which the Gerrit process started.
+* `proc/uptime`: Uptime of the Gerrit process.
+* `proc/cpu/usage`: CPU time used by the Gerrit process.
+* `proc/num_open_fds`: Number of open file descriptors.
+* `proc/jvm/memory/heap_committed`: Amount of memory guaranteed for user objects.
+* `proc/jvm/memory/heap_used`: Amount of memory holding user objects.
+* `proc/jvm/memory/non_heap_committed`: Amount of memory guaranteed for classes,
+etc.
+* `proc/jvm/memory/non_heap_used`: Amount of memory holding classes, etc.
+* `proc/jvm/memory/object_pending_finalization_count`: Approximate number of
+objects needing finalization.
+* `proc/jvm/gc/count`: Number of GCs.
+* `proc/jvm/gc/time`: Approximate accumulated GC elapsed time.
+* `proc/jvm/thread/num_live`: Current live thread count.
+
+=== Caches
+
+* `caches/memory_cached`: Memory entries.
+* `caches/memory_hit_ratio`: Memory hit ratio.
+* `caches/memory_eviction_count`: Memory eviction count.
+* `caches/disk_cached`: Disk entries used by persistent cache.
+* `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
+
+=== HTTP
+
+* `http/server/error_count`: Rate of REST API error responses.
+* `http/server/success_count`: Rate of REST API success responses.
+* `http/server/rest_api/count`: Rate of REST API calls by view.
+* `http/server/rest_api/error_count`: Rate of REST API calls by view.
+* `http/server/rest_api/server_latency`: REST API call latency by view.
+* `http/server/rest_api/response_bytes`: Size of REST API response on network
+(may be gzip compressed) by view.
+
+=== Query
+
+* `query/query_latency`: Successful query latency, accumulated over the life
+of the process.
+
+=== SSH sessions
+
+* `sshd/sessions/connected`: Number of currently connected SSH sessions.
+* `sshd/sessions/created`: Rate of new SSH sessions.
+* `sshd/sessions/authentication_failures`: Rate of SSH authentication failures.
+
+=== SQL connections
+
+* `sql/connection_pool/connections`: SQL database connections.
+
+=== JGit
+
+* `jgit/block_cache/cache_used`: Bytes of memory retained in JGit block cache.
+* `jgit/block_cache/open_files`: File handles held open by JGit block cache.
+
+=== Git
+
+* `git/upload-pack/request_count`: Total number of git-upload-pack requests.
+* `git/upload-pack/phase_counting`: Time spent in the 'Counting...' phase.
+* `git/upload-pack/phase_compressing`: Time spent in the 'Compressing...' phase.
+* `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
+* `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
+
+=== NoteDb
+
+* `notedb/update_latency`: NoteDb update latency by table.
+* `notedb/stage_update_latency`: Latency for staging updates to NoteDb by table.
+* `notedb/read_latency`: NoteDb read latency by table.
+* `notedb/parse_latency`: NoteDb parse latency by table.
+* `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
+* `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
+failed by table.
+
+=== Reviewer Suggestion
+
+* `reviewer_suggestion/query_accounts`: Latency for querying accounts for
+reviewer suggestion.
+* `reviewer_suggestion/query_groups`: Latency for querying groups for reviewer
+suggestion.
+
+=== Replication Plugin
+
+* `plugins/replication/replication_latency`: Time spent pushing to remote
+destination.
+* `plugins/replication/replication_delay`: Time spent waiting before pushing to
+remote destination.
+* `plugins/replication/replication_retries`: Number of retries when pushing to
+remote destination.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 09651c2..1136ced 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -5,8 +5,11 @@
 account to lower case
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'LocalUsernamesToLowerCase' -d <SITE_PATH>
+_java_ -jar gerrit.war _LocalUsernamesToLowerCase
+  -d <SITE_PATH>
+  [--threads]
 --
 
 == DESCRIPTION
@@ -48,9 +51,9 @@
 == EXAMPLES
 To convert the local username of every account to lower case:
 
-====
+----
 	$ java -jar gerrit.war LocalUsernamesToLowerCase -d site_path
-====
+----
 
 == SEE ALSO
 
diff --git a/Documentation/pgm-MigrateAccountPatchReviewDb.txt b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
new file mode 100644
index 0000000..5718a8a
--- /dev/null
+++ b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
@@ -0,0 +1,66 @@
+= MigrateAccountPatchReviewDb
+
+== NAME
+MigrateAccountPatchReviewDb - Migrates AccountPatchReviewDb from one database
+backend to another.
+
+== SYNOPSIS
+[verse]
+--
+_java_ -jar gerrit.war MigrateAccountPatchReviewDb
+  -d <SITE_PATH>
+  [--sourceUrl] [--chunkSize]
+--
+
+== DESCRIPTION
+Migrates AccountPatchReviewDb from one database backend to another. The
+AccountPatchReviewDb is a database used to store the user file reviewed flags.
+
+This command is only intended to be run if the configuration parameter
+link:config-gerrit.html#accountPatchReviewDb.url[accountPatchReviewDb.url]
+is set or changed.
+
+To migrate AccountPatchReviewDb:
+
+* Stop Gerrit
+* Configure new value for link:config-gerrit.html#accountPatchReviewDb.url[accountPatchReviewDb.url]
+* Migrate data using this command
+* Start Gerrit
+
+== OPTIONS
+
+-d::
+--site-path::
+	Location of the `gerrit.config` file, and all other per-site
+	configuration data, supporting libraries and log files.
+
+--sourceUrl::
+	Url of source database. Only need to be specified if the source is not H2.
+
+--chunkSize::
+	Chunk size of fetching from source and pushing to target on each time.
+	Defaults to 100000.
+
+== CONTEXT
+This command can only be run on a server which has direct
+connectivity to the database.
+
+== EXAMPLES
+To migrate from H2 to the database specified by
+link:config-gerrit.html#accountPatchReviewDb.url[accountPatchReviewDb.url]
+in gerrit.config:
+
+----
+	$ java -jar gerrit.war MigrateAccountPatchReviewDb -d site_path
+----
+
+== SEE ALSO
+
+* Configuration parameter link:config-gerrit.html#accountPatchReviewDb.url[accountPatchReviewDb.url]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-SwitchSecureStore.txt b/Documentation/pgm-SwitchSecureStore.txt
index f9b2aa4..47de1be 100644
--- a/Documentation/pgm-SwitchSecureStore.txt
+++ b/Documentation/pgm-SwitchSecureStore.txt
@@ -4,8 +4,10 @@
 SwitchSecureStore - Changes the currently used SecureStore implementation
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'SwitchSecureStore' [<OPTIONS>]
+_java_ -jar gerrit.war _SwitchSecureStore_
+  [--new-secure-store-lib]
 --
 
 == DESCRIPTION
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index bcf2b1b..76a26e1 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -4,16 +4,17 @@
 daemon - Gerrit network server
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'daemon'
-	-d <SITE_PATH>
-	[--enable-httpd | --disable-httpd]
-	[--enable-sshd | --disable-sshd]
-	[--console-log]
-	[--slave]
-	[--headless]
-	[--init]
-	[-s]
+_java_ -jar gerrit.war _daemon_
+  -d <SITE_PATH>
+  [--enable-httpd | --disable-httpd]
+  [--enable-sshd | --disable-sshd]
+  [--console-log]
+  [--slave]
+  [--headless]
+  [--init]
+  [-s]
 --
 
 == DESCRIPTION
diff --git a/Documentation/pgm-gsql.txt b/Documentation/pgm-gsql.txt
index ba40b26..4986522 100644
--- a/Documentation/pgm-gsql.txt
+++ b/Documentation/pgm-gsql.txt
@@ -4,8 +4,10 @@
 gsql - Administrative interface to idle database
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'gsql' -d <SITE_PATH>
+_java_ -jar gerrit.war _gsql_
+  -d <SITE_PATH>
 --
 
 == DESCRIPTION
@@ -32,7 +34,7 @@
 == EXAMPLES
 To manually correct a user's SSH user name:
 
-====
+----
 	$ java -jar gerrit.war gsql
 	Welcome to Gerrit Code Review v2.0.25
 	(PostgreSQL 8.3.8)
@@ -43,7 +45,7 @@
 	UPDATE 1; 1 ms
 	gerrit> \q
 	Bye
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/pgm-index.txt b/Documentation/pgm-index.txt
index bf6dc57..d61cc0b 100644
--- a/Documentation/pgm-index.txt
+++ b/Documentation/pgm-index.txt
@@ -33,11 +33,17 @@
 version::
 	Display the release version of Gerrit Code Review.
 
+link:pgm-passwd.html[passwd]::
+	Set or reset password in secure.config.
+
 === Transition Utilities
 
 link:pgm-LocalUsernamesToLowerCase.html[LocalUsernamesToLowerCase]::
 	Convert the local username of every account to lower case.
 
+link:pgm-MigrateAccountPatchReviewDb.html[MigrateAccountPatchReviewDb]::
+	Migrates AccountPatchReviewDb from one database backend to another.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/pgm-init.txt b/Documentation/pgm-init.txt
index 6aa3a74..9a16cdf 100644
--- a/Documentation/pgm-init.txt
+++ b/Documentation/pgm-init.txt
@@ -1,17 +1,25 @@
 = init
 
 == NAME
-init - Initialize a new Gerrit server installation
+init - Initialize a new Gerrit server installation or upgrade an existing
+installation.
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'init'
-	-d <SITE_PATH>
-	[--batch]
-	[--no-auto-start]
-	[--list-plugins]
-	[--install-plugin=<PLUGIN_NAME>]
-        [--dev]
+_java_ -jar gerrit.war _init_
+  -d <SITE_PATH>
+  [--batch]
+  [--delete-caches]
+  [--no-auto-start]
+  [--skip-plugins]
+  [--list-plugins]
+  [--install-plugin=<PLUGIN_NAME>]
+  [--install-all-plugins]
+  [--secure-store-lib]
+  [--dev]
+  [--skip-all-downloads]
+  [--skip-download=<LIBRARY_NAME>]
 --
 
 == DESCRIPTION
@@ -19,19 +27,25 @@
 for some basic setup prior to writing default configuration files
 into a newly created `$site_path`.
 
-If run in an existing `$site_path`, init will upgrade some resources
-as necessary.
+If run in an existing `$site_path`, init upgrades existing resources
+(e.g. DB schema, plugins) as necessary.
 
 == OPTIONS
+-b::
 --batch::
-	Run in batch mode, skipping interactive prompts.  Reasonable
-	configuration defaults are chosen based on the whims of
-	the Gerrit developers.
+	Run in batch mode, skipping interactive prompts. For a fresh
+	install, reasonable configuration defaults are chosen based
+	on the whims of the Gerrit developers. On upgrades, the existing
+	settings in `gerrit.config` are respected.
 +
 If during a schema migration unused objects (e.g. tables, columns)
-are detected they are *not* automatically dropped, but only a list of
-SQL statements to drop these objects is provided. To drop the unused
-objects these SQL statements have to be executed manually.
+are detected, they are *not* automatically dropped; a list of SQL
+statements to drop these objects is provided. To drop the unused
+objects these SQL statements must be executed manually.
+
+--delete-caches::
+	Force deletion of all persistent cache files. Note that
+	re-creation of these caches may be expensive.
 
 --no-auto-start::
 	Don't automatically start the daemon after initializing a
@@ -41,21 +55,50 @@
 
 -d::
 --site-path::
-	Location of the gerrit.config file, and all other per-site
+	Location of the `gerrit.config` file, and all other per-site
 	configuration data, supporting libraries and log files.
 
+--skip-plugins::
+	Entirely skip installation and initialization of plugins. This option
+	is needed when initializing a gerrit site without an archive. That
+	happens when running gerrit acceptance or integration tests in a
+	debugger, using classes. Supplying this option leads to ignoring the
+	`--install-plugin` and `--install-all-plugins` options, if supplied as well.
+
 --list-plugins::
 	Print names of plugins that can be installed during init process.
 
+--install-all-plugins::
+	Automatically install all plugins from gerrit.war without asking.
+	This option also works in batch mode. This option cannot be supplied
+	alongside `--install-plugin`.
+
+--secure-store-lib::
+	Path to the jar providing the chosen
+	link:dev-plugins.html#secure-store[SecureStore] implementation class.
+	This option is used in the same way as the `--new-secure-store-lib` option
+	documented in link:pgm-SwitchSecureStore.html[SwitchSecureStore].
+
 --install-plugin::
 	Automatically install plugin with given name without asking.
-	This option may be supplied more than once to install multiple
-	plugins.
+	This option also works in batch mode. This option may be supplied
+	more than once to install multiple plugins. This option cannot be
+	supplied alongside `--install-all-plugins`.
 
 --dev::
 	Install in developer mode. Default configuration settings are
 	chosen to run the Gerrit server as a developer.
 
+--skip-all-downloads::
+	Do not automatically download and install required libraries. The
+	administrator must manually install the required libraries in the `lib/`
+	folder.
+
+--skip-download::
+	Do not automatically download and install the library with the given name.
+	The administrator must manually install the required library in the `lib/`
+	folder.
+
 == CONTEXT
 This command can only be run on a server which has direct
 connectivity to the metadata database, and local access to the
diff --git a/Documentation/pgm-passwd.txt b/Documentation/pgm-passwd.txt
new file mode 100644
index 0000000..133fb03
--- /dev/null
+++ b/Documentation/pgm-passwd.txt
@@ -0,0 +1,49 @@
+= passwd
+
+== NAME
+passwd - Set or reset password in secure.config.
+
+== SYNOPSIS
+[verse]
+--
+_java_ -jar gerrit.war _passwd_
+  -d <SITE_PATH>
+  <SECTION.KEY>
+  [PASSWORD]
+
+--
+
+== DESCRIPTION
+Set or reset password in an existing Gerrit server installation,
+interactively prompting for a new password or using the one
+provided in the command line argument.
+
+== OPTIONS
+
+-d::
+--site-path::
+	Location of the `gerrit.config` file, and all other per-site
+	configuration data, supporting libraries and log files.
+
+== ARGUMENTS
+
+SECTION.KEY::
+	Section and key in the `secure.config` file for setting or editing the
+	password value.
+
+PASSWORD::
+	New password to set in `secure.config` associated to the section and key.
+	When specified as argument, automatically implies batch mode and the command
+	would not ask anything interactively.
+
+== CONTEXT
+
+This utility is typically useful when a secure store is configured
+to encrypt password values and thus editing the file manually is not an option.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-prolog-shell.txt b/Documentation/pgm-prolog-shell.txt
index 9861310..aee5799 100644
--- a/Documentation/pgm-prolog-shell.txt
+++ b/Documentation/pgm-prolog-shell.txt
@@ -4,8 +4,10 @@
 prolog-shell - Simple interactive Prolog interpreter
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'prolog-shell' [-s FILE.pl ...]
+_java_ -jar gerrit.war _prolog-shell_
+  [-s FILE.pl ...]
 --
 
 == DESCRIPTION
@@ -22,7 +24,7 @@
 == EXAMPLES
 Define a simple predicate and test it:
 
-====
+----
 	$ cat >simple.pl
 	food(apple).
 	food(orange).
@@ -45,7 +47,7 @@
 
 	no
 	| ?-
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/pgm-reindex.txt b/Documentation/pgm-reindex.txt
index bf09e0c..e13d518 100644
--- a/Documentation/pgm-reindex.txt
+++ b/Documentation/pgm-reindex.txt
@@ -4,8 +4,14 @@
 reindex - Rebuild the secondary index
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'reindex' [<OPTIONS>]
+_java_ -jar gerrit.war _reindex_
+  [--threads]
+  [--changes-schema-version]
+  [--verbose]
+  [--list]
+  [--index]
 --
 
 == DESCRIPTION
@@ -15,15 +21,19 @@
 --threads::
 	Number of threads to use for indexing.
 
---schema-version::
+--changes-schema-version::
 	Schema version to reindex; default is most recent version.
 
---output::
-	Prefix for output; path for local disk index, or prefix for remote index.
-
 --verbose::
 	Output debug information for each change.
 
+--list::
+	List available index names.
+
+--index::
+	Reindex only index with given name. This option can be supplied
+	more than once to reindex multiple indices.
+
 == CONTEXT
 The secondary index must be enabled. See
 link:config-gerrit.html#index.type[index.type].
diff --git a/Documentation/pgm-rulec.txt b/Documentation/pgm-rulec.txt
index 3236c38..1b50812 100644
--- a/Documentation/pgm-rulec.txt
+++ b/Documentation/pgm-rulec.txt
@@ -4,8 +4,12 @@
 rulec - Compile project-specific Prolog rules to JARs
 
 == SYNOPSIS
+[verse]
 --
-'java' -jar gerrit.war 'rulec' -d <SITE_PATH> [--all | <PROJECT>...]
+_java_ -jar gerrit.war _rulec_
+  -d <SITE_PATH>
+  [--quiet]
+  [--all | <PROJECT>...]
 --
 
 == DESCRIPTION
@@ -39,9 +43,9 @@
 == EXAMPLES
 To compile a rule JAR file for test/project:
 
-====
+----
 	$ java -jar gerrit.war rulec -d site_path test/project
-====
+----
 
 GERRIT
 ------
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 6d7c6d0..d71d19a 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -22,9 +22,9 @@
 
 . Create a Git repository under `gerrit.basePath`:
 +
-====
+----
   git --git-dir=$base_path/new/project.git init
-====
+----
 +
 [TIP]
 By tradition the repository directory name should have a `.git`
@@ -33,17 +33,17 @@
 To also make this repository available over the anonymous git://
 protocol, don't forget to create a `git-daemon-export-ok` file:
 +
-====
+----
   touch $base_path/new/project.git/git-daemon-export-ok
-====
+----
 
 . Register Project
 +
 Either restart the server, or flush the `project_list` cache:
 +
-====
+----
   ssh -p 29418 localhost gerrit flush-caches --cache project_list
-====
+----
 
 [[project_options]]
 == Project Options
@@ -53,7 +53,9 @@
 
 The method Gerrit uses to submit a change to a project can be
 modified by any project owner through the project console, `Projects` >
-`List` > my/project.  The following methods are supported:
+`List` > my/project. In general, a submitted change is only merged if all
+its dependencies are also submitted, with exceptions documented below.
+The following submit types are supported:
 
 [[fast_forward_only]]
 * Fast Forward Only
@@ -96,10 +98,12 @@
 is also set to the submitter, while the author header retains the
 original patch set author.
 +
-Note that Gerrit ignores patch set dependencies when operating in
-cherry-pick mode. Submitters must remember to submit changes in
-the right order since inter-change dependencies will not be
-enforced for them.
+Note that Gerrit ignores dependencies between changes when using this
+submit type unless
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled and depending changes share the same topic. So generally
+submitters must remember to submit changes in the right order when using this
+submit type.
 
 [[rebase_if_necessary]]
 * Rebase If Necessary
@@ -258,9 +262,9 @@
   REST endpoint
 - by using a git client to force push nothing to an existing branch
 +
-====
+----
   $ git push --force origin :refs/heads/<branch-to-delete>
-====
+----
 
 To be able to delete branches, the user must have the
 link:access-control.html#category_push[Push] access right with the
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index cd97223..cbd4070 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -4,7 +4,8 @@
 the Prolog engine with a set of facts (current data) about this change.
 The following table provides an overview of the provided facts.
 
-IMPORTANT: All the terms listed below are defined in the `gerrit` package. To use any
+[IMPORTANT]
+All the terms listed below are defined in the `gerrit` package. To use any
 of them we must use a qualified name like `gerrit:change_branch(X)`.
 
 .Prolog facts about the current change
@@ -56,6 +57,9 @@
                       |`current_user(user(anonymous)).`
                       |`current_user(user(peer_daemon)).`
                       |`current_user(user(replication)).`
+
+|`uploader/1`     |`uploader(user(1000000)).`
+    |Uploader as `user(ID)` term. ID is the numeric account ID
 |=============================================================================
 
 In addition Gerrit provides a set of built-in helper predicates that can be used
@@ -94,7 +98,8 @@
 
 |=============================================================================
 
-NOTE: for a complete list of built-in helpers read the `gerrit_common.pl` and
+[NOTE]
+For a complete list of built-in helpers read the `gerrit_common.pl` and
 all Java classes whose name matches `PRED_*.java` from Gerrit's source code.
 
 GERRIT
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index b53da4b..cced53e2 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -17,8 +17,9 @@
 submittable. For a change that is not submittable, the set of needed criteria
 is displayed in the Gerrit UI.
 
-NOTE: Loading and executing Prolog submit rules may be disabled by setting
-`rules.enabled=false` in the Gerrit config file (see
+[NOTE]
+Loading and executing Prolog submit rules may be disabled by setting
+`rules.enable=false` in the Gerrit config file (see
 link:config-gerrit.html#_a_id_rules_a_section_rules[rules section])
 
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
@@ -48,6 +49,13 @@
 Prolog based submit type computes a submit type for each change. The computed
 submit type is shown on the change screen for each change.
 
+When submitting changes in a batch using "Submit including ancestors" or "Submit
+whole topic", submit type rules may not be used to mix submit types on a single
+branch, and trying to submit such a batch will fail. This avoids potentially
+confusing behavior and spurious submit failures. It is recommended to only use
+submit type rules to change submit types for an entire branch, which avoids this
+situation.
+
 == Prolog Language
 This document is not a complete Prolog tutorial.
 link:http://en.wikipedia.org/wiki/Prolog[This Wikipedia page on Prolog] is a
@@ -66,7 +74,8 @@
 link:pgm-prolog-shell.html[prolog-shell] program which opens an interactive
 Prolog interpreter shell.
 
-NOTE: The interactive shell is just a prolog shell, it does not load
+[NOTE]
+The interactive shell is just a prolog shell, it does not load
 a gerrit server environment and thus is not intended for
 xref:TestingSubmitRules[testing submit rules].
 
@@ -85,14 +94,14 @@
 checkout the `refs/meta/config` branch in order to create or edit the `rules.pl`
 file:
 
-====
+----
   $ git fetch origin refs/meta/config:config
   $ git checkout config
   ... edit or create the rules.pl file
   $ git add rules.pl
   $ git commit -m "My submit rules"
   $ git push origin HEAD:refs/meta/config
-====
+----
 
 [[HowToWriteSubmitRules]]
 == How to write submit rules
@@ -106,14 +115,14 @@
 `C` on top of the `rules.pl` file and then consults it. The set of facts about
 the change `C` will look like:
 
-====
+----
   :- package gerrit.                                                   <1>
 
   commit_author(user(1000000), 'John Doe', 'john.doe@example.com').    <2>
   commit_committer(user(1000000), 'John Doe', 'john.doe@example.com'). <3>
   commit_message('Add plugin support to Gerrit').                      <4>
   ...
-====
+----
 
 <1> Gerrit will provide its facts in a package named `gerrit`. This means we
 have to use qualified names when writing our code and referencing these facts.
@@ -132,31 +141,33 @@
 an expectation on the format and value of the result of the `submit_rule`
 predicate which is expected to be a `submit` term of the following format:
 
-====
+----
   submit(label(label-name, status) [, label(label-name, status)]*)
-====
+----
 
 where `label-name` is usually `'Code-Review'` or `'Verified'` but could also
 be any other string (see examples below). The `status` is one of:
 
-* `ok(user(ID))` or just `ok(_)` if user info is not important. This status is
-  used to tell that this label/category has been met.
+* `ok(user(ID))`. This status is used to tell that this label/category has been
+  met.
 * `need(_)` is used to tell that this label/category is needed for the change to
-   become submittable.
-* `reject(user(ID))` or just `reject(_)`. This status is used to tell that this
-   label/category is blocking submission of the change.
+  become submittable.
+* `reject(user(ID))`. This status is used to tell that this label/category is
+  blocking submission of the change.
 * `impossible(_)` is used when the logic knows that the change cannot be submitted
-   as-is. This is meant for cases where the logic requires members of a specific
-   group to apply a specific label on a change, but no users are in that group.
-   This is usually caused by misconfiguration of permissions.
+  as-is. This is meant for cases where the logic requires members of a specific
+  group to apply a specific label on a change, but no users are in that group.
+  This is usually caused by misconfiguration of permissions.
 * `may(_)` allows expression of approval categories that are optional, i.e.
   could either be set or unset without ever influencing whether the change
   could be submitted.
 
-NOTE: For a change to be submittable all `label` terms contained in the returned
+[NOTE]
+For a change to be submittable all `label` terms contained in the returned
 `submit` term must have either `ok` or `may` status.
 
-IMPORTANT: Gerrit will let the Prolog engine continue searching for solutions of
+[IMPORTANT]
+Gerrit will let the Prolog engine continue searching for solutions of
 the `submit_rule(X)` query until it finds the first one where all labels in the
 return result have either status `ok` or `may` or there are no more solutions.
 If a solution where all labels have status `ok` is found then all previously
@@ -166,11 +177,12 @@
 
 Here some examples of possible return values from the `submit_rule` predicate:
 
-====
-  submit(label('Code-Review', ok(_)))                               <1>
-  submit(label('Code-Review', ok(_)), label('Verified', reject(_))) <2>
+----
+  submit(label('Code-Review', ok(user(ID))))                        <1>
+  submit(label('Code-Review', ok(user(ID))),
+      label('Verified', reject(user(ID))))                          <2>
   submit(label('Author-is-John-Doe', need(_))                       <3>
-====
+----
 
 <1> label `'Code-Review'` is met. As there are no other labels in the
     return result, the change is submittable.
@@ -178,7 +190,7 @@
 <3> label `'Author-is-John-Doe'` is needed for the change to become submittable.
     Note that this tells nothing about how this criteria will be met. It is up
     to the implementer of the `submit_rule` to return
-    `label('Author-is-John-Doe', ok(_))` when this criteria is met.  Most
+    `label('Author-is-John-Doe', ok(user(ID)))` when this criteria is met. Most
     likely, it will have to match against `gerrit:commit_author` in order to
     check if this criteria is met. This will become clear through the examples
     below.
@@ -210,10 +222,9 @@
 of the `submit_rule`. Therefore, the `submit_filter` predicate has two
 parameters:
 
-====
+----
   submit_filter(In, Out) :- ...
-====
-
+----
 Gerrit will invoke `submit_filter` with the `In` parameter containing a `submit`
 structure produced by the `submit_rule` and will take the value of the `Out`
 parameter as the result.
@@ -223,7 +234,8 @@
 of the top-most `submit_filter` is the final result of the submit rule that
 is used to decide whether a change is submittable or not.
 
-IMPORTANT: `submit_filter` is a mechanism for Gerrit administrators to implement
+[IMPORTANT]
+`submit_filter` is a mechanism for Gerrit administrators to implement
 and enforce submit rules that would apply to all projects while `submit_rule` is
 a mechanism for project owners to implement project specific submit rules.
 However, project owners who own several projects could also make use of
@@ -233,7 +245,7 @@
 
 The following "drawing" illustrates the order of the invocation and the chaining
 of the results of the `submit_rule` and `submit_filter` predicates.
-====
+----
   All-Projects
   ^   submit_filter(B, S) :- ...  <4>
   |
@@ -248,7 +260,7 @@
   |
   MyProject
       submit_rule(X) :- ...       <1>
-====
+----
 
 <1> The `submit_rule` of `MyProject` is invoked first.
 <2> The result `X` is filtered through the `submit_filter` from the `Parent-1`
@@ -260,7 +272,8 @@
 `submit_filter` in the `All-Projects` project. The value in `S` is the final
 value of the submit rule evaluation.
 
-NOTE: If `MyProject` doesn't define its own `submit_rule` Gerrit will invoke the
+[NOTE]
+If `MyProject` doesn't define its own `submit_rule` Gerrit will invoke the
 default implementation of submit rule that is named `gerrit:default_submit` and
 its result will be filtered as described above.
 
@@ -282,9 +295,9 @@
 Submit type filter works the same way as the xref:SubmitFilter[Submit Filter]
 where the name of the filter predicate is `submit_type_filter`.
 
-====
+----
   submit_type_filter(In, Out).
-====
+----
 
 Gerrit will invoke `submit_type_filter` with the `In` parameter containing a
 result of the `submit_type` and will take the value of the `Out` parameter as
@@ -299,9 +312,9 @@
 and executes the `submit_rule`. It optionally reads the rule from from `stdin`
 to facilitate easy testing.
 
-====
+----
   $ cat rules.pl | ssh gerrit_srv gerrit test-submit rule I45e080b105a50a625cc8e1fb5b357c0bfabe6d68 -s
-====
+----
 
 == Prolog vs Gerrit plugin for project specific submit rules
 Since version 2.5 Gerrit supports plugins and extension points. A plugin or an
@@ -339,7 +352,7 @@
 [source,prolog]
 ----
 submit_rule(submit(W)) :-
-    W = label('Any-Label-Name', ok(_)).
+    W = label('Any-Label-Name', ok(user(1000000))).
 ----
 
 In this case we make no use of facts about the change. We don't need it as we
@@ -348,6 +361,14 @@
 `'Verified'` categories as labels with these names are not part of the return
 result. The `'Any-Label-Name'` could really be any string.
 
+The `user(1000000)` represents the user whose account ID is `1000000`.
+
+[NOTE]
+Instead of the account ID `1000000` we could have used any other account ID.
+The following examples will use `user(ID)` instead of `user(1000000)` because
+it is easier to read and doesn't suggest that there is anything special with
+the account ID `1000000`.
+
 === Example 2: Every change submittable and voting in the standard categories possible
 This is continuation of the previous example where, in addition, to making
 every change submittable we want to enable voting in the standard
@@ -357,8 +378,8 @@
 [source,prolog]
 ----
 submit_rule(submit(CR, V)) :-
-    CR = label('Code-Review', ok(_)),
-    V = label('Verified', ok(_)).
+    CR = label('Code-Review', ok(user(ID))),
+    V = label('Verified', ok(user(ID))).
 ----
 
 Since for every change all label statuses are `'ok'` every change will be
@@ -373,7 +394,7 @@
 [source,prolog]
 ----
 submit_rule(submit(R)) :-
-    R = label('Any-Label-Name', reject(_)).
+    R = label('Any-Label-Name', reject(user(ID))).
 ----
 
 Since for any change we return only one label with status `reject`, no change
@@ -427,7 +448,7 @@
     N = label('Some-Condition', need(_)).
 
 submit_rule(submit(OK)) :-
-    OK = label('Another-Condition', ok(_)).
+    OK = label('Another-Condition', ok(user(ID))).
 ----
 
 The `'Need Some-Condition'` will not be shown in the UI because of the result of
@@ -439,7 +460,7 @@
 [source,prolog]
 ----
 submit_rule(submit(OK)) :-
-    OK = label('Another-Condition', ok(_)).
+    OK = label('Another-Condition', ok(user(ID))).
 
 submit_rule(submit(N)) :-
     N = label('Some-Condition', need(_)).
@@ -475,8 +496,8 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(_, 'John Doe', _),
-    Author = label('Author-is-John-Doe', ok(_)).
+    gerrit:commit_author(A, 'John Doe', _),
+    Author = label('Author-is-John-Doe', ok(A)).
 ----
 
 In the second rule we return `ok` status for the `'Author-is-John-Doe'` label
@@ -496,8 +517,8 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(_, _, 'john.doe@example.com'),
-    Author = label('Author-is-John-Doe', ok(_)).
+    gerrit:commit_author(A, _, 'john.doe@example.com'),
+    Author = label('Author-is-John-Doe', ok(A)).
 ----
 
 or by user id (assuming it is `1000000`):
@@ -509,8 +530,9 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(user(1000000), _, _),
-    Author = label('Author-is-John-Doe', ok(_)).
+    U = user(1000000),
+    gerrit:commit_author(U, _, _),
+    Author = label('Author-is-John-Doe', ok(U)).
 ----
 
 or by a combination of these 3 attributes:
@@ -522,8 +544,8 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
-    gerrit:commit_author(_, 'John Doe', 'john.doe@example.com'),
-    Author = label('Author-is-John-Doe', ok(_)).
+    gerrit:commit_author(A, 'John Doe', 'john.doe@example.com'),
+    Author = label('Author-is-John-Doe', ok(A)).
 ----
 
 === Example 7: Make change submittable if commit message starts with "Fix "
@@ -549,13 +571,15 @@
 
 submit_rule(submit(Fix)) :-
     gerrit:commit_message(M), name(M, L), starts_with(L, "Fix "),
-    Fix = label('Commit-Message-starts-with-Fix', ok(_)).
+    gerrit:commit_author(A),
+    Fix = label('Commit-Message-starts-with-Fix', ok(A)).
 
 starts_with(L, []).
 starts_with([H|T1], [H|T2]) :- starts_with(T1, T2).
 ----
 
-NOTE: The `name/2` embedded predicate is used to convert a string symbol into a
+[NOTE]
+The `name/2` embedded predicate is used to convert a string symbol into a
 list of characters. A string `abc` is converted into a list of characters `[97,
 98, 99]`.  A double quoted string in Prolog is just a shortcut for creating a
 list of characters. `"abc"` is a shortcut for `[97, 98, 99]`. This is why we use
@@ -573,7 +597,8 @@
 
 submit_rule(submit(Fix)) :-
     gerrit:commit_message_matches('^Fix '),
-    Fix = label('Commit-Message-starts-with-Fix', ok(_)).
+    gerrit:commit_author(A),
+    Fix = label('Commit-Message-starts-with-Fix', ok(A)).
 ----
 
 The previous example could also be written so that it first checks if the commit
@@ -585,7 +610,8 @@
 ----
 submit_rule(submit(Fix)) :-
     gerrit:commit_message_matches('^Fix '),
-    Fix = label('Commit-Message-starts-with-Fix', ok(_)),
+    gerrit:commit_author(A),
+    Fix = label('Commit-Message-starts-with-Fix', ok(A)),
     !.
 
 % Message does not start with 'Fix ' so Fix is needed to submit
@@ -682,8 +708,8 @@
 
 This example uses the `univ` operator `=..` to "unpack" the result of the
 default_submit, which is a structure of the form `submit(label('Code-Review',
-ok(_)), label('Verified', need(_)), ...)` into a list like `[submit,
-label('Code-Review', ok(_)), label('Verified', need(_)), ...]`.  Then we
+ok(user(ID))), label('Verified', need(_)), ...)` into a list like `[submit,
+label('Code-Review', ok(user(ID))), label('Verified', need(_)), ...]`.  Then we
 process the tail of the list (the list of labels) as a Prolog list, which is
 much easier than processing a structure. In the end we use the same `univ`
 operator to convert the resulting list of labels back into a `submit` structure
@@ -734,7 +760,7 @@
 Which of these two behaviors is desired will always depend on how a particular
 Gerrit server is managed.
 
-==== Example 9: Remove the `Verified` category
+=== Example 9: Remove the `Verified` category
 A project has no build and test. It consists of only text files and needs only
 code review.  We want to remove the `Verified` category from this project so
 that `Code-Review+2` is the only criteria for a change to become submittable.
@@ -802,7 +828,7 @@
     N = label('Non-Author-Code-Review', need(_)).
 
 base(CR) :-
-    gerrit:max_with_block(-2, 2, 'Code-Review', CR),
+    gerrit:max_with_block(-2, 2, 'Code-Review', CR).
 ----
 
 === Example 11: Remove the `Verified` category from all projects
@@ -999,37 +1025,13 @@
 submit_type(fast_forward_only) :-
     gerrit:change_branch(B), regex_matches('refs/heads/stable.*', B),
     !.
-submit_type(T) :- gerrit:project_default_submit_type(T)
+submit_type(T) :- gerrit:project_default_submit_type(T).
 ----
 
 The first `submit_type` predicate defines the `Fast Forward Only` submit type
 for `+refs/heads/stable.*+` branches. The second `submit_type` predicate returns
 the project's default submit type.
 
-=== Example 3: Don't require `Fast Forward Only` if only documentation was changed
-Like in the previous example we want the `Fast Forward Only` submit type for the
-`+refs/heads/stable*+` branches.  However, if only documentation was changed
-(only `+*.txt+` files), then we allow project's default submit type for such
-changes.
-
-`rules.pl`
-[source,prolog]
-----
-submit_type(fast_forward_only) :-
-    gerrit:commit_delta('(?<!\.txt)$'),
-    gerrit:change_branch(B), regex_matches('refs/heads/stable.*', B),
-    !.
-submit_type(T) :- gerrit:project_default_submit_type(T)
-----
-
-The `gerrit:commit_delta('(?<!\.txt)$')` succeeds if the change contains a file
-whose name doesn't end with `.txt` The rest of this rule is same like in the
-previous example.
-
-If all file names in the change end with `.txt`, then the
-`gerrit:commit_delta('(?<!\.txt)$')` will fail as no file name will match this
-regular expression.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index fec4a58..baf08e7 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -1,4 +1,5 @@
 #!/usr/bin/env python
+# coding=utf-8
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -60,9 +61,32 @@
 SEARCH_BOX = """
 
 ++++
-<div style="position:absolute; right:20px; top:20px;">
-<input type="text" id="docSearch" size="70" />
-<button type="button" id="searchBox">Search</button>
+<div style="
+  position:fixed;
+  top:0px;
+  right:0px;
+  text-align:
+  right;
+  padding-top:2px;
+  padding-right:0.5em;
+  padding-bottom:2px;">
+<input size="40"
+  style="line-height: 0.75em;font-size: 0.75em;"
+  id="docSearch"
+  type="text">
+<button style="
+  background:none!important;
+  border:none;
+  padding:0!important;
+  vertical-align:bottom;
+  font-family:'Open Sans','DejaVu Sans',sans-serif;
+  font-size:0.8em;
+  color:#1d4b8f;
+  text-decoration:none;"
+  type="button"
+  id="searchBox">
+  Search
+</button>
 <script type="text/javascript">
 var f = function() {
   window.location = '../#/Documentation/' +
@@ -148,11 +172,14 @@
           a.setAttribute('href', '#' + id);
           a.setAttribute('style', 'position: absolute;'
               + ' left: ' + (element.offsetLeft - 16 - 2 * 4) + 'px;'
-              + ' padding-left: 4px; padding-right: 4px; padding-top:4px;');
-          var img = document.createElement('img');
-          img.setAttribute('src', 'images/link.png');
-          img.setAttribute('style', 'background-color: #FFFFFF;');
-          a.appendChild(img);
+              + ' padding-left: 4px; padding-right: 4px;');
+          var span = document.createElement('span');
+          span.setAttribute('style', 'height: ' + element.offsetHeight + 'px;'
+              + ' display: inline-block; vertical-align: baseline;'
+              + ' font-size: 16px; text-decoration: none; color: grey;');
+          a.appendChild(span);
+          var link = document.createTextNode('🔗');
+          span.appendChild(link);
           element.insertBefore(a, element.firstChild);
 
           // remove the link icon when the mouse is moved away,
@@ -160,14 +187,16 @@
           hide = function(evt) {
             if (document.elementFromPoint(evt.clientX, evt.clientY) != element
                 && document.elementFromPoint(evt.clientX, evt.clientY) != a
-                && document.elementFromPoint(evt.clientX, evt.clientY) != img
+                && document.elementFromPoint(evt.clientX, evt.clientY) != span
+                && document.elementFromPoint(evt.clientX, evt.clientY) != link
                 && element.contains(a)) {
               element.removeChild(a);
             }
           }
           element.onmouseout = hide;
           a.onmouseout = hide;
-          img.onmouseout = hide;
+          span.onmouseout = hide;
+          link.onmouseout = hide;
         }
       }
     }
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index ee3e8ce..61ea582 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -334,10 +334,10 @@
 |`force`        |not set if `false`|
 Whether the force flag is set.
 |`min`          |
-not set if range if empty (from `0` to `0`) or not set|
+not set if range is empty (from `0` to `0`) or not set|
 The min value of the permission range.
 |`max`          |
-not set if range if empty (from `0` to `0`) or not set|
+not set if range is empty (from `0` to `0`) or not set|
 The max value of the permission range.
 |==================================
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 5459306..e784c1c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -7,19 +7,76 @@
 [[account-endpoints]]
 == Account Endpoints
 
-[[suggest-account]]
-=== Suggest Account
+[[query-account]]
+=== Query Account
 --
 'GET /accounts/'
 --
 
-Suggest users for a given query `q` and result limit `n`. If result
-limit is not passed, then the default 10 is used. Returns a list of
-matching link:#account-info[AccountInfo] entities.
+Queries accounts visible to the caller. The
+link:user-search-accounts.html#_search_operators[query string] must be
+provided by the `q` parameter. The `n` parameter can be used to limit
+the returned results.
+
+As result a list of link:#account-info[AccountInfo] entities is
+returned.
 
 .Request
 ----
-  GET /accounts/?q=John HTTP/1.0
+  GET /accounts/?q=name:John+email:example.com&n=2 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "_account_id": 1000096,
+    },
+    {
+      "_account_id": 1001439,
+      "_more_accounts": true
+    }
+  ]
+----
+
+If the number of accounts matching the query exceeds either the
+internal limit or a supplied `n` query parameter, the last account
+object has a `_more_accounts: true` JSON field set.
+
+The `S` or `start` query parameter can be supplied to skip a number
+of accounts from the list.
+
+Additional fields can be obtained by adding `o` parameters, each
+option slows down the query response time to the client so they are
+generally disabled by default. Optional fields are:
+
+[[details]]
+--
+* `DETAILS`: Includes full name, preferred email, username and avatars
+for each account.
+--
+
+[[all-emails]]
+--
+* `ALL_EMAILS`: Includes all registered emails.
+--
+
+[[suggest-account]]
+To get account suggestions set the parameter `suggest` and provide the
+typed substring as query `q`. If a result limit `n` is not specified,
+then the default 10 is used.
+
+For account suggestions link:#details[account details] and
+link:#all-emails[all emails] are always returned.
+
+.Request
+----
+  GET /accounts/?suggest&q=John HTTP/1.0
 ----
 
 .Response
@@ -420,6 +477,43 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-oauth-token]]
+=== Get OAuth Access Token
+--
+'GET /accounts/link:#account-id[\{account-id\}]/oauthtoken'
+--
+
+Returns a previously obtained OAuth access token.
+
+.Request
+----
+  GET /accounts/self/oauthtoken HTTP/1.1
+----
+
+As a response, an link:#oauth-token-info[OAuthTokenInfo] entity is returned
+that describes the OAuth access token.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+   )]}'
+    {
+      "username": "johndow",
+      "resource_host": "gerrit.example.org",
+      "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOi",
+      "provider_id": "oauth-plugin:oauth-provider",
+      "expires_at": "922337203775807",
+      "type": "bearer"
+    }
+----
+
+If there is no token available, or the token has already expired,
+"`404 Not Found`" is returned as response. Requests to obtain an access
+token of another user are rejected with "`403 Forbidden`".
+
 [[list-account-emails]]
 === List Account Emails
 --
@@ -500,12 +594,16 @@
 configured, the added email address must belong to a domain that is
 allowed, unless `no_confirmation` is set.
 
-In the request body additional data for the email address can be
-provided as link:#email-input[EmailInput].
+The link:#email-input[EmailInput] object in the request body may
+contain additional options for the email address.
 
 .Request
 ----
   PUT /accounts/self/emails/john.doe@example.com HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+  Content-Length: 3
+
+  {}
 ----
 
 As response the new email address is returned as
@@ -641,6 +739,9 @@
 
 The SSH public key must be provided as raw content in the request body.
 
+Trying to add an SSH key that already exists succeeds, but no new SSH
+key is persisted.
+
 .Request
 ----
   POST /accounts/self/sshkeys HTTP/1.0
@@ -1086,6 +1187,14 @@
 As result the account preferences of the user are returned as a
 link:#preferences-info[PreferencesInfo] entity.
 
+Users may only retrieve the preferences for their own account,
+unless they are an
+link:access-control.html#administrators[Administrator] or a member
+of a group that is granted the
+link:access-control.html#capability_modifyAccount[ModifyAccount]
+capability, in which case they can retrieve the preferences for
+any account.
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -1097,11 +1206,13 @@
     "changes_per_page": 25,
     "show_site_header": true,
     "use_flash_clipboard": true,
+    "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
+    "diff_view": "SIDE_BY_SIDE",
     "size_bar_in_change_table": true,
     "review_category_strategy": "ABBREV",
-    "diff_view": "SIDE_BY_SIDE",
+    "mute_common_path_prefixes": true,
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1151,11 +1262,13 @@
     "changes_per_page": 50,
     "show_site_header": true,
     "use_flash_clipboard": true,
+    "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
+    "mute_common_path_prefixes": true,
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1199,11 +1312,13 @@
     "changes_per_page": 50,
     "show_site_header": true,
     "use_flash_clipboard": true,
+    "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
+    "mute_common_path_prefixes": true,
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1353,6 +1468,7 @@
     "key_map_type": "VIM",
     "tab_size": 4,
     "line_length": 80,
+    "indent_unit": 2,
     "cursor_blink_rate": 530,
     "hide_top_menu": true,
     "show_whitespace_errors": true,
@@ -1384,6 +1500,7 @@
     "key_map_type": "VIM",
     "tab_size": 4,
     "line_length": 80,
+    "indent_unit": 2,
     "cursor_blink_rate": 530,
     "hide_top_menu": true,
     "show_tabs": true,
@@ -1396,23 +1513,166 @@
   }
 ----
 
-The response is "`204 No Content`"
+As result the new edit preferences of the user are returned as a
+link:#edit-preferences-info[EditPreferencesInfo] entity.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "theme": "ECLIPSE",
+    "key_map_type": "VIM",
+    "tab_size": 4,
+    "line_length": 80,
+    "cursor_blink_rate": 530,
+    "hide_top_menu": true,
+    "show_whitespace_errors": true,
+    "hide_line_numbers": true,
+    "match_brackets": true,
+    "auto_close_brackets": true
+  }
+----
+
+[[get-watched-projects]]
+=== Get Watched Projects
+--
+'GET /accounts/link:#account-id[\{account-id\}]/watched.projects'
+--
+
+Retrieves all projects a user is watching.
+
+.Request
+----
+  GET /a/accounts/self/watched.projects HTTP/1.0
+----
+
+As result the watched projects of the user are returned as a list of
+link:#project-watch-info[ProjectWatchInfo] entities.
+The result is sorted by project name in ascending order.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "project": "Test Project 1",
+      "notify_new_changes": true,
+      "notify_new_patch_sets": true,
+      "notify_all_comments": true,
+    },
+    {
+      "project": "Test Project 2",
+      "filter": "branch:experimental",
+      "notify_all_comments": true,
+      "notify_submitted_changes": true,
+      "notify_abandoned_changes": true
+    }
+  ]
+----
+
+[[set-watched-projects]]
+=== Add/Update a List of Watched Project Entities
+--
+'POST /accounts/link:#account-id[\{account-id\}]/watched.projects'
+--
+
+Add new projects to watch or update existing watched projects.
+Projects that are already watched by a user will be updated with
+the provided configuration. All other projects in the request
+will be watched using the provided configuration. The posted body
+can contain link:#project-watch-info[ProjectWatchInfo] entities.
+Omitted boolean values will be set to false.
+
+.Request
+----
+  POST /a/accounts/self/watched.projects HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  [
+    {
+      "project": "Test Project 1",
+      "notify_new_changes": true,
+      "notify_new_patch_sets": true,
+      "notify_all_comments": true,
+    }
+  ]
+----
+
+As result the watched projects of the user are returned as a list of
+link:#project-watch-info[ProjectWatchInfo] entities.
+The result is sorted by project name in ascending order.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "project": "Test Project 1",
+      "notify_new_changes": true,
+      "notify_new_patch_sets": true,
+      "notify_all_comments": true,
+    },
+    {
+      "project": "Test Project 2",
+      "notify_new_changes": true,
+      "notify_new_patch_sets": true,
+      "notify_all_comments": true,
+    }
+  ]
+----
+
+[[delete-watched-projects]]
+=== Delete Watched Projects
+--
+'POST /accounts/link:#account-id[\{account-id\}]/watched.projects:delete'
+--
+
+Projects posted to this endpoint will no longer be watched. The posted body
+can contain a list of link:#project-watch-info[ProjectWatchInfo] entities.
+
+.Request
+----
+  POST /a/accounts/self/watched.projects:delete HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  [
+    {
+      "project": "Test Project 1",
+      "filter": "branch:master"
+    }
+  ]
+----
 
 .Response
 ----
   HTTP/1.1 204 No Content
 ----
 
-[[get-starred-changes]]
-=== Get Starred Changes
+[[default-star-endpoints]]
+== Default Star Endpoints
+
+[[get-changes-with-default-star]]
+=== Get Changes With Default Star
 --
 'GET /accounts/link:#account-id[\{account-id\}]/starred.changes'
 --
 
-Gets the changes starred by the identified user account. This
-URL endpoint is functionally identical to the changes query
-`GET /changes/?q=is:starred`. The result is a list of
-link:rest-api-changes.html#change-info[ChangeInfo] entities.
+Gets the changes that were starred with the default star by the
+identified user account. This URL endpoint is functionally identical
+to the changes query `GET /changes/?q=is:starred`. The result is a list
+of link:rest-api-changes.html#change-info[ChangeInfo] entities.
 
 .Request
 ----
@@ -1436,7 +1696,14 @@
       "status": "NEW",
       "created": "2013-02-01 09:59:32.126000000",
       "updated": "2013-02-21 11:16:36.775000000",
+      "starred": true,
+      "stars": [
+        "star"
+      ],
       "mergeable": true,
+      "submittable": false,
+      "insertions": 145,
+      "deletions": 12,
       "_number": 3965,
       "owner": {
         "name": "John Doe"
@@ -1446,14 +1713,15 @@
 ----
 
 [[star-change]]
-=== Star Change
+=== Put Default Star On Change
 --
 'PUT /accounts/link:#account-id[\{account-id\}]/starred.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
 --
 
-Star a change. Starred changes are returned for the search query
-`is:starred` or `starredby:USER` and automatically notify the user
-whenever updates are made to the change.
+Star a change with the default label. Changes starred with the default
+label are returned for the search query `is:starred` or `starredby:USER`
+and automatically notify the user whenever updates are made to the
+change.
 
 .Request
 ----
@@ -1466,12 +1734,12 @@
 ----
 
 [[unstar-change]]
-=== Unstar Change
+=== Remove Default Star From Change
 --
 'DELETE /accounts/link:#account-id[\{account-id\}]/starred.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
 --
 
-Unstar a change. Removes the starred flag, stopping notifications.
+Remove the default star label from a change. This stops notifications.
 
 .Request
 ----
@@ -1483,6 +1751,214 @@
   HTTP/1.1 204 No Content
 ----
 
+[[star-endpoints]]
+== Star Endpoints
+
+[[get-starred-changes]]
+=== Get Starred Changes
+--
+'GET /accounts/link:#account-id[\{account-id\}]/stars.changes'
+--
+
+Gets the changes that were starred with any label by the identified
+user account. This URL endpoint is functionally identical to the
+changes query `GET /changes/?q=has:stars`. The result is a list of
+link:rest-api-changes.html#change-info[ChangeInfo] entities.
+
+.Request
+----
+  GET /a/accounts/self/stars.changes
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-21 11:16:36.775000000",
+      "stars": [
+        "ignore",
+        "risky"
+      ],
+      "mergeable": true,
+      "submittable": false,
+      "insertions": 145,
+      "deletions": 12,
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    }
+  ]
+----
+
+[[get-stars]]
+=== Get Star Labels From Change
+--
+'GET /accounts/link:#account-id[\{account-id\}]/stars.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
+--
+
+Get star labels from a change.
+
+.Request
+----
+  GET /a/accounts/self/stars.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
+----
+
+As response the star labels that the user applied on the change are
+returned. The labels are lexicographically sorted.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "blue",
+    "green",
+    "red"
+  ]
+----
+
+[[set-stars]]
+=== Update Star Labels On Change
+--
+'POST /accounts/link:#account-id[\{account-id\}]/stars.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
+--
+
+Update star labels on a change. The star labels to be added/removed
+must be specified in the request body as link:#stars-input[StarsInput]
+entity. Starred changes are returned for the search query `has:stars`.
+
+.Request
+----
+  POST /a/accounts/self/stars.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add": [
+      "blue",
+      "red"
+    ],
+    "remove": [
+      "yellow"
+    ]
+  }
+----
+
+As response the star labels that the user applied on the change are
+returned. The labels are lexicographically sorted.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "blue",
+    "green",
+    "red"
+  ]
+----
+
+[[list-contributor-agreements]]
+=== List Contributor Agreements
+--
+'GET /accounts/link:#account-id[\{account-id\}]/agreements'
+--
+
+Gets a list of the user's signed contributor agreements.
+
+.Request
+----
+  GET /a/accounts/self/agreements HTTP/1.0
+----
+
+As response the user's signed agreements are returned as a list
+of link:#contributor-agreement-info[ContributorAgreementInfo] entities.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Individual",
+      "description": "If you are going to be contributing code on your own, this is the one you want. You can sign this one online.",
+      "url": "static/cla_individual.html"
+    }
+  ]
+----
+
+[[sign-contributor-agreement]]
+=== Sign Contributor Agreement
+--
+'PUT /accounts/link:#account-id[\{account-id\}]/agreements'
+--
+
+Signs a contributor agreement.
+
+The contributor agreement must be provided in the request body as
+a link:#contributor-agreement-input[ContributorAgreementInput].
+
+.Request
+----
+  PUT /accounts/self/agreements HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Individual"
+  }
+----
+
+As response the contributor agreement name is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Individual"
+----
+
+[[index-account]]
+=== Index Account
+--
+'POST /accounts/link:#account-id[\{account-id\}]/index'
+--
+
+Adds or updates the account in the secondary index.
+
+.Request
+----
+  POST /accounts/1000096/index HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[ids]]
 == IDs
 
@@ -1529,7 +2005,7 @@
 
 [[account-detail-info]]
 === AccountDetailInfo
-The `AccountDetailInfo` entity contains detailled information about an
+The `AccountDetailInfo` entity contains detailed information about an
 account.
 
 `AccountDetailInfo` has the same fields as link:#account-info[
@@ -1548,20 +2024,34 @@
 The `AccountInfo` entity contains information about an account.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`_account_id` ||The numeric ID of the account.
-|`name`        |optional|The full name of the user. +
-Only set if link:rest-api-changes.html#detailed-accounts[detailed
-account information] is requested.
-|`email`       |optional|
+|===============================
+|Field Name        ||Description
+|`_account_id`     ||The numeric ID of the account.
+|`name`            |optional|The full name of the user. +
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS] for change queries +
+and option link:#details[DETAILS] for account queries.
+|`email`           |optional|
 The email address the user prefers to be contacted through. +
-Only set if link:rest-api-changes.html#detailed-accounts[detailed
-account information] is requested.
-|`username`    |optional|The username of the user. +
-Only set if link:rest-api-changes.html#detailed-accounts[detailed
-account information] is requested.
-|===========================
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS] for change queries +
+and options link:#details[DETAILS] and link:#all-emails[
+ALL_EMAILS] for account queries.
+|`secondary_emails`|optional|
+A list of the secondary email addresses of the user. +
+Only set for account queries when the link:#all-emails[ALL_EMAILS]
+option is set.
+|`username`        |optional|The username of the user. +
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS] for change queries +
+and option link:#details[DETAILS] for account queries.
+|`_more_accounts`  |optional, not set if `false`|
+Whether the query would deliver more results if not limited. +
+Only set on the last account that is returned.
+|===============================
 
 [[account-input]]
 === AccountInput
@@ -1655,6 +2145,31 @@
 link:access-control.html#capability_viewQueue[View Queue] capability.
 |=================================
 
+[[contributor-agreement-info]]
+=== ContributorAgreementInfo
+
+The `ContributorAgreementInfo` entity contains information about a
+contributor agreement.
+
+[options="header",cols="1,6"]
+|=================================
+|Field Name                 |Description
+|`name`                     |The name of the agreement.
+|`description`              |The description of the agreement.
+|`url`                      |The URL of the agreement.
+|=================================
+
+[[contributor-agreement-input]]
+=== ContributorAgreementInput
+The `ContributorAgreementInput` entity contains information about a
+new contributor agreement.
+
+[options="header",cols="1,6"]
+|=================================
+|Field Name                 |Description
+|`name`                     |The name of the agreement.
+|=================================
+
 [[diff-preferences-info]]
 === DiffPreferencesInfo
 The `DiffPreferencesInfo` entity contains information about the diff
@@ -1666,8 +2181,8 @@
 |`context`                     ||
 The number of lines of context when viewing a patch.
 |`theme`                       ||
-The CodeMirror theme. Currently only a subset of light and dark
-CodeMirror themes are supported.
+The CodeMirror theme name in upper case, for example `DEFAULT`. All the themes
+from the CodeMirror release that Gerrit is using are available.
 |`expand_all_comments`         |not set if `false`|
 Whether all inline comments should be automatically expanded.
 |`ignore_whitespace`           ||
@@ -1784,16 +2299,17 @@
 |===========================================
 |Field Name                    ||Description
 |`theme`                       ||
-The CodeMirror theme. Currently only a subset of light and dark
-CodeMirror themes are supported. Light themes `DEFAULT`, `ECLIPSE`,
-`ELEGANT`, `NEAT`. Dark themes `MIDNIGHT`, `NIGHT`, `TWILIGHT`.
+The CodeMirror theme name in upper case, for example `DEFAULT`. All the themes
+from the CodeMirror release that Gerrit is using are available.
 |`key_map_type`                ||
 The CodeMirror key map. Currently only a subset of key maps are
-supported: `DEFAULT`, `EMACS`, `VIM`.
+supported: `DEFAULT`, `EMACS`, `SUBLIME`, `VIM`.
 |`tab_size`                    ||
 Number of spaces that should be used to display one tab.
 |`line_length`                 ||
 Number of characters that should be displayed per line.
+|`indent_unit`                 ||
+Number of spaces that should be used for auto-indent.
 |`cursor_blink_rate`           ||
 Half-period in milliseconds used for cursor blinking.
 Setting it to 0 disables cursor blinking.
@@ -1906,6 +2422,22 @@
 password is deleted.
 |============================
 
+[[oauth-token-info]]
+=== OAuthTokenInfo
+The `OAuthTokenInfo` entity contains information about an OAuth access token.
+
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name      ||Description
+|`username`      ||The owner of the OAuth access token.
+|`resource_host` ||The host of the Gerrit instance.
+|`access_token`  ||The actual token value.
+|`provider_id`   |optional|
+The identifier of the OAuth provider in the form `plugin-name:provider-name`.
+|`expires_at`    |optional|Time of expiration of this token in milliseconds.
+|`type`          ||The type of the OAuth access token, always `bearer`.
+|========================
+
 [[preferences-info]]
 === PreferencesInfo
 The `PreferencesInfo` entity contains information about a user's preferences.
@@ -1920,7 +2452,7 @@
 Whether the site header should be shown.
 |`use_flash_clipboard`          |not set if `false`|
 Whether to use the flash clipboard widget.
-|`download_scheme`              ||
+|`download_scheme`              |optional|
 The type of download URL the user prefers to use. May be any key from
 the `schemes` map in
 link:rest-api-config.html#download-info[DownloadInfo].
@@ -1942,6 +2474,9 @@
 Whether to show change number in the change table.
 |`mute_common_path_prefixes`    |not set if `false`|
 Whether to mute common path prefixes in file names in the file table.
+|`signed_off_by`                |not set if `false`|
+Whether to insert Signed-off-by footer in changes created with the
+inline edit feature.
 |`review_category_strategy`     ||
 The strategy used to displayed info in the review category column.
 Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
@@ -1954,6 +2489,12 @@
 |`url_aliases`                  |optional|
 A map of URL path pairs, where the first URL path is an alias for the
 second URL path.
+|`email_strategy`               ||
+The type of email strategy to use. On `ENABLED`, the user will receive emails
+from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
+their own comments. On `DISABLED` the user will not receive any email
+notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
 |============================================
 
 [[preferences-input]]
@@ -1991,6 +2532,9 @@
 Whether to show change number in the change table.
 |`mute_common_path_prefixes`    |optional|
 Whether to mute common path prefixes in file names in the file table.
+|`signed_off_by`                |optional|
+Whether to insert Signed-off-by footer in changes created with the
+inline edit feature.
 |`review_category_strategy`     |optional|
 The strategy used to displayed info in the review category column.
 Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
@@ -2003,6 +2547,12 @@
 |`url_aliases`                  |optional|
 A map of URL path pairs, where the first URL path is an alias for the
 second URL path.
+|`email_strategy`               |optional|
+The type of email strategy to use. On `ENABLED`, the user will receive emails
+from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
+their own comments. On `DISABLED` the user will not receive any email
+notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
 |============================================
 
 [[query-limit-info]]
@@ -2033,6 +2583,18 @@
 |`valid`         ||Whether the SSH key is valid.
 |=============================
 
+[[stars-input]]
+=== StarsInput
+The `StarsInput` entity contains star labels that should be added to
+or removed from a change.
+
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name ||Description
+|`add`      |optional|List of labels to add to the change.
+|`remove`   |optional|List of labels to remove from the change.
+|========================
+
 [[username-input]]
 === UsernameInput
 The `UsernameInput` entity contains information for setting the
@@ -2044,6 +2606,22 @@
 |`username` |The new username of the account.
 |=======================
 
+[[project-watch-info]]
+=== ProjectWatchInfo
+The `WatchedProjectsInfo` entity contains information about a project watch
+for a user.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name                 |        |Description
+|`project`                  |        |The name of the project.
+|`filter`                   |optional|A filter string to be applied to the project.
+|`notify_new_changes`       |optional|Notify on new changes.
+|`notify_new_patch_sets`    |optional|Notify on new patch sets.
+|`notify_all_comments`      |optional|Notify on comments.
+|`notify_submitted_changes` |optional|Notify on submitted changes.
+|`notify_abandoned_changes` |optional|Notify on abandoned changes.
+|=======================
 
 GERRIT
 ------
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index aa9417b..77feb18 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -13,10 +13,8 @@
 'POST /changes'
 --
 
-The change info link:#change-info[ChangeInfo] entity must be provided in the
-request body. Only the following attributes are honored: `project`,
-`branch`, `subject`, `status` and `topic`. The first three attributes are
-mandatory. Valid values for status are: `DRAFT` and `NEW`.
+The change input link:#change-input[ChangeInput] entity must be provided in the
+request body.
 
 .Request
 ----
@@ -130,8 +128,8 @@
   ]
 ----
 
-If the `n` query parameter is supplied and additional changes exist
-that match the query beyond the end, the last change object has a
+If the number of changes matching the query exceeds either the internal
+limit or a supplied `n` query parameter, the last change object has a
 `_more_changes: true` JSON field set.
 
 The `S` or `start` query parameter can be supplied to skip a number
@@ -194,6 +192,7 @@
 get::/changes/?q=is:open+owner:self&q=is:open+reviewer:self+-owner:self&q=is:closed+owner:self+limit:5&o=LABELS
 ****
 
+[[query-options]]
 Additional fields can be obtained by adding `o` parameters, each
 option requires more database lookups and slows down the query
 response time to the client so they are generally disabled by
@@ -209,8 +208,8 @@
 --
 * `DETAILED_LABELS`: detailed label information, including numeric
   values of all existing approvals, recognized label values, values
-  permitted to be set by the current user, and reviewers that may be
-  removed by the current user.
+  permitted to be set by the current user, all reviewers by state, and
+  reviewers that may be removed by the current user.
 --
 
 [[current-revision]]
@@ -266,6 +265,12 @@
   fields when referencing accounts.
 --
 
+[[reviewer-updates]]
+--
+* `REVIEWER_UPDATES`: include updates to reviewers set as
+  link:#review-update-info[ReviewerUpdateInfo] entities.
+--
+
 [[messages]]
 --
 * `MESSAGES`: include messages associated with the change.
@@ -355,6 +360,7 @@
       "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c",
       "revisions": {
         "184ebe53805e102605d11f6b143486d15c23a09c": {
+          "kind": "REWORK",
           "_number": 1,
           "ref": "refs/changes/97/97/1",
           "fetch": {
@@ -414,35 +420,42 @@
           "files": {
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeCache.java": {
               "lines_deleted": 8,
-              "size_delta": -412
+              "size_delta": -412,
+              "size": 7782
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDetailCache.java": {
               "lines_inserted": 1,
-              "size_delta": 23
+              "size_delta": 23,
+              "size": 6762
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java": {
               "lines_inserted": 11,
               "lines_deleted": 19,
-              "size_delta": -298
+              "size_delta": -298,
+              "size": 47023
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java": {
               "lines_inserted": 23,
               "lines_deleted": 20,
-              "size_delta": 132
+              "size_delta": 132,
+              "size": 17727
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarCache.java": {
               "status": "D",
               "lines_deleted": 139,
-              "size_delta": -5512
+              "size_delta": -5512,
+              "size": 13098
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java": {
               "status": "A",
               "lines_inserted": 204,
-              "size_delta": 8345
+              "size_delta": 8345,
+              "size": 8345
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java": {
               "lines_deleted": 9,
-              "size_delta": -343
+              "size_delta": -343,
+              "size": 5385
             }
           }
         }
@@ -505,8 +518,8 @@
 --
 
 Retrieves a change with link:#labels[labels], link:#detailed-labels[
-detailed labels], link:#detailed-accounts[detailed accounts], and
-link:#messages[messages].
+detailed labels], link:#detailed-accounts[detailed accounts],
+link:#reviewer-updates[reviewer updates], and link:#messages[messages].
 
 Additional fields can be obtained by adding `o` parameters, each
 option requires more database lookups and slows down the query
@@ -634,6 +647,72 @@
         "username": "jroe"
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        {
+          "_account_id": 1000097,
+          "name": "Jane Roe",
+          "email": "jane.roe@example.com",
+          "username": "jroe"
+        }
+      ]
+    },
+    "reviewer_updates": [
+      {
+        "state": "REVIEWER",
+        "reviewer": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated_by": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated": "2016-07-21 20:12:39.000000000"
+      },
+      {
+        "state": "REMOVED",
+        "reviewer": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated_by": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated": "2016-07-21 20:12:33.000000000"
+      },
+      {
+        "state": "CC",
+        "reviewer": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated_by": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        "updated": "2016-03-23 21:34:02.419000000",
+      },
+    ],
     "messages": [
       {
         "id": "YH-egE",
@@ -912,6 +991,7 @@
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
+        "kind": "REWORK",
         "_number": 2,
         "ref": "refs/changes/99/4799/2",
         "fetch": {
@@ -959,6 +1039,84 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+[[move-change]]
+=== Move Change
+--
+'POST /changes/link:#change-id[\{change-id\}]/move'
+--
+
+Move a change.
+
+The destination branch must be provided in the request body inside a
+link:#move-input[MoveInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/move HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "destination_branch" : "release-branch"
+  }
+
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the moved change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~release-branch~I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "subject": "Implementing Feature X",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 13,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
+If the change cannot be moved because the change state doesn't
+allow moving the change, the response is "`409 Conflict`" and
+the error message is contained in the response body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  change is merged
+----
+
+If the change cannot be moved because the user doesn't have
+abandon permission on the change or upload permission on the destination,
+the response is "`409 Conflict`" and the error message is contained in the
+response body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  move not permitted
+----
+
 [[revert-change]]
 === Revert Change
 --
@@ -972,7 +1130,7 @@
 
 .Request
 ----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revert HTTP/1.0
+  POST /changes/myProject~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14/revert HTTP/1.0
 ----
 
 As response a link:#change-info[ChangeInfo] entity is returned that
@@ -1026,8 +1184,7 @@
 Submits a change.
 
 The request body only needs to include a link:#submit-input[
-SubmitInput] entity if the request should wait for the merge to
-complete.
+SubmitInput] entity if submitting on behalf of another user.
 
 .Request
 ----
@@ -1035,7 +1192,7 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "wait_for_merge": true
+    "on_behalf_of": 1001439
   }
 ----
 
@@ -1058,6 +1215,7 @@
     "status": "MERGED",
     "created": "2013-02-01 09:59:32.126000000",
     "updated": "2013-02-21 11:16:36.775000000",
+    "submitted": "2013-02-21 11:16:36.615000000",
     "_number": 3965,
     "owner": {
       "name": "John Doe"
@@ -1079,29 +1237,15 @@
 ----
 
 [[submitted-together]]
-=== Changes submitted together
+=== Changes Submitted Together
 --
-'GET /changes/link:#change-id[\{change-id\}]/submitted_together'
+'GET /changes/link:#change-id[\{change-id\}]/submitted_together?o=NON_VISIBLE_CHANGES'
 --
 
-Returns a list of all changes which are submitted when
-link:#submit-change[\{submit\}] is called for this change,
+Computes list of all changes which are submitted when
+link:#submit-change[Submit] is called for this change,
 including the current change itself.
 
-An empty list is returned if this change will be submitted
-by itself (no other changes).
-
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submitted_together HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-The return value is a list of changes in the same format as in
-link:#list-changes[\{listing changes\}] with the options
-link:#labels[\{LABELS\}], link:#detailed-labels[\{DETAILED_LABELS\}],
-link:#current-revision[\{CURRENT_REVISION\}],
-link:#current-commit[\{CURRENT_COMMIT\}] set.
 The list consists of:
 
 * The given change.
@@ -1110,6 +1254,27 @@
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
+As a special case, the list is empty if this change would be
+submitted by itself (without other changes).
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submitted_together?o=NON_VISIBLE_CHANGES HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+As a response a link:#submitted-together-info[SubmittedTogetherInfo]
+entity is returned that describes what would happen if the change were
+submitted. This response contains a list of changes and a count of
+changes that are not visible to the caller that are part of the set of
+changes to be merged.
+
+The listed changes use the same format as in
+link:#list-changes[Query Changes] with the
+link:#labels[`LABELS`], link:#detailed-labels[`DETAILED_LABELS`],
+link:#current-revision[`CURRENT_REVISION`], and
+link:#current-commit[`CURRENT_COMMIT`] options set.
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -1117,232 +1282,257 @@
   Content-Type: application/json; charset=UTF-8
 
 )]}'
-[
-  {
-    "id": "gerrit~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
-    "project": "gerrit",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
-    "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
-    "status": "NEW",
-    "created": "2015-05-01 15:39:57.979000000",
-    "updated": "2015-05-20 19:25:21.592000000",
-    "mergeable": true,
-    "insertions": 303,
-    "deletions": 210,
-    "_number": 1779,
-    "owner": {
-      "_account_id": 1000000
-    },
-    "labels": {
-      "Code-Review": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 2,
-            "date": "2015-05-20 19:25:21.592000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
-          " 0": "No score",
-          "+1": "Looks good to me, but someone else must approve",
-          "+2": "Looks good to me, approved"
-        },
-        "default_value": 0
-      },
-      "Verified": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 1,
-            "date": "2015-05-20 19:25:21.592000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-1": "Fails",
-          " 0": "No score",
-          "+1": "Verified"
-        },
-        "default_value": 0
-      }
-    },
-    "permitted_labels": {
-      "Code-Review": [
-        "-2",
-        "-1",
-        " 0",
-        "+1",
-        "+2"
-      ],
-      "Verified": [
-        "-1",
-        " 0",
-        "+1"
-      ]
-    },
-    "removable_reviewers": [
-      {
+{
+  "changes": [
+    {
+      "id": "gerrit~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
+      "project": "gerrit",
+      "branch": "master",
+      "hashtags": [],
+      "change_id": "I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
+      "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
+      "status": "NEW",
+      "created": "2015-05-01 15:39:57.979000000",
+      "updated": "2015-05-20 19:25:21.592000000",
+      "mergeable": true,
+      "insertions": 303,
+      "deletions": 210,
+      "_number": 1779,
+      "owner": {
         "_account_id": 1000000
-      }
-    ],
-    "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
-    "revisions": {
-      "9adb9f4c7b40eeee0646e235de818d09164d7379": {
-        "_number": 1,
-        "created": "2015-05-01 15:39:57.979000000",
-        "uploader": {
-          "_account_id": 1000000
-        },
-        "ref": "refs/changes/79/1779/1",
-        "fetch": {},
-        "commit": {
-          "parents": [
+      },
+      "labels": {
+        "Code-Review": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
             {
-              "commit": "2d3176497a2747faed075f163707e57d9f961a1c",
-              "subject": "Merge changes from topic \u0027submodule-subscription-tests-and-fixes-3\u0027"
+              "value": 2,
+              "date": "2015-05-20 19:25:21.592000000",
+              "_account_id": 1000000
             }
           ],
-          "author": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-04-29 21:36:52.000000000",
-            "tz": -420
+          "values": {
+            "-2": "This shall not be merged",
+            "-1": "I would prefer this is not merged as is",
+            " 0": "No score",
+            "+1": "Looks good to me, but someone else must approve",
+            "+2": "Looks good to me, approved"
           },
-          "committer": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-05-01 00:11:16.000000000",
-            "tz": -420
+          "default_value": 0
+        },
+        "Verified": {
+          "approved": {
+            "_account_id": 1000000
           },
-          "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
-          "message": "ChangeMergeQueue: Rewrite such that it works on set of changes\n\nChangeMergeQueue used to work on branches rather than sets of changes.\nThis change is a first step to merge sets of changes (e.g. grouped by a\ntopic and `changes.submitWholeTopic` enabled) in an atomic fashion.\nThis change doesn\u0027t aim to implement these changes, but only as a step\ntowards it.\n\nMergeOp keeps its functionality and behavior as is. A new class\nMergeOpMapper is introduced which will map the set of changes to\nthe set of branches. Additionally the MergeOpMapper is also\nresponsible for the threading done right now, which was part of\nthe ChangeMergeQueue before.\n\nChange-Id: I1ffe09a505e25f15ce1521bcfb222e51e62c2a14\n"
+          "all": [
+            {
+              "value": 1,
+              "date": "2015-05-20 19:25:21.592000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-1": "Fails",
+            " 0": "No score",
+            "+1": "Verified"
+          },
+          "default_value": 0
+        }
+      },
+      "permitted_labels": {
+        "Code-Review": [
+          "-2",
+          "-1",
+          " 0",
+          "+1",
+          "+2"
+        ],
+        "Verified": [
+          "-1",
+          " 0",
+          "+1"
+        ]
+      },
+      "removable_reviewers": [
+        {
+          "_account_id": 1000000
+        }
+      ],
+      "reviewers": {
+        "REVIEWER": [
+          {
+            "_account_id": 1000000
+          }
+        ]
+      },
+      "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
+      "revisions": {
+        "9adb9f4c7b40eeee0646e235de818d09164d7379": {
+          "kind": "REWORK",
+          "_number": 1,
+          "created": "2015-05-01 15:39:57.979000000",
+          "uploader": {
+            "_account_id": 1000000
+          },
+          "ref": "refs/changes/79/1779/1",
+          "fetch": {},
+          "commit": {
+            "parents": [
+              {
+                "commit": "2d3176497a2747faed075f163707e57d9f961a1c",
+                "subject": "Merge changes from topic \u0027submodule-subscription-tests-and-fixes-3\u0027"
+              }
+            ],
+            "author": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-04-29 21:36:52.000000000",
+              "tz": -420
+            },
+            "committer": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-05-01 00:11:16.000000000",
+              "tz": -420
+            },
+            "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
+            "message": "ChangeMergeQueue: Rewrite such that it works on set of changes\n\nChangeMergeQueue used to work on branches rather than sets of changes.\nThis change is a first step to merge sets of changes (e.g. grouped by a\ntopic and `changes.submitWholeTopic` enabled) in an atomic fashion.\nThis change doesn\u0027t aim to implement these changes, but only as a step\ntowards it.\n\nMergeOp keeps its functionality and behavior as is. A new class\nMergeOpMapper is introduced which will map the set of changes to\nthe set of branches. Additionally the MergeOpMapper is also\nresponsible for the threading done right now, which was part of\nthe ChangeMergeQueue before.\n\nChange-Id: I1ffe09a505e25f15ce1521bcfb222e51e62c2a14\n"
+          }
+        }
+      }
+    },
+    {
+      "id": "gerrit~master~I7fe807e63792b3d26776fd1422e5e790a5697e22",
+      "project": "gerrit",
+      "branch": "master",
+      "hashtags": [],
+      "change_id": "I7fe807e63792b3d26776fd1422e5e790a5697e22",
+      "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
+      "status": "NEW",
+      "created": "2015-05-01 15:39:57.979000000",
+      "updated": "2015-05-20 19:25:21.546000000",
+      "mergeable": true,
+      "insertions": 15,
+      "deletions": 6,
+      "_number": 1780,
+      "owner": {
+        "_account_id": 1000000
+      },
+      "labels": {
+        "Code-Review": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 2,
+              "date": "2015-05-20 19:25:21.546000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-2": "This shall not be merged",
+            "-1": "I would prefer this is not merged as is",
+            " 0": "No score",
+            "+1": "Looks good to me, but someone else must approve",
+            "+2": "Looks good to me, approved"
+          },
+          "default_value": 0
+        },
+        "Verified": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 1,
+              "date": "2015-05-20 19:25:21.546000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-1": "Fails",
+            " 0": "No score",
+            "+1": "Verified"
+          },
+          "default_value": 0
+        }
+      },
+      "permitted_labels": {
+        "Code-Review": [
+          "-2",
+          "-1",
+          " 0",
+          "+1",
+          "+2"
+        ],
+        "Verified": [
+          "-1",
+          " 0",
+          "+1"
+        ]
+      },
+      "removable_reviewers": [
+        {
+          "_account_id": 1000000
+        }
+      ],
+      "reviewers": {
+        "REVIEWER": [
+          {
+            "_account_id": 1000000
+          }
+        ]
+      },
+      "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
+      "revisions": {
+        "1bd7c12a38854a2c6de426feec28800623f492c4": {
+          "kind": "REWORK",
+          "_number": 1,
+          "created": "2015-05-01 15:39:57.979000000",
+          "uploader": {
+            "_account_id": 1000000
+          },
+          "ref": "refs/changes/80/1780/1",
+          "fetch": {},
+          "commit": {
+            "parents": [
+              {
+                "commit": "9adb9f4c7b40eeee0646e235de818d09164d7379",
+                "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes"
+              }
+            ],
+            "author": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-04-25 00:11:59.000000000",
+              "tz": -420
+            },
+            "committer": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-05-01 00:11:16.000000000",
+              "tz": -420
+            },
+            "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
+            "message": "AbstractSubmoduleSubscription: Split up createSubscription\n\nLater we want to have subscriptions to more submodules, so we need to\nfind a way to add more submodule entries into the file. By splitting up\nthe createSubscription() method, that is very easy by using the\naddSubmoduleSubscription method multiple times.\n\nChange-Id: I7fe807e63792b3d26776fd1422e5e790a5697e22\n"
+          }
         }
       }
     }
-  },
-  {
-    "id": "gerrit~master~I7fe807e63792b3d26776fd1422e5e790a5697e22",
-    "project": "gerrit",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "I7fe807e63792b3d26776fd1422e5e790a5697e22",
-    "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
-    "status": "NEW",
-    "created": "2015-05-01 15:39:57.979000000",
-    "updated": "2015-05-20 19:25:21.546000000",
-    "mergeable": true,
-    "insertions": 15,
-    "deletions": 6,
-    "_number": 1780,
-    "owner": {
-      "_account_id": 1000000
-    },
-    "labels": {
-      "Code-Review": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 2,
-            "date": "2015-05-20 19:25:21.546000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
-          " 0": "No score",
-          "+1": "Looks good to me, but someone else must approve",
-          "+2": "Looks good to me, approved"
-        },
-        "default_value": 0
-      },
-      "Verified": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 1,
-            "date": "2015-05-20 19:25:21.546000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-1": "Fails",
-          " 0": "No score",
-          "+1": "Verified"
-        },
-        "default_value": 0
-      }
-    },
-    "permitted_labels": {
-      "Code-Review": [
-        "-2",
-        "-1",
-        " 0",
-        "+1",
-        "+2"
-      ],
-      "Verified": [
-        "-1",
-        " 0",
-        "+1"
-      ]
-    },
-    "removable_reviewers": [
-      {
-        "_account_id": 1000000
-      }
-    ],
-    "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
-    "revisions": {
-      "1bd7c12a38854a2c6de426feec28800623f492c4": {
-        "_number": 1,
-        "created": "2015-05-01 15:39:57.979000000",
-        "uploader": {
-          "_account_id": 1000000
-        },
-        "ref": "refs/changes/80/1780/1",
-        "fetch": {},
-        "commit": {
-          "parents": [
-            {
-              "commit": "9adb9f4c7b40eeee0646e235de818d09164d7379",
-              "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes"
-            }
-          ],
-          "author": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-04-25 00:11:59.000000000",
-            "tz": -420
-          },
-          "committer": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-05-01 00:11:16.000000000",
-            "tz": -420
-          },
-          "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
-          "message": "AbstractSubmoduleSubscription: Split up createSubscription\n\nLater we want to have subscriptions to more submodules, so we need to\nfind a way to add more submodule entries into the file. By splitting up\nthe createSubscription() method, that is very easy by using the\naddSubmoduleSubscription method multiple times.\n\nChange-Id: I7fe807e63792b3d26776fd1422e5e790a5697e22\n"
-        }
-      }
-    }
-  }
-]
+  ],
+  "non_visible_changes": 0
+}
 ----
 
+If the `o=NON_VISIBLE_CHANGES` query parameter is not passed, then
+instead of a link:#submitted-together-info[SubmittedTogetherInfo]
+entity, the response is a list of changes, or a 403 response with a
+message if the set of changes to be submitted with this change
+includes changes the caller cannot read.
+
 
 [[publish-draft-change]]
 === Publish Draft Change
@@ -1533,7 +1723,7 @@
 ----
 
 [[check-change]]
-=== Check change
+=== Check Change
 --
 'GET /changes/link:#change-id[\{change-id\}]/check'
 --
@@ -1583,7 +1773,7 @@
 ----
 
 [[fix-change]]
-=== Fix change
+=== Fix Change
 --
 'POST /changes/link:#change-id[\{change-id\}]/check'
 --
@@ -1618,6 +1808,7 @@
     "status": "MERGED",
     "created": "2013-02-01 09:59:32.126000000",
     "updated": "2013-02-21 11:16:36.775000000",
+    "submitted": "2013-02-21 11:16:36.615000000",
     "mergeable": true,
     "insertions": 34,
     "deletions": 101,
@@ -1692,6 +1883,7 @@
        "subject":"Use an EventBus to manage star icons",
        "message":"Use an EventBus to manage star icons\n\nImage widgets that need to ..."
     },
+    "base_revision":"c35558e0925e6985c91f3a16921537d5e572b7a3"
   }
 ----
 
@@ -1835,6 +2027,9 @@
 If only the content type is required, callers should use HEAD to
 avoid downloading the encoded file contents.
 
+If the `base` parameter is set to true, the returned content is from the
+revision that the edit is based on.
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -1902,6 +2097,9 @@
 
 Retrieves commit message from change edit.
 
+If the `base` parameter is set to true, the returned message is from the
+revision that the edit is based on.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:message HTTP/1.0
@@ -2068,13 +2266,15 @@
         "_account_id": 1000097,
         "name": "Jane Roe",
         "email": "jane.roe@example.com"
-      }
+      },
+      "count": 1
     },
     {
       "group": {
         "id": "4fd581c0657268f2bdcc26699fbf9ddb76e3a279",
         "name": "Joiner"
-      }
+      },
+      "count": 5
     }
   ]
 ----
@@ -2147,6 +2347,7 @@
   {
     "reviewers": [
       {
+        "input": "john.doe@example.com",
         "approvals": {
           "Verified": " 0",
           "Code-Review": " 0"
@@ -2184,6 +2385,7 @@
 
   )]}'
   {
+    "input": "MyProjectVerifiers",
     "error": "The group My Group has 15 members. Do you want to add them all as reviewers?",
     "confirm": true
   }
@@ -2198,7 +2400,7 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "reviewer": "MyProjectVerifiers",
+    "input": "MyProjectVerifiers",
     "confirmed": true
   }
 ----
@@ -2221,6 +2423,74 @@
   HTTP/1.1 204 No Content
 ----
 
+[[list-votes]]
+=== List Votes
+--
+'GET /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/'
+--
+
+Lists the votes for a specific reviewer of the change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/ HTTP/1.0
+----
+
+As result a map is returned that maps the label name to the label value.
+The entries in the map are sorted by label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "Code-Review": -1,
+    "Verified": 1
+    "Work-In-Progress": 1,
+  }
+----
+
+[[delete-vote]]
+=== Delete Vote
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]'
+'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]/delete'
+--
+
+Deletes a single vote from a change. Note, that even when the last vote of
+a reviewer is removed the reviewer itself is still listed on the change.
+
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+----
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a POST
+request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[revision-endpoints]]
 == Revision Endpoints
 
@@ -2434,15 +2704,31 @@
         "email": "jane.roe@example.com"
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
+        },
+        {
+          "_account_id": 1000097,
+          "name": "Jane Roe",
+          "email": "jane.roe@example.com"
+        }
+      ]
+    },
     "current_revision": "674ac754f91e64a0efb8087e59a176484bd534d1",
     "revisions": {
       "674ac754f91e64a0efb8087e59a176484bd534d1": {
-      "_number": 2,
-      "ref": "refs/changes/65/3965/2",
-      "fetch": {
-        "http": {
-          "url": "http://gerrit/myProject",
-          "ref": "refs/changes/65/3965/2"
+        "kind": "REWORK",
+        "_number": 2,
+        "ref": "refs/changes/65/3965/2",
+        "fetch": {
+          "http": {
+            "url": "http://gerrit/myProject",
+            "ref": "refs/changes/65/3965/2"
+          }
         }
       }
     }
@@ -2541,6 +2827,7 @@
   Content-Type: application/json; charset=UTF-8
 
   {
+    "tag": "jenkins",
     "message": "Some nits need to be fixed.",
     "labels": {
       "Code-Review": -1
@@ -2589,6 +2876,92 @@
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
 
+It is also possible to add one or more reviewers to a change simultaneously
+with a review.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "Looks good to me, but Jane and John should also take a look.",
+    "labels": {
+      "Code-Review": 1
+    },
+    "reviewers": [
+      {
+        "reviewer": "jane.roe@example.com"
+      },
+      {
+        "reviewer": "john.doe@example.com"
+      }
+    ]
+  }
+----
+
+Each element of the `reviewers` list is an instance of
+link:#reviewer-input[ReviewerInput]. The corresponding result of
+adding each reviewer will be returned in a list of
+link:#add-reviewer-result[AddReviewerResult].
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "labels": {
+      "Code-Review": 1
+    },
+    "reviewers": [
+      {
+        "input": "jane.roe@example.com",
+        "approvals": {
+          "Verified": " 0",
+          "Code-Review": " 0"
+        },
+        "_account_id": 1000097,
+        "name": "Jane Roe",
+        "email": "jane.roe@example.com"
+      },
+      {
+        "input": "john.doe@example.com",
+        "approvals": {
+          "Verified": " 0",
+          "Code-Review": " 0"
+        },
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com"
+      }
+    ]
+  }
+----
+
+If there are any errors returned for reviewers, the entire review request will
+be rejected with `400 Bad Request`.
+
+.Error Response
+----
+  HTTP/1.1 400 Bad Request
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "reviewers": {
+      "MyProjectVerifiers": {
+        "input": "MyProjectVerifiers",
+        "error": "The group My Group has 15 members. Do you want to add them all as reviewers?",
+        "confirm": true
+      }
+    }
+  }
+----
+
 [[rebase-revision]]
 === Rebase Revision
 --
@@ -2640,6 +3013,7 @@
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
+        "kind": "REWORK",
         "_number": 2,
         "ref": "refs/changes/99/4799/2",
         "fetch": {
@@ -2827,7 +3201,8 @@
   )]}'
   {
     submit_type: "MERGE_IF_NECESSARY",
-    mergeable: true,
+    strategy: "recursive",
+    mergeable: true
   }
 ----
 
@@ -3264,12 +3639,14 @@
     "/COMMIT_MSG": {
       "status": "A",
       "lines_inserted": 7,
-      "size_delta": 551
+      "size_delta": 551,
+      "size": 551
     },
     "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": {
       "lines_inserted": 5,
       "lines_deleted": 3,
-      "size_delta": 98
+      "size_delta": 98,
+      "size": 23348
     }
   }
 ----
@@ -3283,6 +3660,11 @@
 in the path name. This is useful to implement suggestion services
 finding a file by partial name.
 
+The integer-valued request parameter `parent` changes the response to return a
+list of the files which are different in this commit compared to the given
+parent commit. This is useful for supporting review of merge commits.  The value
+is the 1-based index of the parent's position in the commit object.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/?reviewed HTTP/1.0
@@ -3368,7 +3750,7 @@
 
 .Request
 ----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/website%2Freleases%2Flogo.png/safe_content HTTP/1.0
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/website%2Freleases%2Flogo.png/download HTTP/1.0
 ----
 
 .Response
@@ -3382,7 +3764,7 @@
 
 .Request
 ----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/safe_content?suffix=new HTTP/1.0
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/download?suffix=new HTTP/1.0
 ----
 
 .Response
@@ -3528,6 +3910,11 @@
 The `base` parameter can be specified to control the base patch set from which the diff should
 be generated.
 
+The integer-valued request parameter `parent` can be specified to control the
+parent commit number against which the diff should be generated.  This is useful
+for supporting review of merge commits.  The value is the 1-based index of the
+parent's position in the commit object.
+
 [[weblinks-only]]
 If the `weblinks-only` parameter is specified, only the diff web links are returned.
 
@@ -3563,12 +3950,73 @@
   }
 ----
 
-The `ignore-whitespace` parameter can be specified to control how whitespace differences are
-reported in the result.  Valid values are `NONE`, `TRAILING`, `CHANGED` or `ALL`.
+The `whitespace` parameter can be specified to control how whitespace
+differences are reported in the result.  Valid values are `IGNORE_NONE`,
+`IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
 The `context` parameter can be specified to control the number of lines of surrounding context
 in the diff.  Valid values are `ALL` or number of lines.
 
+[[get-blame]]
+=== Get Blame
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/files/link:#file-id[\{file-id\}]/blame'
+--
+
+Gets the blame of a file from a certain revision.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/blame HTTP/1.0
+----
+
+As response a link:#blame-info[BlameInfo] entity is returned that describes the
+blame.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]
+  {
+    [
+      {
+        "author": "Joe Daw",
+        "id": "64e140b4de5883a4dd74d06c2b62ccd7ffd224a7",
+        "time": 1421441349,
+        "commit_msg": "RST test\n\nChange-Id: I11e9e24bd122253f4bb10c36dce825ac2410d646\n",
+        "ranges": [
+          {
+            "start": 1,
+            "end": 10
+          },
+          {
+            "start": 16,
+            "end": 296
+          }
+        ]
+      },
+      {
+        "author": "Jane Daw",
+        "id": "8d52621a0e2ac6adec73bd3a49f2371cd53137a7",
+        "time": 1421825421,
+        "commit_msg": "add banner\n\nChange-Id: I2eced9b2691015ae3c5138f4d0c4ca2b8fb15be9\n",
+        "ranges": [
+          {
+            "start": 13,
+            "end": 13
+          }
+        ]
+      }
+    ]
+  }
+----
+
+The `base` parameter can be specified to control the base patch set from which
+the blame should be generated.
+
 [[set-reviewed]]
 === Set Reviewed
 --
@@ -3688,6 +4136,10 @@
 === \{draft-id\}
 UUID of a draft comment.
 
+[[label-id]]
+=== \{label-id\}
+The name of the label.
+
 [[file-id]]
 \{file-id\}
 ~~~~~~~~~~~~
@@ -3704,6 +4156,7 @@
 * an abbreviated commit ID that uniquely identifies one revision of the
   change ("674ac754"), at least 4 digits are required
 * a legacy numeric patch number ("1" for first patch set of the change)
+* "0" or the literal `edit` for a change edit
 
 [[json-entities]]
 == JSON Entities
@@ -3718,6 +4171,11 @@
 |`message`     |optional|
 Message to be added as review comment to the change when abandoning the
 change.
+|`notify`      |optional|
+Notify handling that defines to whom email notifications should be sent after
+the change is abandoned. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
 |===========================
 
 [[action-info]]
@@ -3754,9 +4212,17 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
+|`input`    ||
+Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
+set while adding the reviewer.
 |`reviewers`   |optional|
 The newly added reviewers as a list of link:#reviewer-info[
 ReviewerInfo] entities.
+|`ccs`         |optional|
+The newly CCed accounts as a list of link:#reviewer-info[
+ReviewerInfo] entities. This field will only appear if the requested
+`state` for the reviewer was `CC` *and* NoteDb is enabled on the
+server.
 |`error`       |optional|
 Error message explaining why the reviewer could not be added. +
 If a group was specified in the input and an error is returned, it
@@ -3783,6 +4249,27 @@
 permitted to vote on that label.
 |`date`        |optional|
 The time and date describing when the approval was made.
+|`tag`                 |optional|
+Value of the `tag` field from link:#review-input[ReviewInput] set
+while posting the review.
+NOTE: To apply different tags on on different votes/comments multiple
+invocations of the REST call are required.
+|===========================
+
+[[blame-info]]
+=== BlameInfo
+The `BlameInfo` entity stores the commit metadata with the row coordinates where
+it applies.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name | Description
+|`author`     | The author of the commit.
+|`id`         | The id of the commit.
+|`time`       | Commit time.
+|`commit_msg` | The commit message.
+|`ranges`     |
+The blame row coordinates as link:#range-info[RangeInfo] entities.
 |===========================
 
 [[change-edit-input]]
@@ -3836,11 +4323,20 @@
 |`updated`            ||
 The link:rest-api.html#timestamp[timestamp] of when the change was last
 updated.
+|`submitted`          |only set for merged changes|
+The link:rest-api.html#timestamp[timestamp] of when the change was
+submitted.
 |`starred`            |not set if `false`|
-Whether the calling user has starred this change.
+Whether the calling user has starred this change with the default label.
+|`stars`              |optional|
+A list of star labels that are applied by the calling user to this
+change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
+|`submit_type`        |optional|
+The link:project-configuration.html#submit_type[submit type] of the change. +
+Not set for merged changes.
 |`mergeable`          |optional|
 Whether the change is mergeable. +
 Not set for merged changes, or if the change has not yet been tested.
@@ -3869,6 +4365,20 @@
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`reviewers`          ||
+The reviewers as a map that maps a reviewer state to a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities.
+Possible reviewer states are `REVIEWER`, `CC` and `REMOVED`. +
+`REVIEWER`: Users with at least one non-zero vote on the change. +
+`CC`: Users that were added to the change, but have not voted. +
+`REMOVED`: Users that were previously reviewers on the change, but have
+been removed. +
+Only set if link:#detailed-labels[detailed labels] are requested.
+|`reviewer_updates`|optional|
+Updates to reviewers set for the change as
+link:#review-update-info[ReviewerUpdateInfo] entities.
+Only set if link:#reviewer-updates[reviewer updates] are requested and
+if NoteDb is enabled.
 |`messages`|optional|
 Messages associated with the change as a list of
 link:#change-message-info[ChangeMessageInfo] entities. +
@@ -3889,9 +4399,31 @@
 |`problems`           |optional|
 A list of link:#problem-info[ProblemInfo] entities describing potential
 problems with this change. Only set if link:#check[CHECK] is set.
+|==================================
+
+[[change-input]]
+=== ChangeInput
+The `ChangeInput` entity contains information about creating a new change.
+
+[options="header",cols="1,^1,5"]
+|==================================
+|Field Name           ||Description
+|`project`            ||The name of the project.
+|`branch`             ||
+The name of the target branch. +
+The `refs/heads/` prefix is omitted.
+|`subject`            ||
+The subject of the change (header line of the commit message).
+|`topic`              |optional|The topic to which this change belongs.
+|`status`             |optional, default to `NEW`|
+The status of the change (only `NEW` and `DRAFT` accepted here).
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Only used for the link:#create-change[CreateChange] endpoint.
+change operation.
+|`new_branch`         |optional, default to `false`|
+Allow creating a new branch when set to `true`.
+|`merge`              |optional|
+The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 |==================================
 
 [[change-message-info]]
@@ -3910,6 +4442,11 @@
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||The text left by the user.
+|`tag`                 |optional|
+Value of the `tag` field from link:#review-input[ReviewInput] set
+while posting the review.
+NOTE: To apply different tags on on different votes/comments multiple
+invocations of the REST call are required.
 |`_revision_number`    |optional|
 Which patchset (if any) generated this message.
 |==================================
@@ -3943,6 +4480,9 @@
 The side on which the comment was added. +
 Allowed values are `REVISION` and `PARENT`. +
 If not set, the default is `REVISION`.
+|`parent`      |optional|
+The 1-based parent number. Used only for merge commits when `side == PARENT`.
+When not set the comment is for the auto-merge tree.
 |`line`        |optional|
 The number of the line for which the comment was done. +
 If range is set, this equals the end line of the range. +
@@ -3960,6 +4500,11 @@
 The author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
 Unset for draft comments, assumed to be the calling user.
+|`tag`                 |optional|
+Value of the `tag` field from link:#review-input[ReviewInput] set
+while posting the review.
+NOTE: To apply different tags on on different votes/comments multiple
+invocations of the REST call are required.
 |===========================
 
 [[comment-input]]
@@ -3998,6 +4543,10 @@
 The comment message. +
 If not set and an existing draft comment is updated, the existing draft
 comment is deleted.
+|`tag`         |optional, drafts only|
+Value of the `tag` field. Only allowed on link:#create-draft[draft comment] +
+inputs; for published comments, use the `tag` field in +
+link#review-input[ReviewInput]
 |===========================
 
 [[comment-range]]
@@ -4040,6 +4589,24 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[delete-vote-input]]
+=== DeleteVoteInput
+The `DeleteVoteInput` entity contains options for the deletion of a
+vote.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`label`   |optional|
+The label for which the vote should be deleted. +
+If set, must match the label in the URL.
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the vote is deleted. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|=======================
+
 [[diff-content]]
 === DiffContent
 The `DiffContent` entity contains information about the content differences
@@ -4160,15 +4727,15 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`commit`      ||The commit of change edit as
+|Field Name     ||Description
+|`commit`       ||The commit of change edit as
 link:#commit-info[CommitInfo] entity.
-|`baseRevision`||The revision of the patch set change edit is based on.
-|`fetch`       ||
+|`base_revision`||The revision of the patch set the change edit is based on.
+|`fetch`        ||
 Information about how to fetch this patch set. The fetch information is
 provided as a map that maps the protocol name ("`git`", "`http`",
 "`ssh`") to link:#fetch-info[FetchInfo] entities.
-|`files`       |optional|
+|`files`        |optional|
 The files of the change edit as a map that maps the file names to
 link:#file-info[FileInfo] entities.
 |===========================
@@ -4212,6 +4779,8 @@
 Not set for binary files or if no lines were deleted.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
+|`size`          ||
+File size in bytes.
 |=============================
 
 [[fix-input]]
@@ -4343,12 +4912,49 @@
 Submit type used for this change, can be `MERGE_IF_NECESSARY`,
 `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `MERGE_ALWAYS` or
 `CHERRY_PICK`.
+|`strategy`     |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`.
 |`mergeable`     ||
 `true` if this change is cleanly mergeable, `false` otherwise
+|`commit_merged`     |optional|
+`true` if this change is already merged, `false` otherwise
+|`content_merged`     |optional|
+`true` if the content of this change is already merged, `false` otherwise
+|`conflicts`|optional|
+A list of paths with conflicts
 |`mergeable_into`|optional|
 A list of other branch names where this change could merge cleanly
 |============================
 
+[[merge-input]]
+=== MergeInput
+The `MergeInput` entity contains information about the merge
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name      ||Description
+|`source`   ||
+The source to merge from, e.g. a complete or abbreviated commit SHA-1,
+a complete reference name, a short reference name under refs/heads, refs/tags,
+or refs/remotes namespace, etc.
+|`strategy`     |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
+|============================
+
+[[move-input]]
+=== MoveInput
+The `MoveInput` entity contains information for moving a change to a new branch.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name          ||Description
+|`destination_branch`||Destination branch
+|`message`           |optional|
+A message to be posted in this change's comments
+|===========================
+
 [[problem-info]]
 === ProblemInfo
 The `ProblemInfo` entity contains a description of a potential consistency problem
@@ -4385,6 +4991,17 @@
 link:rest-api-accounts.html#gpg-key-info[GpgKeyInfo] entity.
 |===========================
 
+[[range-info]]
+=== RangeInfo
+The `RangeInfo` entity stores the coordinates of a range.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name   | Description
+|`start`      | First index.
+|`end`        | Last index.
+|===========================
+
 [[rebase-input]]
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
@@ -4469,6 +5086,26 @@
 voting values.
 |===========================
 
+[[review-update-info]]
+=== ReviewerUpdateInfo
+The `ReviewerUpdateInfo` entity contains information about updates to
+change's reviewers set.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name     |Description
+|`updated`|
+Timestamp of the update.
+|`updated_by`|
+The account which modified state of the reviewer in question as
+link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`reviewer`|
+The reviewer account added or removed from the change as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`state`|
+The reviewer state, one of `REVIEWER`, `CC` or `REMOVED`.
+|===========================
+
 [[review-input]]
 === ReviewInput
 The `ReviewInput` entity contains information for adding a review to a
@@ -4479,6 +5116,11 @@
 |Field Name               ||Description
 |`message`                |optional|
 The message to be added as review comment.
+|`tag`                    |optional|
+Apply this tag to the review comment message, votes, and inline
+comments. Tags may be used by CI or other automated systems to
+distinguish them from human reviews. Comments with specific tag
+values can be filtered out in the web UI.
 |`labels`                 |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
@@ -4545,11 +5187,19 @@
 ID] of one group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
+|`state`       |optional|
+Add reviewer in this state. Possible reviewer states are `REVIEWER`
+and `CC`. If not given, defaults to `REVIEWER`.
 |`confirmed`   |optional|
 Whether adding the reviewer is confirmed. +
 The Gerrit server may be configured to
 link:config-gerrit.html#addreviewer.maxWithoutConfirmation[require a
 confirmation] when adding a group as reviewer that has many members.
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the reviewer is added. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
 |===========================
 
 [[revision-info]]
@@ -4563,6 +5213,8 @@
 |===========================
 |Field Name    ||Description
 |`draft`       |not set if `false`|Whether the patch set is a draft.
+|`kind`        ||The change kind. Valid values are `REWORK`, `TRIVIAL_REBASE`,
+`MERGE_FIRST_PARENT_UPDATE`, `NO_CODE_CHANGE`, and `NO_CHANGE`.
 |`_number`     ||The patch set number.
 |`created`     ||
 The link:rest-api.html#timestamp[timestamp] of when the patch set was
@@ -4633,18 +5285,14 @@
 |Field Name    ||Description
 |`status`      ||
 The status of the change after submitting is `MERGED`.
-+
-As `wait_for_merge` in the link:#submit-input[SubmitInput] is deprecated and
-the request always waits for the merge to be completed, you can expect
-`MERGED` to be returned here.
 |`on_behalf_of`|optional|
 The link:rest-api-accounts.html#account-id[\{account-id\}] of the user on
 whose behalf the action should be done. To use this option the caller must
 have been granted both `Submit` and `Submit (On Behalf Of)` permissions.
 The user named by `on_behalf_of` does not need to be granted the `Submit`
 permission. This feature is aimed for CI solutions: the CI account can be
-granted both permssions, so individual users don't need `Submit` permission
-themselves. Still the changes can be submited on behalf of real users and
+granted both permissions, so individual users don't need `Submit` permission
+themselves. Still the changes can be submitted on behalf of real users and
 not with the identity of the CI account.
 |==========================
 
@@ -4655,8 +5303,17 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name      ||Description
-|`wait_for_merge`|Deprecated, always `true`|
-Whether the request should wait for the merge to complete.
+|`on_behalf_of`|optional|
+If set, submit the change on behalf of the given user. The value may take any
+format link:rest-api-accounts.html#account-id[accepted by the accounts REST
+API]. Using this option requires
+link:access-control.html#category_submit_on_behalf_of[Submit (On Behalf Of)]
+permission on the branch.
+|`notify`|optional|
+Notify handling that defines to whom email notifications should be sent after
+the change is submitted. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
 |===========================
 
 [[submit-record]]
@@ -4694,6 +5351,23 @@
 the failure of the rule predicate.
 |===========================
 
+[[submitted-together-info]]
+=== SubmittedTogetherInfo
+The `SubmittedTogetherInfo` entity contains information about a
+collection of changes that would be submitted together.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name           |Description
+|`changes`            |
+A list of ChangeInfo entities representing the changes to be submitted together.
+|`non_visible_changes`|
+The number of changes to be submitted together that the current user
+cannot see. (This count includes changes that are visible to the
+current user when their reason for being submitted together involves
+changes the user cannot see.)
+|===========================
+
 [[suggested-reviewer-info]]
 === SuggestedReviewerInfo
 The `SuggestedReviewerInfo` entity contains information about a reviewer
@@ -4704,6 +5378,25 @@
 the `group` field that contains the
 link:rest-api-changes.html#group-base-info[GroupBaseInfo] entity.
 
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`account`     |optional|
+An link:rest-api-accounts.html#account-info[AccountInfo] entity, if the
+suggestion is an account.
+|`group`       |optional|
+A link:rest-api-changes.html#group-base-info[GroupBaseInfo] entity, if the
+suggestion is a group.
+|`count`       ||
+The total number of accounts in the suggestion. This is `1` if `account` is
+present. If `group` is present, the total number of accounts that are
+members of the group is returned (this count includes members of nested
+groups).
+|`confirm`     |optional|
+True if `group` is present and `count` is above the threshold where the
+`confirmed` flag must be passed to add the group as a reviewer.
+|===========================
+
 [[topic-input]]
 === TopicInput
 The `TopicInput` entity contains information for setting a topic.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index b1b795c..7b96a1c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -115,6 +115,7 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
+      "doc_search": true
     },
     "sshd": {},
     "suggest": {
@@ -935,6 +936,258 @@
   ]
 ----
 
+[[get-user-preferences]]
+=== Get Default User Preferences
+--
+'GET /config/server/preferences'
+--
+
+Returns the default user preferences for the server.
+
+.Request
+----
+  GET /a/config/server/preferences HTTP/1.0
+----
+
+As response a link:rest-api-accounts.html#preferences-info[
+PreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "changes_per_page": 25,
+    "show_site_header": true,
+    "use_flash_clipboard": true,
+    "download_command": "CHECKOUT",
+    "date_format": "STD",
+    "time_format": "HHMM_12",
+    "diff_view": "SIDE_BY_SIDE",
+    "size_bar_in_change_table": true,
+    "review_category_strategy": "NONE",
+    "mute_common_path_prefixes": true,
+    "my": [
+      {
+        "url": "#/dashboard/self",
+        "name": "Changes"
+      },
+      {
+        "url": "#/q/owner:self+is:draft",
+        "name": "Drafts"
+      },
+      {
+        "url": "#/q/has:draft",
+        "name": "Draft Comments"
+      },
+      {
+        "url": "#/q/has:edit",
+        "name": "Edits"
+      },
+      {
+        "url": "#/q/is:watched+is:open",
+        "name": "Watched Changes"
+      },
+      {
+        "url": "#/q/is:starred",
+        "name": "Starred Changes"
+      },
+      {
+        "url": "#/groups/self",
+        "name": "Groups"
+      }
+    ],
+    "email_strategy": "ENABLED"
+  }
+----
+
+[[set-user-preferences]]
+=== Set Default User Preferences
+
+--
+'PUT /config/server/preferences'
+--
+
+Sets the default user preferences for the server.
+
+The new user preferences must be provided in the request body as a
+link:rest-api-accounts.html#preferences-input[PreferencesInput]
+entity.
+
+To be allowed to set default preferences, a user must be a member of
+a group that is granted the
+link:access-control.html#capability_administrateServer[
+Administrate Server] capability.
+
+.Request
+----
+  PUT /a/config/server/preferences HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "changes_per_page": 50
+  }
+----
+
+As response a link:rest-api-accounts.html#preferences-info[
+PreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "changes_per_page": 50,
+    "show_site_header": true,
+    "use_flash_clipboard": true,
+    "download_command": "CHECKOUT",
+    "date_format": "STD",
+    "time_format": "HHMM_12",
+    "diff_view": "SIDE_BY_SIDE",
+    "size_bar_in_change_table": true,
+    "review_category_strategy": "NONE",
+    "mute_common_path_prefixes": true,
+    "my": [
+      {
+        "url": "#/dashboard/self",
+        "name": "Changes"
+      },
+      {
+        "url": "#/q/owner:self+is:draft",
+        "name": "Drafts"
+      },
+      {
+        "url": "#/q/has:draft",
+        "name": "Draft Comments"
+      },
+      {
+        "url": "#/q/has:edit",
+        "name": "Edits"
+      },
+      {
+        "url": "#/q/is:watched+is:open",
+        "name": "Watched Changes"
+      },
+      {
+        "url": "#/q/is:starred",
+        "name": "Starred Changes"
+      },
+      {
+        "url": "#/groups/self",
+        "name": "Groups"
+      }
+    ],
+    "email_strategy": "ENABLED"
+  }
+----
+
+[[get-diff-preferences]]
+=== Get Default Diff Preferences
+
+--
+'GET /config/server/preferences.diff'
+--
+
+Returns the default diff preferences for the server.
+
+.Request
+----
+  GET /a/config/server/preferences.diff HTTP/1.0
+----
+
+As response a link:rest-api-accounts.html#diff-preferences-info[
+DiffPreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "context": 10,
+    "tab_size": 8,
+    "line_length": 100,
+    "cursor_blink_rate": 0,
+    "intraline_difference": true,
+    "show_line_endings": true,
+    "show_tabs": true,
+    "show_whitespace_errors": true,
+    "syntax_highlighting": true,
+    "auto_hide_diff_table_header": true,
+    "theme": "DEFAULT",
+    "ignore_whitespace": "IGNORE_NONE"
+  }
+----
+
+[[set-diff-preferences]]
+=== Set Default Diff Preferences
+
+--
+'PUT /config/server/preferences.diff'
+--
+
+Sets the default diff preferences for the server.
+
+The new diff preferences must be provided in the request body as a
+link:rest-api-accounts.html#diff-preferences-input[
+DiffPreferencesInput] entity.
+
+To be allowed to set default diff preferences, a user must be a member
+of a group that is granted the
+link:access-control.html#capability_administrateServer[
+Administrate Server] capability.
+
+.Request
+----
+  PUT /a/config/server/preferences.diff HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "context": 10,
+    "tab_size": 8,
+    "line_length": 80,
+    "cursor_blink_rate": 0,
+    "intraline_difference": true,
+    "show_line_endings": true,
+    "show_tabs": true,
+    "show_whitespace_errors": true,
+    "syntax_highlighting": true,
+    "auto_hide_diff_table_header": true,
+    "theme": "DEFAULT",
+    "ignore_whitespace": "IGNORE_NONE"
+  }
+----
+
+As response a link:rest-api-accounts.html#diff-preferences-info[
+DiffPreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "context": 10,
+    "tab_size": 8,
+    "line_length": 80,
+    "cursor_blink_rate": 0,
+    "intraline_difference": true,
+    "show_line_endings": true,
+    "show_tabs": true,
+    "show_whitespace_errors": true,
+    "syntax_highlighting": true,
+    "auto_hide_diff_table_header": true,
+    "theme": "DEFAULT",
+    "ignore_whitespace": "IGNORE_NONE"
+  }
+----
+
 
 [[ids]]
 == IDs
@@ -1008,6 +1261,12 @@
 is used for Git over HTTP/HTTPS]. Only set if
 link:config-gerrit.html#auth.type[authentication type] is is `LDAP` or
 `LDAP_BIND`.
+|`git_basic_auth_policy`      |optional|
+The link:config-gerrit.html#auth.gitBasicAuthPolicy[policy] to authenticate
+Git over HTTP and REST API requests when
+link:config-gerrit.html#auth.type[authentication type] is `LDAP` and
+link:config-gerrit.html#auth.gitBasicAuth[basic authentication] is set to true.
+Can be `HTTP`, `LDAP` or `HTTP_LDAP`.
 |==========================================
 
 [[cache-info]]
@@ -1075,6 +1334,9 @@
 [options="header",cols="1,^1,5"]
 |=============================
 |Field Name           ||Description
+|`allow_blame`        |not set if `false`|
+link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
+allowed].
 |`allow_drafts`       |not set if `false`|
 link:config-gerrit.html#change.allowDrafts[Whether draft workflow is
 allowed].
@@ -1189,6 +1451,8 @@
 |`all_users_name`    ||
 Name of the link:config-gerrit.html#gerrit.allUsers[project in which
 meta data of all users is stored].
+|`doc_search`        ||
+Whether documentation search is available.
 |`doc_url`           |optional|
 Custom base URL where Gerrit server documentation is located.
 (Documentation may still be available at /Documentation relative to the
@@ -1202,54 +1466,6 @@
 bugs link].
 |=================================
 
-[[git-web-info]]
-=== GitwebInfo
-The `GitwebInfo` entity contains information about the
-link:config-gerrit.html#gitweb[gitweb] configuration.
-
-[options="header",cols="1,6"]
-|=======================
-|Field Name |Description
-|`url`      |
-The link:config-gerrit.html#gitweb.url[gitweb base URL].
-|`type`     |
-The link:config-gerrit.html#gitweb.type[gitweb type] as
-link:#git-web-type-info[GitwebTypeInfo] entity.
-|=======================
-
-[[git-web-type-info]]
-=== GitwebTypeInfo
-The `GitwebTypeInfo` entity contains information about the
-link:config-gerrit.html#gitweb[gitweb] configuration.
-
-[options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`name`          ||
-The link:config-gerrit.html#gitweb.linkname[gitweb link name].
-|`revision`      |optional|
-The link:config-gerrit.html#gitweb.revision[gitweb revision pattern].
-|`project`       |optional|
-The link:config-gerrit.html#gitweb.project[gitweb project pattern].
-|`branch`        |optional|
-The link:config-gerrit.html#gitweb.branch[gitweb branch pattern].
-|`root_tree`     |optional|
-The link:config-gerrit.html#gitweb.roottree[gitweb root tree pattern].
-|`file`          |optional|
-The link:config-gerrit.html#gitweb.file[gitweb file pattern].
-|`file_history`  |optional|
-The link:config-gerrit.html#gitweb.filehistory[gitweb file history
-pattern].
-|`path_separator`||
-The link:config-gerrit.html#gitweb.pathSeparator[gitweb path separator].
-|`link_drafts`   |optional|
-link:config-gerrit.html#gitweb.linkDrafts[Whether Gerrit should provide
-links to gitweb on draft patch set.]
-|`url_encode`    |optional|
-link:config-gerrit.html#gitweb.urlEncode[Whether Gerrit should encode
-the generated viewer URL.]
-|=============================
-
 [[hit-ration-info]]
 === HitRatioInfo
 The `HitRatioInfo` entity contains information about the hit ratio of a
@@ -1361,8 +1577,8 @@
 Information about the configuration from the
 link:config-gerrit.html#gerrit[gerrit] section as link:#gerrit-info[
 GerritInfo] entity.
-|`gitweb `                 |optional|
-Information about the link:config-gerrit.html#gitweb[gitweb]
+|`note_db_enabled`         |not set if `false`|
+Whether the NoteDb storage backend is fully enabled.
 |`plugin `                 ||
 Information about Gerrit extensions by plugins as
 link:#plugin-config-info[PluginConfigInfo] entity.
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index e0df4ca..23d4c5b 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -174,13 +174,12 @@
 
 [[suggest-group]]
 ==== Suggest Group
-The `suggest` option indicates a user-entered string that
+The `suggest` or `s` option indicates a user-entered string that
 should be auto-completed to group names.
 If this option is set and `n` is not set, then `n` defaults to 10.
 
-When using this option,
-the `project` or `p` option can be used to name the current project,
-to allow context-dependent suggestions.
+When using this option, the `project` or `p` option can be used to
+name the current project, to allow context-dependent suggestions.
 
 Not compatible with `visible-to-all`, `owned`, `user`, `match`, `q`,
 or `S`.
@@ -1315,10 +1314,12 @@
 |`visible_to_all`|optional|
 Whether the group is visible to all registered users. +
 `false` if not set.
-|`owner_id`|optional|The URL encoded ID of the owner group. +
+|`owner_id`      |optional|The URL encoded ID of the owner group. +
 This can be a group UUID, a legacy numeric group ID or a unique group
 name. +
 If not set, the new group will be self-owned.
+|`members`       |optional|The initial members in a list of +
+link:#account-id[account ids].
 |===========================
 
 [[group-options-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 986ccd8..457a287 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -733,6 +733,7 @@
     "create_new_change_for_all_not_in_target": "INHERIT",
     "enable_signed_push": "INHERIT",
     "require_signed_push": "INHERIT",
+    "reject_implicit_merges": "INHERIT",
     "require_change_id": "TRUE",
     "max_object_size_limit": "10m",
     "submit_type": "REBASE_IF_NECESSARY",
@@ -786,6 +787,11 @@
       "configured_value": "INHERIT",
       "inherited_value": false
     },
+    "reject_implicit_merges": {
+      "value": false,
+      "configured_value": "INHERIT",
+      "inherited_value": false
+    },
     "max_object_size_limit": {
       "value": "10m",
       "configured_value": "10m",
@@ -844,6 +850,34 @@
   done.
 ----
 
+==== Asynchronous Execution
+
+The option `async` allows to schedule a background task that asynchronously
+executes a Git garbage collection.
+
+The `Location` header of the response refers to the link:rest-api-config.html#get-task[background task]
+which allows to inspect the progress of its execution. In case of asynchronous
+execution the `show_progress` option is ignored.
+
+.Request
+----
+  POST /projects/plugins%2Freplication/gc HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "async": true
+  }
+----
+
+The response is empty.
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+  Location: https:<host>/a/config/server/tasks/383a0602
+----
+
 [[ban-commit]]
 === Ban Commit
 --
@@ -895,6 +929,143 @@
   }
 ----
 
+[[get-access]]
+=== List Access Rights for Project
+--
+'GET /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access'
+--
+
+Lists the access rights for a single project.
+
+As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+
+.Request
+----
+  GET /projects/MyProject/access HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
+    "inherits_from": {
+      "id": "All-Projects",
+      "name": "All-Projects",
+      "description": "Access inherited by all other projects."
+    },
+    "local": {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                  "action": "ALLOW",
+                  "force": false
+                },
+                "global:Anonymous-Users": {
+                  "action": "ALLOW",
+                  "force": false
+                }
+              }
+            }
+          }
+        }
+    },
+    "is_owner": true,
+    "owner_of": [
+      "refs/*"
+    ],
+    "can_upload": true,
+    "can_add": true,
+    "config_visible": true
+  }
+----
+
+[[set-access]]
+=== Add, Update and Delete Access Rights for Project
+--
+'POST /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access'
+--
+
+Sets access rights for the project using the diff schema provided by
+link:#project-access-input[ProjectAccessInput]. Deductions are used to
+remove access sections, permissions or permission rules. The backend will remove
+the entity with the finest granularity in the request, meaning that if an
+access section without permissions is posted, the access section will be
+removed; if an access section with a permission but no permission rules is
+posted, the permission will be removed; if an access section with a permission
+and a permission rule is posted, the permission rule will be removed.
+
+Additionally, access sections and permissions will be cleaned up after applying
+the deductions by removing items that have no child elements.
+
+After removals have been applied, additions will be applied.
+
+As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+
+.Request
+----
+  POST /projects/MyProject/access HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "remove": [
+      "refs/*": {
+        "permissions": {
+          "read": {
+            "rules": {
+              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                "action": "ALLOW"
+              }
+            }
+          }
+        }
+      }
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
+    "inherits_from": {
+      "id": "All-Projects",
+      "name": "All-Projects",
+      "description": "Access inherited by all other projects."
+    },
+    "local": {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "global:Anonymous-Users": {
+                  "action": "ALLOW",
+                  "force": false
+                }
+              }
+            }
+          }
+        }
+    },
+    "is_owner": true,
+    "owner_of": [
+      "refs/*"
+    ],
+    "can_upload": true,
+    "can_add": true,
+    "config_visible": true
+  }
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -1189,6 +1360,101 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+
+[[get-mergeable-info]]
+=== Get Mergeable Information
+--
+'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/mergeable'
+--
+
+Gets whether the source is mergeable with the target branch.
+
+The `source` query parameter is required, which can be anything that could be
+resolved to a commit, see examples of the `source` attribute in
+link:rest-api-changes.html#merge-input[MergeInput].
+
+Also takes an optional parameter `strategy`, which can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
+
+.Request
+----
+  GET /projects/test/branches/master/mergeable?source=testbranch&strategy=recursive HTTP/1.0
+----
+
+As response a link:rest-api-changes.html#mergeable-info[MergeableInfo] entity is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "submit_type": "MERGE_IF_NECESSARY",
+    "strategy": "recursive",
+    "mergeable": true,
+    "commit_merged": false,
+    "content_merged": false
+  }
+----
+
+or when there were conflicts.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "submit_type": "MERGE_IF_NECESSARY",
+    "strategy": "recursive",
+    "mergeable": false,
+    "conflicts": [
+      "common.txt",
+      "shared.txt"
+    ]
+  }
+----
+
+or when the 'testbranch' has been already merged.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "submit_type": "MERGE_IF_NECESSARY",
+    "strategy": "recursive",
+    "mergeable": true,
+    "commit_merged": true,
+    "content_merged": true
+  }
+----
+
+or when only the content of 'testbranch' has been merged.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "submit_type": "MERGE_IF_NECESSARY",
+    "strategy": "recursive",
+    "mergeable": true,
+    "commit_merged": false,
+    "content_merged": true
+  }
+----
+
 [[get-reflog]]
 === Get Reflog
 --
@@ -1403,6 +1669,57 @@
 [[tag-endpoints]]
 == Tag Endpoints
 
+[[create-tag]]
+=== Create Tag
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/tags/link:#tag-id[\{tag-id\}]'
+--
+
+Create a new tag on the project.
+
+In the request body additional data for the tag can be provided as
+link:#tag-input[TagInput].
+
+If a message is provided in the input, the tag is created as an
+annotated tag with the current user as tagger. Signed tags are not
+supported.
+
+.Request
+----
+  PUT /projects/MyProject/tags/v1.0 HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "annotation",
+    "revision": "c83117624b5b5d8a7f86093824e2f9c1ed309d63"
+  }
+----
+
+As response a link:#tag-info[TagInfo] entity is returned that describes
+the created tag.
+
+.Response
+----
+  HTTP/1.1 201 Created
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+
+  "object": "d48d304adc4b6674e11dc2c018ea05fcbdda32fd",
+  "message": "annotation",
+  "tagger": {
+    "name": "David Pursehouse",
+    "email": "dpursehouse@collab.net",
+    "date": "2016-06-06 01:22:03.000000000",
+    "tz": 540
+  },
+  "ref": "refs/tags/v1.0",
+  "revision": "c83117624b5b5d8a7f86093824e2f9c1ed309d63"
+  }
+----
+
 [[list-tags]]
 === List Tags
 --
@@ -1988,14 +2305,15 @@
 valid link:user-changeid.html[Change-Id] footer in any commit uploaded
 for review is required. This does not apply to commits pushed directly
 to a branch or tag.
-|`enable_signed_push`|
-optional, not set if signed push is disabled|
+|`enable_signed_push`|optional, not set if signed push is disabled|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 signed push validation is enabled on the project.
-|`require_signed_push`|
-optional, not set if signed push is disabled
+|`require_signed_push`|optional, not set if signed push is disabled|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 signed push validation is required on the project.
+|`reject_implicit_merges`|optional|
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+implicit merges should be rejected on changes pushed to the project.
 |`max_object_size_limit`     ||
 The link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] of this project as a link:#max-object-size-limit-info[
@@ -2064,6 +2382,11 @@
 directly to a branch or tag. +
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
+|`reject_implicit_merges`                  |optional|
+Whether a check for implicit merges will be performed when changes
+are pushed for review. +
+Can be `TRUE`, `FALSE` or `INHERIT`. +
+If not set, this setting is not updated.
 |`max_object_size_limit`                   |optional|
 The link:config-gerrit.html#receive.maxObjectSizeLimit[max object size
 limit] of this project as a link:#max-object-size-limit-info[
@@ -2108,7 +2431,7 @@
 `configured_value` and `inherited_value`.
 |`values`          |optional|
 The list of values. Only set if the `type` is `ARRAY`.
-`editable`         |`false` if not set|
+|`editable`         |`false` if not set|
 Whether the value is editable.
 |`permitted_values`|optional|
 The list of permitted values. Only set if the `type` is `LIST`.
@@ -2212,6 +2535,8 @@
 Whether progress information should be shown.
 |`aggressive`    |`false` if not set|
 Whether an aggressive garbage collection should be done.
+|`async`         |`false` if not set|
+Whether the garbage collection should run asynchronously.
 |=============================
 
 [[head-input]]
@@ -2265,6 +2590,27 @@
 Not set if there is no global limit for the object size.
 |===============================
 
+[[project-access-input]]
+=== ProjectAccessInput
+The `ProjectAccessInput` describes changes that should be applied to a project
+access config.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name          |        |Description
+|`remove`            |optional|
+A list of deductions to be applied to the project access as
+link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities.
+|`add`               |optional|
+A list of additions to be applied to the project access as
+link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities.
+|`message`           |optional|
+A commit message for this change.
+|`parent`            |optional|
+A new parent for the project to inherit from. Changing the parent project
+requires administrative privileges.
+|=============================
+
 [[project-description-input]]
 === ProjectDescriptionInput
 The `ProjectDescriptionInput` entity contains information for setting a
@@ -2429,6 +2775,22 @@
 link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
 |=========================
 
+[[tag-input]]
+=== TagInput
+
+The `TagInput` entity contains information for creating a tag.
+
+[options="header",cols="1,^2,4"]
+|=========================
+|Field Name  ||Description
+|`ref`       ||The name of the tag. The leading `refs/tags/` is optional.
+|`revision`  |optional|The revision to which the tag should point. If not
+specified, the project's `HEAD` will be used.
+|`message`   |optional|The tag message. When set, the tag will be created
+as an annotated tag.
+|=========================
+
+
 [[theme-info]]
 === ThemeInfo
 The `ThemeInfo` entity describes a theme.
@@ -2444,6 +2806,7 @@
 The path to the `GerritSiteFooter.html` file.
 |=============================
 
+----
 
 GERRIT
 ------
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index a87f5b6..7f7e62e 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -97,6 +97,9 @@
 in the link:http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html[
 HTTP specification].
 
+In most cases, the response body of an error response will be a
+plaintext, human-readable error message.
+
 Here are examples that show how HTTP status codes are used in the
 context of the Gerrit REST API.
 
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index 05932df..f3c8b00 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -173,7 +173,7 @@
 change it, save it, close edit screen and select next file from the file table to edit.
 "<-" | "->" icons in header of edit screen could be used to navigate to the next file to
 change from the file table. This would behave like the navigation icons in side by side
-with thefollowing logic on click:
+with the following logic on click:
 
 ** "save-when-file-was-changed" or
 ** "close-when-no-changes"
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 9dffa51..4dc4880 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -45,24 +45,24 @@
 details on how access permissions work.
 
 Initialize a temporary Git repository to edit the configuration:
-====
+----
   mkdir cfg_dir
   cd cfg_dir
   git init
-====
+----
 
 Download the existing configuration from Gerrit:
-====
+----
   git fetch ssh://localhost:29418/project refs/meta/config
   git checkout FETCH_HEAD
-====
+----
 
 Enable notifications to an email address by adding to
 `project.config`, this can be done using the `git config` command:
-====
+----
   git config -f project.config --add notify.team.email team-address@example.com
   git config -f project.config --add notify.team.email paranoid-manager@example.com
-====
+----
 
 Examining the project.config file with any text editor should show
 a new notify section describing the email addresses to deliver to:
@@ -79,10 +79,10 @@
 if different filters are needed.
 
 Commit the configuration change, and push it back:
-====
+----
   git commit -a -m "Notify team-address@example.com of changes"
   git push ssh://localhost:29418/project HEAD:refs/meta/config
-====
+----
 
 [[notify.name.email]]notify.<name>.email::
 +
@@ -132,11 +132,11 @@
 security filtering by adding the `visibleto:groupname` predicate to
 the filter expression, for example:
 
-====
+----
   [notify "Developers"]
   	email = team-address@example.com
   	filter = visibleto:Developers
-====
+----
 
 When sending email to an internal group, the internal group's read
 access is automatically checked by Gerrit and therefore does not
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 859765c..838a433 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -187,6 +187,19 @@
 the link:access-control.html#category_submit[Submit] access right is
 assigned.
 
+** [[revert]]`Revert`:
++
+Reverts the change via creating a new one.
++
+The `Revert` button is available if the change has been submitted.
++
+When the `Revert` button is pressed, a panel will appear to allow
+the user to enter a commit message for the reverting change.
++
+Once a revert change is created, the original author and any reviewers
+of the original change are added as reviewers and a message is posted
+to the original change linking to the revert.
+
 ** [[abandon]]`Abandon`:
 +
 Abandons the change.
@@ -387,6 +400,10 @@
 
 image::images/user-review-ui-change-screen-patch-sets.png[width=800, link="images/user-review-ui-change-screen-patch-sets.png"]
 
+Another indication is a highlighted drop-down label.
+
+image::images/user-review-ui-change-screen-not-current.png[width=800, link="images/user-review-ui-change-screen-not-current.png"]
+
 [[patch-set-drop-down]]
 The patch set drop-down list shows the list of patch sets and allows to
 switch between them. The patch sets are sorted in descending order so
@@ -591,8 +608,12 @@
 
 Clicking on the `Reply...` button opens a popup panel.
 
+[[summary-comment]]
 A text box allows to type a summary comment for the currently viewed
-patch set.
+patch set. Some basic markdown-like syntax is supported which renders
+indented lines preformatted, lines starting with "- " or "* " as list
+items, and lines starting with "> " as block quotes (also see replying to
+link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
 
 Note that you can set the text and tooltip of the button in
 link:config-gerrit.html#change.replyLabel[gerrit.config].
@@ -649,7 +670,7 @@
 reply icon in the right upper corner of a change message. This opens
 the reply popup panel and prefills the text box with the quoted comment.
 Then the reply can be written below the quoted comment or inserted
-inline. Lines starting with " > " will be rendered as a block quote.
+inline. Lines starting with "> " will be rendered as a block quote.
 Please note that for a correct rendering it is important to leave a blank
 line between a quoted block and the reply to it.
 
@@ -818,7 +839,7 @@
 Clicking on the `Reply` button opens an editor to type the reply.
 
 Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by " > ". Please
+comment that should be quoted and prefixing every line by "> ". Please
 note that for a correct rendering it is important to leave a blank line
 between a quoted block and the reply to it.
 
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
new file mode 100644
index 0000000..15d87b0
--- /dev/null
+++ b/Documentation/user-search-accounts.txt
@@ -0,0 +1,83 @@
+= Gerrit Code Review - Searching Accounts
+
+== Basic Change Search
+
+Similar to many popular search engines on the web, just enter some
+text and let Gerrit figure out the meaning:
+
+[options="header"]
+|=============================================================
+|Description                      | Examples
+|Name                             | John
+|Email address                    | jdoe@example.com
+|Username                         | jdoe
+|Account-Id                       | 1000096
+|Own account                      | self
+|=============================================================
+
+[[search-operators]]
+== Search Operators
+
+Operators act as restrictions on the search. As more operators
+are added to the same query string, they further restrict the
+returned results. Search can also be performed by typing only a
+text with no operator, which will match against a variety of fields.
+
+[[email]]
+email:'EMAIL'::
++
+Matches accounts that have the email address 'EMAIL' or an email
+address that starts with 'EMAIL'.
+
+[[is]]
+[[is-active]]
+is:active::
++
+Matches accounts that are active.
+
+[[is-inactive]]
+is:inactive::
++
+Matches accounts that are inactive.
+
+[[name]]
+name:'NAME'::
++
+Matches accounts that have any name part 'NAME'. The name parts consist
+of any part of the full name and the email addresses.
+
+[[username]]
+username:'USERNAME'::
++
+Matches accounts that have the username 'USERNAME'.
+
+== Magical Operators
+
+[[is-visible]]
+is:visible::
++
+Magical internal flag to prove the current user has access to read
+the change. This flag is always added to any query.
+
+[[is-active-magic]]
+is:active::
++
+Matches accounts that are active. If neither link:#is-active[is:active]
+nor link:#is-inactive[is:inactive] is contained in a query, `is:active`
+is automatically added so that by default only active accounts are
+matched.
+
+[[limit]]
+limit:'CNT'::
++
+Limit the returned results to no more than 'CNT' records. This is
+automatically set to the page size configured in the current user's
+preferences. Including it in a web query may lead to unpredictable
+results with regards to pagination.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index b2b3614..b04898e 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -247,25 +247,43 @@
 Regular expression matching can be enabled by starting the string
 with `^`. In this mode `file:` is an alias of `path:` (see above).
 
+[[star]]
+star:'LABEL'::
++
+Matches any change that was starred by the current user with the label
+'LABEL'.
++
+E.g. if changes that are not interesting are marked with an `ignore`
+star, they could be filtered out by '-star:ignore'.
++
+'star:star' is the same as 'has:star' and 'is:starred'.
+
 [[has]]
 has:draft::
 +
 True if there is a draft comment saved by the current user.
 
+[[has-star]]
 has:star::
 +
-Same as 'is:starred', true if the change has been starred by the
-current user.
+Same as 'is:starred' and 'star:star', true if the change has been
+starred by the current user with the default label.
+
+[[has-stars]]
+has:stars::
++
+True if the change has been starred by the current user with any label.
 
 has:edit::
 +
 True if the change has inline edit created by the current user.
 
 [[is]]
+[[is-starred]]
 is:starred::
 +
 Same as 'has:star', true if the change has been starred by the
-current user.
+current user with the default label.
 
 is:watched::
 +
@@ -510,7 +528,7 @@
 
 starredby:'USER'::
 +
-Matches changes that have been starred by 'USER'.
+Matches changes that have been starred by 'USER' with the default label.
 The special case `starredby:self` applies to the caller.
 
 watchedby:'USER'::
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt
index 151ac71..2754b45 100644
--- a/Documentation/user-submodules.txt
+++ b/Documentation/user-submodules.txt
@@ -1,78 +1,127 @@
 = Gerrit Code Review - Superproject subscription to submodules updates
 
+[[automatic_update]]
 == Description
-
 Gerrit supports a custom git superproject feature for tracking submodules.
 This feature is useful for automatic updates on superprojects whenever
-a change is merged on tracked submodules. To take advantage of this
-feature, one should add submodule(s) to a local working copy of a
-superproject, edit the created .gitmodules configuration file to
-have a branch field on each submodule section with the value of the
-submodule branch it is subscribing to, commit the changes, push and
-merge the commit.
+a change is merged on tracked submodules.
 
-When a commit is merged to a project, the commit content is scanned
-to identify if it registers git submodules (if the commit registers
-any gitlinks and .gitmodules file with required info) and if so,
-a new submodule subscription is registered.
+When a superproject is subscribed to a submodule, it is not
+required to push/merge commits to this superproject to update the
+gitlink to the submodule. Whenever a commit is merged in a submodule,
+its subscribed superproject is updated by Gerrit.
 
-When a new commit of a registered submodule is merged, Gerrit
-automatically updates the subscribers to the submodule with a new
-commit having the updated gitlinks.
+Imagine a superproject called 'super' having a branch called 'dev'
+having subscribed to a submodule 'sub' on a branch 'dev-of-sub'. When a commit
+is merged in branch 'dev-of-sub' of 'sub' project, Gerrit automatically
+creates a new commit on branch 'dev' of 'super' updating the gitlink
+to point to the just merged commit.
 
-== Git Submodules Overview
+To take advantage of this feature, one should:
 
-Submodules are a git feature that allows an external repository to be
+. ensure superproject subscriptions are enabled on the server via
+  link:config-gerrit.html#submodule.enableSuperProjectSubscriptions[submodule.enableSuperProjectSubscriptions]
+. configure the submodule to allow having a superproject subscribed
+. ensure the .gitmodules file of the superproject includes
+.. a branch field
+.. a url that starts with the link:config-gerrit.html#gerrit.canonicalWebUrl[`gerrit.canonicalWebUrl`]
+
+When a commit in a project is merged, Gerrit checks for superprojects
+that are subscribed to the the project and automatically updates those
+superprojects with a commit that updates the gilink for the project.
+
+This feature is enabled by default and can be disabled
+via link:config-gerrit.html#submodule.enableSuperProjectSubscriptions[submodule.enableSuperProjectSubscriptions]
+in the server configuration.
+
+== Git submodules overview
+
+Submodules are a Git feature that allows an external repository to be
 attached inside a repository at a specific path. The objective here
 is to provide a brief overview, further details can be found
-in the official git submodule command documentation.
+in the official Git submodule documentation.
 
-Imagine a repository called 'super' and another one called 'a'.
-Also consider 'a' available in a running Gerrit instance on "server".
-With this feature, one could attach 'a' inside of 'super' repository
-at path 'a' by executing the following command when being inside
+Imagine a repository called 'super' and another one called 'sub'.
+Also consider 'sub' available in a running Gerrit instance on "server".
+With this feature, one could attach 'sub' inside of 'super' repository
+at path 'sub' by executing the following command when being inside
 'super':
-=====
-git submodule add ssh://server/a a
-=====
+----
+git submodule add ssh://server/sub sub
+----
 
 Still considering the above example, after its execution notice that
-inside the local repository 'super' the 'a' folder is considered a
-gitlink to the external repository 'a'. Also notice a file called
+inside the local repository 'super' the 'sub' folder is considered a
+gitlink to the external repository 'sub'. Also notice a file called
 .gitmodules is created (it is a configuration file containing the
-subscription of 'a'). To provide the SHA-1 each gitlink points to in
+subscription of 'sub'). To provide the SHA-1 each gitlink points to in
 the external repository, one should use the command:
-====
+----
 git submodule status
-====
+----
 
-In the example provided, if 'a' is updated and 'super' is supposed
-to see the latest SHA-1 (considering here 'a' has only the master
-branch), one should then commit the modified gitlink for 'a' in
+In the example provided, if 'sub' is updated and 'super' is supposed
+to see the latest SHA-1 (considering here 'sub' has only the master
+branch), one should then commit the modified gitlink for 'sub' in
 the 'super' project. Actually it would not even need to be an
-external update, one could move to 'a' folder (insider 'super'),
+external update, one could move to 'sub' folder (inside 'super'),
 modify its content, commit, then move back to 'super' and
-commit the modified gitlink for 'a'.
+commit the modified gitlink for 'sub'.
 
-== Creating a New Subscription
+== Creating a new subscription
 
-=== Defining the Submodule Branch
+=== Ensure the subscription is allowed
 
-This is required because submodule subscription is actually the
-subscription of a submodule project and one of its branches for
-a branch of a super project.
+Gerrit has a complex access control system, where different repositories
+can be accessed by different groups of people. To ensure that the submodule
+related information is allowed to be exposed in the superproject,
+the submodule needs to be configured to enable the superproject subscription.
+In a submodule client, checkout the refs/meta/config branch and edit
+the subscribe capabilities in the 'project.config' file:
+----
+    git fetch <remote> refs/meta/config:refs/meta/config
+    git checkout refs/meta/config
+    $EDITOR project.config
+----
+and add the following lines:
+----
+  [allowSuperproject "<superproject>"]
+    matching = <refspec>
+----
+where the 'superproject' should be the exact project name of the superproject.
+The refspec defines which branches of the submodule are allowed to be
+subscribed to which branches of the superproject. See below for
+link:#acl_refspec[details]. Push the configuration for review and
+submit the change:
+----
+  git add project.config
+  git commit -m "Allow <superproject> to subscribe"
+  git push <remote> HEAD:refs/for/refs/meta/config
+----
+After the change is integrated a superproject subscription is possible.
+
+The configuration is inherited from parent projects, such that you can have
+a configuration in the "All-Projects" project like:
+----
+    [allowSuperproject "my-only-superproject"]
+        matching = refs/heads/*:refs/heads/*
+----
+and then you don't have to worry about configuring the individual projects
+any more. Child projects cannot negate the parent's configuration.
+
+=== Defining the submodule branch
 
 Since Gerrit manages subscriptions in the branch scope, we could have
 a scenario having a project called 'super' having a branch 'integration'
-subscribed to a project called 'a' in branch 'integration', and also
-having the same 'super' project but in branch 'dev' subscribed to the 'a'
+subscribed to a project called 'sub' in branch 'integration', and also
+having the same 'super' project but in branch 'dev' subscribed to the 'sub'
 project in a branch called 'local-dev'.
 
 After adding the git submodule to a super project, one should edit
 the .gitmodules file to add a branch field to each submodule
 section which is supposed to be subscribed.
 
-As the branch field is a Gerrit specific field it will not be filled
+As the branch field is a Gerrit-specific field it will not be filled
 automatically by the git submodule command, so one needs to edit it
 manually. Its value should indicate the branch of a submodule project
 that when updated will trigger automatic update of its registered
@@ -90,45 +139,78 @@
 .gitmodules file, Gerrit will not create a subscription for the
 submodule and there will be no automatic updates to the superproject.
 
-=== Detecting and Subscribing Submodules
+Whenever a commit is merged to a project, its project config is checked
+to see if any potential superprojects are allowed to subscribe to it.
+If so, the superproject is checked if a valid subscription exists
+by checking the .gitmodules file for the a submodule which includes
+a `branch` field and a url pointing to this server.
 
-Whenever a commit is merged to a project, its content is scanned
-to identify if it registers any submodules (if the commit contains new
-gitlinks and a .gitmodules file with all required info) and if so,
-a new submodule subscription is registered.
+[[acl_refspec]]
+=== The RefSpec in the allowSuperproject section
+There are two options for specifying which branches can be subscribed
+to. The most common is to set `allowSuperproject.<superproject>.matching`
+to a Git-style refspec, which has the same syntax as the refspecs used
+for pushing in Git. Regular expressions as found in the ACL configuration
+are not supported.
 
-[[automatic_update]]
-== Automatic Update of Superprojects
+The most restrictive refspec is allowing one specific branch of the
+submodule to be subscribed to one specific branch of the superproject:
+----
+  [allowSuperproject "<superproject>"]
+    matching = refs/heads/<submodule-branch>:refs/heads/<superproject-branch>
+----
 
-After a superproject is subscribed to a submodule, it is not
-required to push/merge commits to this superproject to update the
-gitlink to the submodule.
+If you want to allow for a 1:1 mapping, i.e. 'master' maps to 'master',
+'stable' maps to 'stable', but not allowing 'master' to be subscribed to
+'stable':
+----
+  [allowSuperproject "<superproject>"]
+    matching = refs/heads/*:refs/heads/*
+----
 
-Whenever a commit is merged in a submodule, its subscribed superproject
-is updated.
+To allow all refs matching one pattern to subscribe to all refs
+matching another pattern, set `allowSuperproject.<superproject>.all`
+to the patterns concatenated with a colon. For example, to make a
+single branch available for subscription from all branches of the
+superproject:
+----
+  [allowSuperproject "<superproject>"]
+     all = refs/heads/<submodule-branch>:refs/heads/*
+----
 
-Imagine a superproject called 'super' having a branch called 'dev'
-having subscribed to a submodule 'a' on a branch 'dev-of-a'. When a commit
-is merged in branch 'dev-of-a' of 'a' project, Gerrit automatically
-creates a new commit on branch 'dev' of 'super' updating the gitlink
-to point to the just merged commit.
+To make all branches available for subscription from all branches of
+the superproject:
+----
+  [allowSuperproject "<superproject>"]
+     all = refs/heads/*:refs/heads/*
+----
 
 === Subscription Limitations
 
 Gerrit will only automatically update superprojects where the
 submodules are hosted on the same Gerrit instance as the
-superproject. Gerrit determines this by checking the hostname of the
-submodule specified in the .gitmodules file and comparing it to the
-hostname from the canonical web URL.
+superproject. Gerrit determines this by checking that the URL of the
+submodule specified in the .gitmodules file starts with
+link:config-gerrit.html#gerrit.canonicalWebUrl[`gerrit.canonicalWebUrl`].
+The protocol part is ignored in this check.
 
 It is currently not possible to use the submodule subscription feature
-with a canonical web URL hostname that differs from the hostname of
-the submodule. Instead relative submodules should be used.
+with a canonical web URL that differs from the first part  of
+the submodule URL. Instead relative submodules should be used.
 
-The Gerrit instance administrator group should always certify to
-provide the canonical web URL value in its configuration file. Users
-should certify to use the correct hostname of the running Gerrit
-instance to add/subscribe submodules.
+The Gerrit instance administrator should ensure that the canonical web
+URL value is specified in its configuration file. Users should ensure
+that they use the correct hostname of the running Gerrit instance when
+adding submodule subscriptions.
+
+When converting an existing submodule to use subscription by adding
+a `branch` field into the .gitmodules file, Gerrit does not change
+the revision of the submodule (i.e. update the superproject's gitlink)
+until the next time the branch of the submodule advances. In other words,
+if the currently used revision of the submodule is not the branch's head,
+adding a subscription will not cause an immediate update to the head. In
+this case the revision must be manually updated at the same time as adding
+the subscription.
 
 === Relative submodules
 
@@ -170,10 +252,9 @@
 
 == Removing Subscriptions
 
-If one has added a submodule subscription and drops it, it is
-required to merge a commit updating the subscribed super
-project/branch to remove the gitlink and the submodule section
-of the .gitmodules file.
+To remove a subscription, either disable the subscription from the
+submodules configuration or remove the submodule or information thereof
+(such as the branch field) in the superproject.
 
 GERRIT
 ------
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 25665a3..ba3445a 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -19,11 +19,17 @@
 user must authenticate via HTTP/HTTPS.
 
 When link:config-gerrit.html#auth.gitBasicAuth[gitBasicAuth] is enabled,
-the user is authenticated using standard BasicAuth and credentials validated
-using the randomly generated HTTP password on the `HTTP Password` tab
-in the user settings page or against LDAP when configured for the Gerrit Web UI.
+the user is authenticated using standard BasicAuth. Depending on the value of
+link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy], credentials are
+validated using:
 
-When gitBasicAuth is not configured, the user's HTTP credentials can be
+* The randomly generated HTTP password on the `HTTP Password` tab
+  in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
+* The LDAP password if `gitBasicAuthPolicy` is `LDAP`
+* Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
+  is `HTTP_LDAP`.
+
+When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can be
 accessed within Gerrit by going to `Settings`, and then accessing the `HTTP
 Password` tab.
 
@@ -54,16 +60,16 @@
 If you don't have any keys yet, you can create a new one and protect
 it with a passphrase:
 
-====
+----
   ssh-keygen -t rsa
-====
+----
 
 Then copy the content of the public key file onto your clipboard,
 and paste it into Gerrit's web interface:
 
-====
+----
   cat ~/.ssh/id_rsa.pub
-====
+----
 
 [TIP]
 Users who frequently upload changes will also want to consider
@@ -80,8 +86,7 @@
 to connect to Gerrit's SSHD port.  By default Gerrit runs on
 port 29418, using the same hostname as the web server:
 
-====
-..................................................................
+----
   $ ssh -p 29418 sshusername@hostname
 
     ****    Welcome to Gerrit Code Review    ****
@@ -94,8 +99,7 @@
     git clone ssh://sshusername@hostname:29418/REPOSITORY_NAME.git
 
   Connection to hostname closed.
-..................................................................
-====
+----
 
 In the command above, `sshusername` was configured as `Username` on
 the `Profile` tab of the `Settings` screen.  If it is not set,
@@ -105,10 +109,10 @@
 information URL `http://'hostname'/ssh_info`, and copy the port
 number from the second field:
 
-====
+----
   $ curl http://hostname/ssh_info
   hostname 29418
-====
+----
 
 If you are developing an automated tool to perform uploads to Gerrit,
 let the user supply the hostname or the web address for Gerrit,
@@ -125,17 +129,17 @@
 To create new changes for review, simply push to the project's
 magical `refs/for/'branch'` ref using any Git client tool:
 
-====
+----
   git push ssh://sshusername@hostname:29418/projectname HEAD:refs/for/branch
-====
+----
 
 E.g. `john.doe` can use git push to upload new changes for the
 `experimental` branch of project `kernel/common`, hosted at the
 `git.example.com` Gerrit server:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental
-====
+----
 
 Each new commit uploaded by the `git push` client will be
 converted into a change record on the server.  The remote ref
@@ -146,38 +150,89 @@
 notify them of new changes will be automatically sent an email
 message when the push is completed.
 
+[[push_options]]
+=== Push Options
+
+Additional options may be specified when pushing changes.
+
+[[notify]]
+==== Email Notifications
+
+Uploaders can control to whom email notifications are sent by setting
+the `notify` option:
+
+* `NONE`: No email notification will be sent to anyone.
+* `OWNER`: Only the change owner is notified.
+* `OWNER_REVIEWERS`: Only owners and reviewers will be  notified. This
+  includes all reviewers, existing reviewers of the change and new
+  reviewers that are added by the `reviewer` option or by mentioning
+  in the commit message.
+* `ALL`: All email notifications will be sent. This includes
+  notifications to watchers, users that have starred the change, CCs
+  and the committer and author of the uploaded commit.
+
+By default all email notifications are sent.
+
+----
+  git push ssh://bot@git.example.com:29418/kernel/common HEAD:refs/for/master%notify=NONE
+----
+
 [[topic]]
+==== Topic
+
 To include a short tag associated with all of the changes in the
 same group, such as the local topic branch name, append it after
 the destination branch name. In this example the short topic tag
 'driver/i42' will be saved on each change this push creates or
 updates:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%topic=driver/i42
-====
+----
+
+[[message]]
+==== Message
+
+A comment message can be applied to the change by using the `message` (or `m`)
+option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master
+----
+
+[NOTE]
+git push refs parameter does not allow spaces.  Use the '_' character instead,
+it will then be applied as "This is a rebase on master".
 
 [[review_labels]]
+==== Review Labels
+
 Review labels can be applied to the change by using the `label` (or `l`)
 option in the reference:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%l=Verified+1
-====
+----
 
 The `l='label[score]'` option may be specified more than once to
 apply multiple review labels.
 
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%l=Code-Review+1,l=Verified+1
+----
+
 The value is optional.  If not specified, it defaults to +1 (if
 the label range allows it).
 
 [[change_edit]]
+==== Change Edits
+
 A change edit can be pushed by specifying the `edit` (or `e`) option on
 the reference:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%edit
-====
+----
 
 There is at most one change edit per user and change. In order to push
 a change edit the change must already exist.
@@ -193,7 +248,7 @@
 your username, hostname and port number.  This permits the use of
 shorter URLs on the command line, such as:
 
-====
+----
   $ cat ~/.ssh/config
   ...
   Host tr
@@ -202,15 +257,18 @@
     User john.doe
 
   $ git push tr:kernel/common HEAD:refs/for/experimental
-====
+----
+
+[[reviewers]]
+==== Reviewers
 
 Specific reviewers can be requested and/or additional 'carbon
 copies' of the notification message may be sent by including the
 `reviewer` (or `r`) and `cc` options in the reference:
 
-====
+----
   git push tr:kernel/common HEAD:refs/for/experimental%r=a@a.com,cc=b@o.com
-====
+----
 
 The `r='email'` and `cc='email'` options may be specified as many
 times as necessary to cover all interested parties. Gerrit will
@@ -222,7 +280,7 @@
 branches, consider adding a custom remote block to your project's
 `.git/config` file:
 
-====
+----
   $ cat .git/config
   ...
   [remote "exp"]
@@ -230,7 +288,7 @@
     push = HEAD:refs/for/experimental%r=a@a.com,cc=b@o.com
 
   $ git push exp
-====
+----
 
 
 [[push_replace]]
@@ -254,8 +312,8 @@
 [[manual_replacement_mapping]]
 ==== Manual Replacement Mapping
 
-.Note
-****
+[NOTE]
+--
 The remainder of this section describes a manual method of replacing
 changes by matching each commit name to an existing change number.
 End-users should instead prefer to use Change-Id lines in their
@@ -263,7 +321,7 @@
 during normal uploads.
 
 See above for the preferred technique of replacing changes.
-****
+--
 
 To add an additional patch set to a change, replacing it with an
 updated version of the same logical modification, send the new
@@ -271,9 +329,9 @@
 SHA-1 starts with `c0ffee` as a new patch set for change number
 `1979`, use the push refspec `c0ffee:refs/changes/1979` as below:
 
-====
+----
   git push ssh://sshusername@hostname:29418/projectname c0ffee:refs/changes/1979
-====
+----
 
 This form can be combined together with `refs/for/'branchname'`
 (above) to simultaneously create new changes and replace changes
@@ -281,7 +339,7 @@
 
 For example, consider the following sequence of events:
 
-====
+----
   $ git commit -m A                    ; # create 3 commits
   $ git commit -m B
   $ git commit -m C
@@ -298,7 +356,7 @@
       HEAD~3:refs/changes/1500
       HEAD~1:refs/changes/1501
       HEAD~0:refs/changes/1502         ; # upload replacements
-====
+----
 
 At the final step during the push Gerrit will attach A' as a new
 patch set on change 1500; B' as a new patch set on change 1501; C'
@@ -363,18 +421,21 @@
 Changes can be directly submitted on push.  This is primarily useful
 for teams that don't want to do code review but want to use Gerrit's
 submit strategies to handle contention on busy branches.  Using
-`%submit` creates a change and submits it immediately, if the caller
-has link:access-control.html#category_submit[Submit] permission on
-`refs/for/<ref>` (e.g. on `refs/for/refs/heads/master`).
+`%submit` creates a change and submits it immediately:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%submit
-====
+----
 
 On auto-merge of a change neither labels nor submit rules are checked.
 If the merge fails the change stays open, but when pushing a new patch
 set the merge can be reattempted by using `%submit` again.
 
+This requires the caller to have link:access-control.html#category_submit[Submit]
+permission on `refs/for/<ref>` (e.g. on `refs/for/refs/heads/master`).
+Note how this is different from the `Submit` permission on `refs/heads/<ref>`,
+and in particular you typically do not want to apply the `Submit` permission
+on `refs/*` (unless you are ok with bypassing submit rules).
 
 [[base]]
 === Selecting Merge Base
@@ -384,18 +445,18 @@
 may override that behavior and force new changes to be created
 by setting the merge base SHA-1 using the '%base' argument:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%base=$(git rev-parse origin/master)
-====
+----
 
 It is also possible to specify more than one '%base' argument.
 This may be useful when pushing a merge commit. Note that the '%'
 character has only to be provided once, for the first '%base'
 argument:
 
-====
+----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%base=commit-id1,base=commit-id2
-====
+----
 
 
 == repo upload
diff --git a/README.md b/README.md
index b8de6e2..020602f 100644
--- a/README.md
+++ b/README.md
@@ -71,9 +71,3 @@
 
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
-
-## Events
-
-- November 7-8 2015: Gerrit User Conference, Mountain View. ([Register](http://goo.gl/forms/fifi2YQTc7)).
-- November 9-13 2015: Gerrit Hackathon, Mountain View. (Invitation Only).
-- March 2016: Gerrit Hackathon, Berlin. (Details to be confirmed).
diff --git a/ReleaseNotes/BUCK b/ReleaseNotes/BUCK
new file mode 100644
index 0000000..0f47808
--- /dev/null
+++ b/ReleaseNotes/BUCK
@@ -0,0 +1,19 @@
+include_defs('//Documentation/asciidoc.defs')
+include_defs('//ReleaseNotes/config.defs')
+
+DIR = 'ReleaseNotes'
+
+SRCS = glob(['*.txt'])
+
+
+genasciidoc(
+  name = 'html',
+  out = 'html.zip',
+  directory = DIR,
+  srcs = SRCS,
+  attributes = release_notes_attributes(),
+  backend = 'html5',
+  searchbox = False,
+  resources = False,
+  visibility = ['PUBLIC'],
+)
diff --git a/ReleaseNotes/Makefile b/ReleaseNotes/Makefile
deleted file mode 100644
index 3081600..0000000
--- a/ReleaseNotes/Makefile
+++ /dev/null
@@ -1,47 +0,0 @@
-# Copyright (C) 2010 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-ASCIIDOC       ?= asciidoc
-ASCIIDOC_EXTRA ?=
-
-DOC_HTML      := $(patsubst %.txt,%.html,$(wildcard ReleaseNotes*.txt))
-
-all: html
-
-html: index.html $(DOC_HTML)
-
-clean:
-	rm -f *.html
-
-index.html: index.txt
-	@echo FORMAT $@
-	@rm -f $@+ $@
-	@$(ASCIIDOC) --unsafe \
-		-a toc \
-		-b xhtml11 -f asciidoc.conf \
-		$(ASCIIDOC_EXTRA) -o $@+ $<
-	@mv $@+ $@
-
-$(DOC_HTML): %.html : %.txt
-	@echo FORMAT $@
-	@rm -f $@+ $@
-	@v=$$(echo $< | sed 's/^ReleaseNotes-//;s/.txt$$//;') && \
-	 n=$$(git describe HEAD) && \
-	 if ! git diff-index --quiet v$$v -- $< 2>/dev/null; then v="$$v (from $$n)"; fi && \
-	 $(ASCIIDOC) --unsafe \
-		-a toc \
-		-a "revision=$$v" \
-		-b xhtml11 -f asciidoc.conf \
-		$(ASCIIDOC_EXTRA) -o $@+ $<
-	@mv $@+ $@
diff --git a/ReleaseNotes/ReleaseNotes-2.0.10.txt b/ReleaseNotes/ReleaseNotes-2.0.10.txt
index 695be4f..33078d9 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.10.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.10.txt
@@ -1,13 +1,11 @@
-Release notes for Gerrit 2.0.10
-===============================
+= Release notes for Gerrit 2.0.10
 
 Gerrit 2.0.10 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
 
-New Features
-------------
+== New Features
 
 * GERRIT-129  Make the browser window title reflect the current scre...
 +
@@ -25,8 +23,7 @@
 +
 Minor enhancement to the way submitted emails are formatted.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-91   Delay updating the UI until a Screen instance is fully...
 +
@@ -46,8 +43,7 @@
 * GERRIT-135  Enable Save button after paste in a comment editor
 * GERRIT-137  Error out if a user forgets to squash when replacing a...
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.10 development
 * Add missing super.onSign{In,Out} calls to ChangeScreen
 * Remove the now pointless sign in callback support
diff --git a/ReleaseNotes/ReleaseNotes-2.0.11.txt b/ReleaseNotes/ReleaseNotes-2.0.11.txt
index 62f2a18..5bd6ca0 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.11.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.11.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.11
-===============================
+= Release notes for Gerrit 2.0.11
 
 Gerrit 2.0.11 is now available in the usual location:
 
@@ -12,11 +11,9 @@
   java -jar gerrit.war --cat sql/upgrade009_010.sql | psql reviewdb
 ----
 
-Important Notes
----------------
+== Important Notes
 
-Cache directory
-~~~~~~~~~~~~~~~
+=== Cache directory
 
 Gerrit now prefers having a temporary directory to store a disk-based content cache.  This cache used to be in the PostgreSQL database, and was the primary reason for the rather large size of the Gerrit schema.  In 2.0.11 the cache has been moved to the local filesystem, and now has automatic expiration management to prevent it from growing too large.  As this is only a cache, making backups of this directory is not required.
 
@@ -30,13 +27,11 @@
 
 link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html[http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html]
 
-Protocol change
-~~~~~~~~~~~~~~~
+=== Protocol change
 
 The protocol between the browser based JavaScript and the server has changed.  After installing 2.0.11 users need to load the site page again to ensure they are running 2.0.11 or later.  Users can verify they have the new version by checking the version number in the footer in the lower right.  Users who don't load the new version (e.g. are using a stale tab from a week ago) will see errors when trying to view patches.
 
-New Features
-------------
+== New Features
 
 * GERRIT-8    Add 'Whole File' as a context preference in the user s...
 * GERRIT-9    Honor user's "Default Context" preference
@@ -65,8 +60,7 @@
 +
 Simple DWIMery: users can now do `repo upload --reviewer=who` to have the reviewer email automatically expand according to the email_format column in system_config, e.g. by expanding `who` to `who@example.com`.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-81   Can't repack a repository while Gerrit is running
 +
@@ -80,8 +74,7 @@
 +
 Service users created by manually inserting into the accounts table didn't permit using their preferred_email in commits or tags; administrators had to also insert a dummy record into the account_external_ids table.  The dummy account_external_ids record is no longer necessary.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.11 development
 * Include the 'Google Format' style we selected in our p...
 * Upgrade JGit to v0.4.0-310-g3da8761
diff --git a/ReleaseNotes/ReleaseNotes-2.0.12.txt b/ReleaseNotes/ReleaseNotes-2.0.12.txt
index eb28e2e..0e1df04 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.12.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.12.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.12
-===============================
+= Release notes for Gerrit 2.0.12
 
 Gerrit 2.0.12 is now available in the usual location:
 
@@ -12,21 +11,17 @@
   java -jar gerrit.war --cat sql/upgrade010_011.sql | psql reviewdb
 ----
 
-Important Notes
----------------
+== Important Notes
 
-Java 6 Required
-~~~~~~~~~~~~~~~
+=== Java 6 Required
 
 Gerrit now requires running within a Java 6 (or later) JVM.
 
-Protocol change
-~~~~~~~~~~~~~~~
+=== Protocol change
 
 The protocol between the browser based JavaScript and the server has changed.  After installing 2.0.12 users need to load the site page again to ensure they are running 2.0.12 or later.  Users can verify they have the new version by checking the version number in the footer in the lower right.  Users who don't load the new version (e.g. are using a stale tab from a week ago) will see errors when trying to view patches.
 
-New Features
-------------
+== New Features
 * Honor --reviewer=not.preferred.email during upload
 * Also scan by preferred email for --reviewers and --cc ...
 +
@@ -73,8 +68,7 @@
 +
 Keyboard bindings have been completely overhauled in this release, and should now work on every browser.  Press '?' in any context to see the available actions.  Please note that this help is context sensitive, so you will only see keys that make sense in the current context.  Actions in a user dashboard screen differ from actions in a patch (for example), but where possible the same key is used when the logical meaning is unchanged.
 
-Bug Fixes
----------
+== Bug Fixes
 * Ignore "SshException: Already closed" errors
 +
 Hides some non-errors from the log file.
@@ -83,8 +77,7 @@
 +
 Should be a minor improvement for MSIE 6 users.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.12 development
 * Report what version we want on a schema version mismat...
 * Remove unused imports in SshServlet
diff --git a/ReleaseNotes/ReleaseNotes-2.0.13.txt b/ReleaseNotes/ReleaseNotes-2.0.13.txt
index 8ec13a8..7589568 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.13.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.13.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.13, 2.0.13.1
-=========================================
+= Release notes for Gerrit 2.0.13, 2.0.13.1
 
 Gerrit 2.0.13.1 is now available in the usual location:
 
@@ -23,8 +22,7 @@
   java -jar gerrit.war --cat sql/upgrade011_012_part2.sql | psql reviewdb
 ----
 
-Configuration Mapping
----------------------
+== Configuration Mapping
 || *system_config*                || *$site_path/gerrit.config*     ||
 || max_session_age                || auth.maxSessionAge             ||
 || canonical_url                  || gerrit.canonicalWebUrl         ||
@@ -45,8 +43,7 @@
 
 See also [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html Gerrit2 Configuration].
 
-New Features
-------------
+== New Features
 * GERRIT-180  Rewrite outgoing email to be more user friendly
 +
 A whole slew of feature improvements on outgoing email formatting was closed by this one (massive) rewrite of the outgoing email implementation.
@@ -81,8 +78,7 @@
 +
 The new `sendemail` section of `$site_path/gerrit.config` now controls the configuration of the outgoing SMTP server, rather than relying upon a JNDI resource.  See [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html configuration] section sendemail for more details.
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix file browser in patch that is taller than the wind...
 * GERRIT-184  Make 'f' toggle the file browser popup closed
 * GERRIT-188  Fix key bindings in patch when changing the old or new...
@@ -123,8 +119,7 @@
 +
 Bug fixes identified after release of 2.0.13, rolled into 2.0.13.1.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.13 development
 * Use gwtexpui 1.1.1-SNAPSHOT
 * Document the Patch.PatchType and Patch.ChangeType enum
diff --git a/ReleaseNotes/ReleaseNotes-2.0.14.txt b/ReleaseNotes/ReleaseNotes-2.0.14.txt
index de58035..128036d 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.14.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.14.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.14, 2.0.14.1
-=========================================
+= Release notes for Gerrit 2.0.14, 2.0.14.1
 
 Gerrit 2.0.14.1 is now available in the usual location:
 
@@ -13,8 +12,7 @@
   java -jar gerrit.war --cat sql/upgrade012_013_mysql.sql | mysql reviewdb
 ----
 
-New Features
-------------
+== New Features
 * GERRIT-177  Display branch name next to project in change list
 +
 Now its easier to see from your Mine>Changes what branch each change goes to.  For some users this may help prioritize reviews.
@@ -53,8 +51,7 @@
 +
 This is really for the server admin, the Git reflogs are now more likely to contain actual user information in them, rather than generic "gerrit2@localhost" identities.  This may help if you are mining "WTF happened to this branch" data from Git directly.
 
-Bug Fixes
----------
+== Bug Fixes
 * GERRIT-213  Fix n/p on a file with only one edit
 * GERRIT-66   Always show comments in patch views, even if no edit e...
 * Correctly handle comments after last hunk of patch
@@ -81,8 +78,7 @@
 +
 Fixed run-on addresses when more than one user was listed in To/CC headers.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.14 development (again)
 * Small doc updates.
 * Merge change 10282
diff --git a/ReleaseNotes/ReleaseNotes-2.0.15.txt b/ReleaseNotes/ReleaseNotes-2.0.15.txt
index a87cba1..a8d60a4 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.15.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.15.txt
@@ -1,23 +1,19 @@
-Release notes for Gerrit 2.0.15
-===============================
+= Release notes for Gerrit 2.0.15
 
 Gerrit 2.0.15 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 None.  For a change.  :-)
 
-New Features
-------------
+== New Features
 * Allow other ignore whitespace settings beyond IGNORE_S...
 +
 Now you can ignore whitespace inside the middle of a line, in addition to on the ends.
 
-Bug Fixes
----------
+== Bug Fixes
 * Update SSHD to include SSHD-28 (deadlock on close) bug...
 +
 Fixes a major stability problem with the internal SSHD.  Without this patch the daemon can become unresponsive, requiring a complete JVM restart to recover the daemon.  The symptom is connections appear to work sporadically... some connections are fine while others freeze during setup, or during data transfer.
@@ -31,8 +27,7 @@
 +
 Stupid bugs in the patch viewing code.  Random server errors and/or client UI crashes.
 
-Other Changes
--------------
+== Other Changes
 * Restart 2.0.15 development
 * Update JGit to 0.4.0-411-g8076bdb
 * Remove dead isGerrit method from AbstractGitCommand
diff --git a/ReleaseNotes/ReleaseNotes-2.0.16.txt b/ReleaseNotes/ReleaseNotes-2.0.16.txt
index 4d0252d..4f5a5ba 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.16.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.16.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.16
-===============================
+= Release notes for Gerrit 2.0.16
 
 Gerrit 2.0.16 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.14)
 
@@ -16,8 +14,7 @@
   java -jar gerrit.war --cat sql/upgrade013_014_mysql.sql | mysql reviewdb
 ----
 
-New Features
-------------
+== New Features
 * Search for changes created or reviewed by a user
 +
 The search box in the upper right corner now accepts "owner:email" and "reviewer:email", in addition to change numbers and commit SHA-1s.  Using owner: and reviewer: is not the most efficient query plan, as potentially the entire database is scanned.  We hope to improve on that as we move to a pure git based backend.
@@ -42,8 +39,7 @@
 +
 The "/Gerrit" suffix is no longer necessary in the URL.  Gerrit now favors just "/" as its path location.  This drops one redirection during initial page loading, slightly improving page loading performance, and making all URLs 6 characters shorter.  :-)
 
-Bug Fixes
----------
+== Bug Fixes
 * Don't create reflogs for patch set refs
 +
 Previously Gerrit created pointless 1 record reflogs for each change ref under refs/changes/.  These waste an inode on the local filesystem and provide no metadata value, as the same information is also stored in the metadata database.  These reflogs are no longer created.
@@ -64,8 +60,7 @@
 +
 If the hostname is "localhost" or "127.0.0.1", such as might happen when a user tries to proxy through an SSH tunnel, we honor the hostname anyway if OpenID is not being used.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.16 development
 * Update JGit to 0.4.9-18-g393ad45
 * Name replication threads by their remote name
diff --git a/ReleaseNotes/ReleaseNotes-2.0.17.txt b/ReleaseNotes/ReleaseNotes-2.0.17.txt
index 493a64b..8a24b22 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.17.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.17.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.17
-===============================
+= Release notes for Gerrit 2.0.17
 
 Gerrit 2.0.17 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.16)
 
@@ -22,8 +20,7 @@
   java -jar gerrit.war --cat sql/upgrade014_015_part2.sql | mysql reviewdb
 ----
 
-New Features
-------------
+== New Features
 * Add '[' and ']' shortcuts to PatchScreen.
 +
 The keys '[' and ']' can be used to navigate to previous and next file in a patch set.
@@ -56,8 +53,7 @@
 +
 The owner of a project was moved from the General tab to the Access Rights tab, under a new category called Owner.  This permits multiple groups to be designated the Owner of the project (simply grant Owner status to each group).
 
-Bug Fixes
----------
+== Bug Fixes
 * Permit author Signed-off-by to be optional
 +
 If a project requires Signed-off-by tags to appear the author tag is now optional, only the committer/uploader must provide a Signed-off-by tag.
@@ -83,8 +79,7 @@
 +
 Instead of crashing on a criss-cross merge case, Gerrit unsubmits the change and attaches a message, like it does when it encounters a path conflict.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.17 development
 * Move '[' and ']' key bindings to Navigation category
 * Use gwtexpui 1.1.2-SNAPSHOT to fix navigation keys
diff --git a/ReleaseNotes/ReleaseNotes-2.0.18.txt b/ReleaseNotes/ReleaseNotes-2.0.18.txt
index df635d9..1028185 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.18.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.18.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.18
-===============================
+= Release notes for Gerrit 2.0.18
 
 Gerrit 2.0.18 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Important Notices
------------------
+== Important Notices
 
 Please ensure you read the following important notices about this release; .18 is a much larger release than usual.
 
@@ -41,8 +39,7 @@
 the SSH authentication system.  More details can be found in the
 [http://android.git.kernel.org/?p=tools/gerrit.git;a=commit;h=080b40f7bbe00ac5fc6f2b10a861b63ce63e8add commit message].
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.17)
 
@@ -71,15 +68,13 @@
   java -jar gerrit.war --cat sql/upgrade015_016_part2.sql | mysql reviewdb
 ----
 
-New Bugs
---------
+== New Bugs
 * Memory leaks during warm restarts
 
 2.0.18 includes [http://code.google.com/p/google-guice/ Google Guice], which leaves a finalizer thread dangling when the Gerrit web application is halted by the servlet container.  As this thread does not terminate, the web context stays loaded in memory indefinitely, creating a memory leak.  Cold restarting the container in order to restart Gerrit is highly recommended.
 
 
-New Features
-------------
+== New Features
 * GERRIT-104  Allow end-users to select their own SSH username
 +
 End users may now select their own SSH username through the web interface.  The username must be unique within a Gerrit server installation.  During upgrades from 2.0.17 duplicate users are resolved by giving the username to the user who most recently logged in under it; other users will need to login through the web interface and select a unique username.  This change was necessary to fix a very minor security bug (see above).
@@ -113,8 +108,7 @@
 +
 As noted above in the section about cache changes, the disk cache is now completely optional.
 
-Bug Fixes
----------
+== Bug Fixes
 * GERRIT-5    Remove PatchSetInfo from database and get it always fr...
 +
 A very, very old bug.  We no longer mirror the commit data into the SQL database, but instead pull it directly from Git when needed.  Removing duplicated data simplifies the data store model, something that is important as we shift from an SQL database to a Git backed database.
@@ -139,8 +133,7 @@
 +
 The database schema changed, adding `patch_set_id` to the approval object, and renaming the approval table to `patch_set_approvals`.  If you have external code writing to this table, uh, sorry, its broken with this release, you'll have to update that code first.  :-\
 
-Other Changes
--------------
+== Other Changes
 
 This release is really massive because the internal code moved from some really ugly static data variables to doing almost everything through Guice injection.  Nothing user visible, but code cleanup that needed to occur before we started making additional changes to the system.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.0.19.txt b/ReleaseNotes/ReleaseNotes-2.0.19.txt
index 0e114c8..c9d9c56 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.19.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.19.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.19, 2.0.19.1, 2.0.19.2
-===================================================
+= Release notes for Gerrit 2.0.19, 2.0.19.1, 2.0.19.2
 
 Gerrit 2.0.19.2 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Important Notices
------------------
+== Important Notices
 
 * Prior User Sessions
 +
@@ -25,8 +23,7 @@
 set [http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#cache.directory cache.directory] in gerrit.config.  This allows Gerrit to flush the set
 of active sessions to disk during shutdown, and load them back during startup.
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.18)
 
@@ -44,8 +41,7 @@
 ----
 
 
-New Features
-------------
+== New Features
 * New ssh create-project command
 +
 Thanks to Ulrik Sjölin we now have `gerrit create-project`
@@ -171,8 +167,7 @@
 For more details, please see the docs:
 link:http://gerrit.googlecode.com/svn/documentation/2.0/user-changeid.html[http://gerrit.googlecode.com/svn/documentation/2.0/user-changeid.html]
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix yet another ArrayIndexOutOfBounds during side-by-s...
 +
 We found yet another bug with the side-by-side view failing
@@ -230,8 +225,7 @@
 wrong project, e.g. uploading a replacement commit to project
 B while picking a change number from project A.  Fixed.
 
-=Fixes in 2.0.19.1=
--------------------
+== =Fixes in 2.0.19.1=
 
 * Fix NPE during direct push to branch closing a change
 +
@@ -258,8 +252,7 @@
 +
 HTTP_LDAP broke using local usernames to match an account.  Fixed.
 
-=Fixes in 2.0.19.2=
--------------------
+== =Fixes in 2.0.19.2=
 * Don't line wrap project or group names in admin panels
 +
 Line wrapping group names like "All Users" when the description column
@@ -283,8 +276,7 @@
 As reported on repo-discuss, recursive search is sometimes necessary,
 and is now the default.
 
-Removed Features
-----------------
+== Removed Features
 
 * Remove support for /user/email style URLs
 +
@@ -292,8 +284,7 @@
 discoverable.  Its unlikely anyone is really using it, but if
 they are, they could try using "#q,owner:email,n,z" instead.
 
-Other Changes
--------------
+== Other Changes
 
 * Start 2.0.19 development
 * Document the Failure and UnloggedFailure classes in Ba...
diff --git a/ReleaseNotes/ReleaseNotes-2.0.2.txt b/ReleaseNotes/ReleaseNotes-2.0.2.txt
index b2d5b98..eb8546c 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.2
-==============================
+= Release notes for Gerrit 2.0.2
 
 Gerrit 2.0.2 is now available for download:
 
 link:https://www.gerritcodereview.com/[https://www.gerritcodereview.com/]
 
-Important Notes
----------------
+== Important Notes
 
 Starting with this version, Gerrit is now packaged as a single WAR file.
 Just download and drop into your webapps directory for easier deployment.
@@ -31,16 +29,14 @@
 and insert it into your container's CLASSPATH.  But I think all known
 instances are on PostgreSQL, so this is probably not a concern to anyone.
 
-New Features
-------------
+== New Features
 
 * Trailing whitespace is highlighted in diff views
 * SSHD upgraded with "faster connection" patch discussed on list
 * Git reflogs now contain the Gerrit account information of who did the push
 * Insanely long change subjects are now clipped at 80 characters
 
-All Changes
------------
+== All Changes
 
 * Switch back to -SNAPSHOT builds
 * Overhaul our build system to only create a WAR file
diff --git a/ReleaseNotes/ReleaseNotes-2.0.20.txt b/ReleaseNotes/ReleaseNotes-2.0.20.txt
index 527de8e..4f15bb0 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.20.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.20.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.20
-===============================
+= Release notes for Gerrit 2.0.20
 
 Gerrit 2.0.20 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 A prior bug (GERRIT-262) permitted some invalid data to enter into some databases.  Administrators should consider running the following update statement as part of their upgrade to .20 to make any comments which were created with this bug visible:
 ----
@@ -14,8 +12,7 @@
 ----
 Unfortunately the correct position of the comment has been lost, and the statement above will simply position them on the first line of the file.  Fortunately the lost comments were only on the wrong side of an insertion or deletion, and are generally rare.  (On my servers only 0.33% of the comments were created like this.)
 
-New Features
-------------
+== New Features
 * New ssh command approve
 +
 Patch sets can now be approved remotely via SSH.  For more
@@ -31,8 +28,7 @@
 administrators may permit automatically updating an existing
 account with a new identity by matching on the email address.
 
-Bug Fixes
----------
+== Bug Fixes
 * GERRIT-262  Disallow creating comments on line 0
 +
 Users were able to create comments in dead regions of a file.
@@ -59,8 +55,7 @@
 +
 MySQL schema upgrade scripts had a few bugs, fixed.
 
-Other Changes
--------------
+== Other Changes
 * Restart 2.0.20
 * Update MINA SSHD to 0.2.0 release
 * Update args4j to snapshot built from current CVS
diff --git a/ReleaseNotes/ReleaseNotes-2.0.21.txt b/ReleaseNotes/ReleaseNotes-2.0.21.txt
index 34ab581..5de84ff 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.21.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.21.txt
@@ -1,13 +1,11 @@
-Release notes for Gerrit 2.0.21
-===============================
+= Release notes for Gerrit 2.0.21
 
 Gerrit 2.0.21 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.19)
 
@@ -48,8 +46,7 @@
 ----
 
 
-Important Notices
------------------
+== Important Notices
 
 * Prior User Sessions
 +
@@ -106,8 +103,7 @@
 a supported provider.  I just want to make it clear that I no
 longer recommend it in production.
 
-New Features
-------------
+== New Features
 
 * GERRIT-189  Show approval status in account dashboards
 +
@@ -203,8 +199,7 @@
 to `USER` in gerrit.config.  For more details see
 link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#sendemail.from[sendemail.from]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix ReviewDb to actually be per-request scoped
 +
@@ -276,8 +271,7 @@
 the same email address, or to have the same OpenID auth token.
 Fixed by asserting a unique constraint on the column.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.21 development
 * Support cleaning up a Commons DBCP connection pool
 * Clarify which Factory we are importing in ApproveComma...
diff --git a/ReleaseNotes/ReleaseNotes-2.0.22.txt b/ReleaseNotes/ReleaseNotes-2.0.22.txt
index faaca81..5e2f8b5 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.22.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.22.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.22
-===============================
+= Release notes for Gerrit 2.0.22
 
 Gerrit 2.0.22 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 There is no schema change in this release.
 
@@ -32,8 +30,7 @@
    login over SSH until they return and select a new name.
 
 
-New Features
-------------
+== New Features
 * GERRIT-280  create-project: Add --branch and cleanup arguments
 +
 The --branch option to create-project can be used to setup the
@@ -94,8 +91,7 @@
 +
 Sample git pull lines are now included in email notifications.
 
-Bug Fixes
----------
+== Bug Fixes
 * create-project: Document needing to double quote descr...
 +
 The --description flag to create-project require two levels
@@ -141,8 +137,7 @@
 Merge commits created by Gerrit were still using the older style
 integer change number; changed to use the abbreviated Change-Id.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.22 development
 * Configure Maven to build with UTF-8 encoding
 * Document minimum build requirement for Mac OS X
diff --git a/ReleaseNotes/ReleaseNotes-2.0.23.txt b/ReleaseNotes/ReleaseNotes-2.0.23.txt
index 16488d4..a3f28a7 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.23.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.23.txt
@@ -1,18 +1,15 @@
-Release notes for Gerrit 2.0.23
-===============================
+= Release notes for Gerrit 2.0.23
 
 Gerrit 2.0.23 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 There is no schema change in this release.
 
 
-New Features
-------------
+== New Features
 
 * Adding support to list merged and abandoned changes
 +
@@ -22,8 +19,7 @@
 changes in the same project while merged changes link to all merged
 changes in the same project.  These links are bookmarkable.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix new change email to always have SSH pull URL
 * Move git pull URL to bottom of email notifications
@@ -39,8 +35,7 @@
 
 * Fix MySQL CREATE USER example in install documentation
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.23 development
 * Move Jetty 6.x resources into a jetty6 directory
 * Move the Jetty 6.x start script to our extra directory
diff --git a/ReleaseNotes/ReleaseNotes-2.0.24.txt b/ReleaseNotes/ReleaseNotes-2.0.24.txt
index 1f08582..7da1693 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.24.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.24.txt
@@ -1,13 +1,11 @@
-Release notes for Gerrit 2.0.24, 2.0.24.1, 2.0.24.2
-===================================================
+= Release notes for Gerrit 2.0.24, 2.0.24.1, 2.0.24.2
 
 Gerrit 2.0.24 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING: This version contains a schema change* (since 2.0.21)
 
@@ -18,8 +16,7 @@
 ----
 
 
-LDAP Change
------------
+== LDAP Change
 
 LDAP groups are now bound via their full distinguished name, and not
 by their common name.  Sites using LDAP groups will need to have the
@@ -34,8 +31,7 @@
 create identically named groups.
 
 
-New Features
-------------
+== New Features
 * Check if the user has permission to upload changes
 +
 The new READ +2 permission is required to upload a change to a
@@ -69,8 +65,7 @@
 Encrypted SMTP is now supported natively within Gerrit, see
 link:http://gerrit.googlecode.com/svn/documentation/2.0/config-gerrit.html#sendemail.smtpEncryption[sendemail.smtpEncryption]
 
-Bug Fixes
----------
+== Bug Fixes
 * issue 290    Fix invalid drop index in upgrade017_018_mysql
 +
 Minor syntax error in SQL script.
@@ -142,8 +137,7 @@
 identities when the claimed identity is just a delegate to the
 delegate provider.  We now store both in the account.
 
-Fixes in 2.0.24.1
------------------
+== Fixes in 2.0.24.1
 * Fix unused import in OpenIdServiceImpl
 * dev-readme: Fix formatting of initdb command
 +
@@ -159,8 +153,7 @@
 Fixes sendemail configuration to use the documented smtppass
 variable and not the undocumented smtpuserpass variable.
 
-Fixes in 2.0.24.2
------------------
+== Fixes in 2.0.24.2
 * Fix CreateSchema to create Administrators group
 * Fix CreateSchema to set type of Registered Users group
 * Default AccountGroup instances to type INTERNAL
@@ -180,8 +173,7 @@
 Added unit tests to validate CreateSchema works properly, so we
 don't have a repeat of breakage here.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.24 development
 * Merge change Ie16b8ca2
 * Switch to the new org.eclipse.jgit package
diff --git a/ReleaseNotes/ReleaseNotes-2.0.3.txt b/ReleaseNotes/ReleaseNotes-2.0.3.txt
index 6bf3510..d319b35 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.3.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.3
-==============================
+= Release notes for Gerrit 2.0.3
 
 Gerrit 2.0.3 is now available in the usual location:
 
@@ -10,8 +9,7 @@
 existing change".  This has been an open issue in the bug tracker for a
 while, and its finally closed thanks to his work.
 
-New Features
-------------
+== New Features
 
 * GERRIT-37  Add additional reviewers to an existing change
 * Display old and new image line numbers in unified diff
@@ -19,8 +17,7 @@
 * Allow up/down arrow keys to scroll the page in patch view
 * Use a Java applet to help users load public SSH keys
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-72  Make review comments standout more from the surrounding text
 * GERRIT-7   Restart the merge queue when Gerrit starts up
@@ -36,8 +33,7 @@
 Gerrit UI.  Such a display might be able to convince a user they are
 clicking on one thing, while doing something else entirely.
 
-Other Changes
--------------
+== Other Changes
 
 * Restore -SNAPSHOT suffix after 2.0.2
 * Add a document describing Gerrit's high level design
diff --git a/ReleaseNotes/ReleaseNotes-2.0.4.txt b/ReleaseNotes/ReleaseNotes-2.0.4.txt
index fec2425..0b10756 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.4.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.4
-==============================
+= Release notes for Gerrit 2.0.4
 
 Gerrit 2.0.4 is now available in the usual location:
 
@@ -31,8 +30,7 @@
 individual user's privacy by strongly encrypting their contact
 information, and storing it "off site".
 
-Other Changes
--------------
+== Other Changes
 * Change to 2.0.3-SNAPSHOT
 * Correct grammar in the patch conflict messages
 * Document how to create branches through SSH and web
diff --git a/ReleaseNotes/ReleaseNotes-2.0.5.txt b/ReleaseNotes/ReleaseNotes-2.0.5.txt
index 70116d3..8006e12 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.5.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.5
-==============================
+= Release notes for Gerrit 2.0.5
 
 Gerrit 2.0.5 is now available in the usual location:
 
@@ -15,8 +14,7 @@
 
 link:http://gerrit.googlecode.com/svn/documentation/2.0/config-sso.html[http://gerrit.googlecode.com/svn/documentation/2.0/config-sso.html]
 
-New Features
-------------
+== New Features
 
 * GERRIT-62  Work around IE6's inability to set innerHTML on a tbody ...
 * GERRIT-62  Upgrade to gwtjsonrpc 1.0.2 for ie6 support
@@ -35,14 +33,12 @@
 +
 These features make it easier to copy patch download commands.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-79  Error out with more useful message on "push :refs/change...
 * Invalidate all SSH keys when otherwise flushing all cach...
 
-Other Changes
--------------
+== Other Changes
 
 * Set version 2.0.4-SNAPSHOT
 * Correct note in developer setup about building SSHD
diff --git a/ReleaseNotes/ReleaseNotes-2.0.6.txt b/ReleaseNotes/ReleaseNotes-2.0.6.txt
index 9d0af33..1e28da8 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.6.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.0.6
-==============================
+= Release notes for Gerrit 2.0.6
 
 Gerrit 2.0.6 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-New Features
-------------
+== New Features
 
 * GERRIT-41  Add support for abandoning a dead change
 +
@@ -14,8 +12,7 @@
 
 * Bold substrings which match query when showing completi...
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-43  Work around Safari 3.2.1 OpenID login problems
 * GERRIT-43  Suggest boosting the headerBufferSize when deploying un...
@@ -24,8 +21,7 @@
 * GERRIT-76  Upgrade to JGit 0.4.0-209-g9c26a41
 * Ensure branches modified through web UI replicate
 
-Other Changes
--------------
+== Other Changes
 
 * Start 2.0.6 development
 * Generate the id for the iframe used during OpenID login
diff --git a/ReleaseNotes/ReleaseNotes-2.0.7.txt b/ReleaseNotes/ReleaseNotes-2.0.7.txt
index afc7784..d1bc38f 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.7.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.7.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.7
-==============================
+= Release notes for Gerrit 2.0.7
 
 Gerrit 2.0.7 is now available in the usual location:
 
@@ -11,15 +10,13 @@
 
 Gerrit is still Apache 2/MIT/BSD licensed, despite the switch of a dependency.
 
-New Features
-------------
+== New Features
 
 * GERRIT-103  Display our server host keys for the client to copy an...
 +
 For the paranoid user, they can check the key fingerprint, or even copy the complete host key line for ~/.ssh/known_hosts, directly from Settings > SSH Keys.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * GERRIT-98   Require that a change be open in order to abandon it
 * GERRIT-101  Switch OpenID relying party to openid4java
@@ -34,8 +31,7 @@
 
 * Fix a NullPointerException in OpenIdServiceImpl on res...
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.7 development
 * Upgrade JGit to 0.4.0-212-g9057f1b
 * Make the sign in dialog a bit taller to avoid clipping...
diff --git a/ReleaseNotes/ReleaseNotes-2.0.8.txt b/ReleaseNotes/ReleaseNotes-2.0.8.txt
index 4b2d10a5..89e7fdd 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.8.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.8.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.8
-==============================
+= Release notes for Gerrit 2.0.8
 
 Gerrit 2.0.8 is now available in the usual location:
 
@@ -14,8 +13,7 @@
 This version has some major bug fixes for JGit.  I strongly encourage people to upgrade, we had a number of JGit bugs identified last week, all of them should be fixed in this release.
 
 
-New Features
-------------
+== New Features
 * Allow users to subscribe to submitted change events
 +
 Someone asked me on an IRC channel to have Gerrit send emails when changes are actually merged into a project.  This is what triggered the schema change; there is a new checkbox on the Watched Projects list under Settings to subscribe to these email notifications.
@@ -33,15 +31,13 @@
 +
 The reflogs now contain the remote user's IP address when Gerrit makes edits, resulting in slightly more detail than was there before.
 
-Bug Fixes
----------
+== Bug Fixes
 * Make sure only valid ObjectIds can be passed into git d...
 * GERRIT-92  Upgrade JGit to 0.4.0-262-g3c268c8
 +
 The JGit bug fixes are rather major.  I would strongly encourage upgrading.
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.8 development
 * Upgrade MINA SSHD to SVN trunk 755651
 * Fix a minor whitespace error in ChangeMail
diff --git a/ReleaseNotes/ReleaseNotes-2.0.9.txt b/ReleaseNotes/ReleaseNotes-2.0.9.txt
index d2a9196..1f683cf 100644
--- a/ReleaseNotes/ReleaseNotes-2.0.9.txt
+++ b/ReleaseNotes/ReleaseNotes-2.0.9.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.0.9
-==============================
+= Release notes for Gerrit 2.0.9
 
 Gerrit 2.0.9 is now available in the usual location:
 
@@ -20,8 +19,7 @@
 
 The SQL statement to insert a new project into the database has been changed.  Please see [http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html Project Setup] for the modified statement.
 
-New Features
-------------
+== New Features
 * GERRIT-69   Make the merge commit message more detailed when mergi...
 * Show the user's starred/not-starred icon in the change...
 * Modify Push Annotated Tag to require signed tags, or r...
@@ -34,8 +32,7 @@
 These last two changes move the hidden gerrit.fastforwardonly feature to the database and the user interface, so project owners can make use of it (or not).  Please see the new 'Change Submit Action' section in the user documentation:
 link:http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html[http://gerrit.googlecode.com/svn/documentation/2.0/project-setup.html]
 
-Bug Fixes
----------
+== Bug Fixes
 * Work around focus bugs in WebKit based browsers
 * Include our license list in the WAR file
 * Whack any prior submit approvals by myself when replac...
@@ -43,8 +40,7 @@
 * GERRIT-85   ie6: Correct rendering of commit messages
 * GERRIT-89   ie6: Fix date line wrapping in messages
 
-Other Changes
--------------
+== Other Changes
 * Start 2.0.9 development
 * Always show the commit SHA-1 next to the patch set hea...
 * Silence more non-critical log messages from openid4java
diff --git a/ReleaseNotes/ReleaseNotes-2.1.1.txt b/ReleaseNotes/ReleaseNotes-2.1.1.txt
index 9d795b6..38b6caf 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.1.txt
@@ -1,20 +1,17 @@
-Release notes for Gerrit 2.1.1, 2.1.1.1
-=======================================
+= Release notes for Gerrit 2.1.1, 2.1.1.1
 
 Gerrit 2.1.1.1 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING* This release contains a schema change.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
 ----
 
-Patch 2.1.1.1
--------------
+== Patch 2.1.1.1
 
 * Update MINA SSHD to SVN 897374
 +
@@ -28,8 +25,7 @@
 Discarding a comment from the publish comments screen caused
 a ConcurrentModificationException.  Fixed.
 
-New Features
-------------
+== New Features
 
 * issue 322    Update to GWT 2.0.0
 +
@@ -106,8 +102,7 @@
 by an accidental click.  This is especially useful when there
 is a merge error during submit.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 359    Allow updates of commits where only the parent changes
 +
@@ -158,8 +153,7 @@
 to leak file descriptors, as pipes to the external CGI were not
 always closed.  Fixed.
 
-Other
------
+== Other
 * Switch to ClientBundle
 * Update to gwtexpui-1.2.0-SNAPSHOT
 * Merge branch 'master' into gwt-2.0
diff --git a/ReleaseNotes/ReleaseNotes-2.1.10.txt b/ReleaseNotes/ReleaseNotes-2.1.10.txt
index 5464267..5c5bcc6 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.10.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.10.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.10
-===============================
+= Release notes for Gerrit 2.1.10
 
 There are no schema changes from link:ReleaseNotes-2.1.9.html[2.1.9].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.10.war[https://www.gerritcodereview.com/download/gerrit-2.1.10.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix clone for modern Git clients
 +
 The security fix in 2.1.9 broke clone for recent Git clients,
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.1.txt b/ReleaseNotes/ReleaseNotes-2.1.2.1.txt
index ae5d912..b181fee 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.1.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2.1
-================================
+= Release notes for Gerrit 2.1.2.1
 
 Gerrit 2.1.2.1 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Include smart http:// URLs in gitweb
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.2.txt b/ReleaseNotes/ReleaseNotes-2.1.2.2.txt
index 6565833..305e3e1 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2.2
-================================
+= Release notes for Gerrit 2.1.2.2
 
 Gerrit 2.1.2.2 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Add ',' to be encoded in email headers.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.3.txt b/ReleaseNotes/ReleaseNotes-2.1.2.3.txt
index 3cfbdd1..f81092c 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2.3
-================================
+= Release notes for Gerrit 2.1.2.3
 
 Gerrit 2.1.2.3 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 528 gsql: Fix escaping of quotes in JSON
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.4.txt b/ReleaseNotes/ReleaseNotes-2.1.2.4.txt
index 5e863f7..45fcb40 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.4.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2.4
-================================
+= Release notes for Gerrit 2.1.2.4
 
 Gerrit 2.1.2.4 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-New Features
-------------
+== New Features
 
 * Add 'checkout' download command to patch sets
 +
@@ -14,8 +12,7 @@
 and checkout the patch set on a detached HEAD.  This is more suitable
 for building and testing the change locally.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 545 Fallback to ISO-8859-1 if charset isn't supported
 +
@@ -45,8 +42,7 @@
 in the directory, Gerrit crashed during sign-in while trying to
 clear out the user name.  Fixed.
 
-Documentation Corrections
-~~~~~~~~~~~~~~~~~~~~~~~~~
+=== Documentation Corrections
 
 * documentation: Elaborate on branch level Owner
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.5.txt b/ReleaseNotes/ReleaseNotes-2.1.2.5.txt
index 6e9a49e..eece1e7 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.5.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2.5
-================================
+= Release notes for Gerrit 2.1.2.5
 
 Gerrit 2.1.2.5 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 390 Resolve objects going missing
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.1.2.txt b/ReleaseNotes/ReleaseNotes-2.1.2.txt
index e0d8c12..8e7cd5c 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.2
-==============================
+= Release notes for Gerrit 2.1.2
 
 Gerrit 2.1.2 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING* This release contains multiple schema changes.  To upgrade:
 ----
@@ -14,8 +12,7 @@
 ----
 
 
-Breakages
----------
+== Breakages
 
 * issue 421 Force validation of the author and committer lines
 +
@@ -33,11 +30,9 @@
 exists, and Forge Identity +2 where Push Branch >= +1 exists.
 
 
-New Features
-------------
+== New Features
 
-UI - Diff Viewer
-~~~~~~~~~~~~~~~~
+=== UI - Diff Viewer
 
 * issue 169 Highlight line-level (aka word) differences in files
 +
@@ -110,8 +105,7 @@
 * Use RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK for tabs
 * Use a tooltip to explain whitespace errors
 
-UI - Other
-~~~~~~~~~~
+=== UI - Other
 
 * issue 408 Show summary of code review, verified on all open changes
 +
@@ -153,8 +147,7 @@
 Site administrators can now theme the UI with local site colors
 by setting theme variables in gerrit.config.
 
-Permissions
-~~~~~~~~~~~
+=== Permissions
 
 * issue 60 Change permissions to be branch based
 +
@@ -172,8 +165,7 @@
 See link:http://gerrit.googlecode.com/svn/documentation/2.1.2/access-control.html#function_MaxNoBlock[access control]
 for more details on this function.
 
-Remote Access
-~~~~~~~~~~~~~
+=== Remote Access
 
 * Enable smart HTTP under /p/ URLs
 +
@@ -210,8 +202,7 @@
 addition to single quotes.  This can make it easier to intermix
 quoting styles with the shell that is calling the SSH client .
 
-Server Administration
-~~~~~~~~~~~~~~~~~~~~~
+=== Server Administration
 
 * issue 383 Add event hook support
 +
@@ -272,8 +263,7 @@
 software driven queries over SSH easier.  The -c option accepts
 one query, executes it, and returns.
 
-Other
-~~~~~
+=== Other
 
 * Warn when a commit message isn't wrapped
 +
@@ -290,11 +280,9 @@
 rather than from the user's preferred account information.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-UI
-~~
+=== UI
 
 * Change "Publish Comments" to "Review"
 +
@@ -380,8 +368,7 @@
 * Update URL for GitHub's SSH key guide
 * issue 314 Hide group type choice if LDAP is not enabled
 
-Email
-~~~~~
+=== Email
 
 * Send missing dependencies to owners if they are the only reviewer
 +
@@ -401,8 +388,7 @@
 An additional from line is now injected at the start of the email
 body to indicate the actual user.
 
-Remote Access
-~~~~~~~~~~~~~
+=== Remote Access
 
 * issue 385 Delete session cookie when session is expired
 +
@@ -443,8 +429,7 @@
 whitespace, removing a common source of typos that lead to users
 being automatically assigned more than one Gerrit user account.
 
-Server Administration
-~~~~~~~~~~~~~~~~~~~~~
+=== Server Administration
 
 * daemon: Really allow httpd.listenUrl to end with /
 +
@@ -532,8 +517,7 @@
 sometimes failed.  Fixed by executing an implicit reload in these
 cases, reducing the number of times a user sees a failure.
 
-Development
-~~~~~~~~~~~
+=== Development
 
 * issue 427 Adjust SocketUtilTest to be more likely to pass
 +
@@ -569,8 +553,7 @@
 removed from the license file.
 
 
-Schema Changes in Detail
-------------------------
+== Schema Changes in Detail
 
 * Remove Project.Id and use only Project.NameKey
 +
@@ -594,8 +577,7 @@
 aren't possible, or to run on MySQL MyISAM tables.
 
 
-Other Changes
--------------
+== Other Changes
 * Update gwtorm to 1.1.4-SNAPSHOT
 * Add unique column ids to every column
 * Remove unused byName @SecondaryKey from ApprovalCategory
diff --git a/ReleaseNotes/ReleaseNotes-2.1.3.txt b/ReleaseNotes/ReleaseNotes-2.1.3.txt
index f4faf32..6226b93 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.3
-==============================
+= Release notes for Gerrit 2.1.3
 
 Gerrit 2.1.3 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING* This release contains multiple schema changes.  To upgrade:
 ----
@@ -14,11 +12,9 @@
 ----
 
 
-New Features
-------------
+== New Features
 
-Web UI
-~~~~~~
+=== Web UI
 
 * issue 289 Remove reviewers (or self) from a change
 +
@@ -63,8 +59,7 @@
 Access tabs.  Editing is obviously disabled, unless the user has
 owner level access to the project, or one of its branches.
 
-Access Controls
-~~~~~~~~~~~~~~~
+=== Access Controls
 
 * Branch-level read access is now supported
 +
@@ -97,8 +92,7 @@
 inherited by default, but the old exclusive behavior can be obtained
 by prefixing the reference with '-'.
 
-SSH Commands
-~~~~~~~~~~~~
+=== SSH Commands
 
 * create-account: Permit creation of batch user accounts over SSH
 * issue 269 Enable create-project for non-Administrators
@@ -125,8 +119,7 @@
 The old `gerrit approve` name will be kept around as an alias to
 provide time to migrate hooks/scripts/etc.
 
-Hooks / Stream Events
-~~~~~~~~~~~~~~~~~~~~~
+=== Hooks / Stream Events
 
 * \--change-url parameter passed to hooks
 +
@@ -147,13 +140,11 @@
 set is now included in the stream-events record, making it possible
 for a monitor to easily pull down a patch set and compile it.
 
-Contrib
-~~~~~~~
+=== Contrib
 
 * Example hook to auto-re-approve a trivial rebase
 
-Misc.
-~~~~~
+=== Misc.
 
 * transfer.timeout: Support configurable timeouts for dead clients
 +
@@ -195,11 +186,9 @@
 Apache Commons DBCP to 1.4.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 
 * issue 396 Prevent 'no-score' approvals from being recorded
 +
@@ -237,8 +226,7 @@
 'Register New Email...' button.  A cancel button was added to
 close the dialog.
 
-Server Programs
-~~~~~~~~~~~~~~~
+=== Server Programs
 
 * init: Import non-standardly named Git repositories
 +
@@ -265,8 +253,7 @@
 were not properly logged by `gerrit approve` (now gerrit review).
 Fixed by logging the root cause of the failure.
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * Display error when HTTP authentication isn't configured
 +
@@ -282,8 +269,7 @@
 during sign-in.  Administrators can enable following by adding
 `ldap.referral = follow` to their gerrit.config file.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
 * documentation: Clarified the ownership of '\-- All Projects \--'
 +
@@ -308,7 +294,6 @@
 the current stable version of the Maven plugin.
 
 
-Version
--------
+== Version
 
 e8fd49f5f7481e2f916cb0d8cfbada79309562b4
diff --git a/ReleaseNotes/ReleaseNotes-2.1.4.txt b/ReleaseNotes/ReleaseNotes-2.1.4.txt
index 3e25163..72eec55 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.4.txt
@@ -1,23 +1,19 @@
-Release notes for Gerrit 2.1.4
-==============================
+= Release notes for Gerrit 2.1.4
 
 Gerrit 2.1.4 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING* This release contains multiple schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
 ----
 
-New Features
-------------
+== New Features
 
-Change Management
-~~~~~~~~~~~~~~~~~
+=== Change Management
 
 * issue 504 Implement full query operators
 +
@@ -51,8 +47,7 @@
 watched project list, and a new menu item was added under the My menu
 to select open changes matching these watched projects.
 
-Web UI
-~~~~~~
+=== Web UI
 
 * issue 579 Remember diff formatting preferences
 +
@@ -82,8 +77,7 @@
 
 * issue 509 Make branch columns link to changes on that branch
 
-Email Notifications
-~~~~~~~~~~~~~~~~~~~
+=== Email Notifications
 
 * issue 311 No longer CC a user by default
 +
@@ -116,8 +110,7 @@
 New fields in the email footer provide additional detail, enabling
 better filtering and classification of messages.
 
-Access Control
-~~~~~~~~~~~~~~
+=== Access Control
 
 * Support regular expressions for ref access rules
 +
@@ -136,8 +129,7 @@
 Groups can now be created over SSH by administrators using the
 `gerrit create-group` command.
 
-Authentication
-~~~~~~~~~~~~~~
+=== Authentication
 
 * Remove password authentication over SSH
 +
@@ -157,8 +149,7 @@
 setting, rather than expiring when the browser closes.  (Previously
 sessions expired when the browser exited.)
 
-Misc.
-~~~~~
+=== Misc.
 
 * Add topic, lastUpdated, sortKey to ChangeAttribute
 +
@@ -184,11 +175,9 @@
 Updated JGit to 0.8.4.89-ge2f5716, log4j to 1.2.16, GWT to 2.0.4,
 sfl4j to 1.6.1, easymock to 3.0, JUnit to 4.8.1.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 
 * issue 352 Confirm branch deletion in web UI
 +
@@ -211,15 +200,13 @@
 Keyboard navigation to standard links like 'Google Accounts'
 wasn't supported.  Fixed.
 
-Misc.
-~~~~~
+=== Misc.
 
 * issue 614 Fix 503 error when Jetty cancels a request
 +
 A bug was introduced in 2.1.3 that caused a server 503 error
 when a fetch/pull/clone or push request timed out.  Fixed.
 
-Version
--------
+== Version
 
 ae59d1bf232bba16d4d03ca924884234c68be0f2
diff --git a/ReleaseNotes/ReleaseNotes-2.1.5.txt b/ReleaseNotes/ReleaseNotes-2.1.5.txt
index 4934223..88288e2 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.5.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.1.5
-==============================
+= Release notes for Gerrit 2.1.5
 
 Gerrit 2.1.5 is now available:
 
@@ -8,8 +7,7 @@
 This is primarily a bug fix release to 2.1.4, but some additional
 new features were included so its named 2.1.5 rather than 2.1.4.1.
 
-Upgrade Instructions
---------------------
+== Upgrade Instructions
 
 If upgrading from version 2.1.4, simply replace the WAR file in
 `'site_path'/bin/gerrit.war` and restart Gerrit.
@@ -18,11 +16,9 @@
 `java -jar gerrit.war init -d 'site_path'` to upgrade the schema,
 and restart Gerrit.
 
-New Features
-------------
+== New Features
 
-Web UI
-~~~~~~
+=== Web UI
 * issue 361 Enable commenting on commit messages
 +
 The commit message of a change can now be commented on inline, and
@@ -49,8 +45,7 @@
 A 'diffstat' is shown for each file, summarizing the size of the
 change on that file in terms of number of lines added or deleted.
 
-Email Notifications
-~~~~~~~~~~~~~~~~~~~
+=== Email Notifications
 * issue 452 Include a quick summary of the size of a change in email
 +
 After the file listing, a summary totaling the number of files
@@ -58,11 +53,9 @@
 help reviewers to get a quick estimation on the time required for
 them to review the change.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 * issue 639 Fix keyboard shortcuts under Chrome/Safari
 +
 Keyboard shortcuts didn't work properly on modern WebKit browsers
@@ -91,8 +84,7 @@
 is present and will toggle the user's starred flag for that change.
 Fixed.
 
-Access Control
-~~~~~~~~~~~~~~
+=== Access Control
 * issue 672 Fix branch owner adding exclusive ACL
 +
 Branch owners could not add exclusive ACLs within their branch
@@ -118,8 +110,7 @@
 bug which failed to consider the project inheritance if any branch
 (not just the one being uploaded to) denied upload access.
 
-Misc.
-~~~~~
+=== Misc.
 * issue 641 Don't pass null arguments to hooks
 +
 Some hooks crashed inside of the server during invocation because the
@@ -162,12 +153,10 @@
 Gerrit Code Review was only accepting the ';' syntax.  Fixed
 to support both.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 * Fixed example for gerrit create-account.
 * gerrit.sh: Correct /etc/default path in error message
 
-Version
--------
+== Version
 
 2765ff9e5f821100e9ca671f4d502b5c938457a5
diff --git a/ReleaseNotes/ReleaseNotes-2.1.6.1.txt b/ReleaseNotes/ReleaseNotes-2.1.6.1.txt
index a490c0a..4626c7b 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.6.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.6.1.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.6.1
-================================
+= Release notes for Gerrit 2.1.6.1
 
 Gerrit 2.1.6.1 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.6.1.war[https://www.gerritcodereview.com/download/gerrit-2.1.6.1.war]
 
-Schema Change
--------------
+== Schema Change
 
 If upgrading from 2.1.6, there are no schema changes.  Replace the
 WAR and restart the daemon.
@@ -17,8 +15,7 @@
   java -jar gerrit.war init -d site_path
 ----
 
-New Features
-------------
+== New Features
 * Display the originator of each access rule
 +
 The project access panel now shows which project each rule inherits
@@ -37,8 +34,7 @@
 project, provided that the parent project is not the root level
 \-- All Projects \--.
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix disabled intraline difference checkbox
 +
 Intraline difference couldn't be enabled once it was disabled by
diff --git a/ReleaseNotes/ReleaseNotes-2.1.6.txt b/ReleaseNotes/ReleaseNotes-2.1.6.txt
index 520b2a6..83689e7 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.6.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.6
-==============================
+= Release notes for Gerrit 2.1.6
 
 Gerrit 2.1.6 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.6.war[https://www.gerritcodereview.com/download/gerrit-2.1.6.war]
 
-Schema Change
--------------
+== Schema Change
 
 *WARNING* This release contains multiple schema changes.  To upgrade:
 ----
@@ -14,11 +12,9 @@
 ----
 
 
-New Features
-------------
+== New Features
 
-Web UI
-~~~~~~
+=== Web UI
 * issue 312 Abandoned changes can now be restored.
 * issue 698 Make date and time fields customizable
 * issue 556 Preference to display patch sets in reverse order
@@ -39,8 +35,7 @@
 This is built upon experimental merge code inherited from JGit,
 and is therefore still experimental in Gerrit.
 
-Change Query
-~~~~~~~~~~~~
+=== Change Query
 * issue 688 Match branch, topic, project, ref by regular expressions
 +
 Similar to other features in Gerrit Code Review, starting any of these
@@ -80,8 +75,7 @@
 Gerrit change submission, Gerrit will now send a new "ref-updated"
 event to the event stream.
 
-User Management
-~~~~~~~~~~~~~~~
+=== User Management
 * SSO via client SSL certificates
 +
 A new auth.type of CLIENT_SSL_CERT_LDAP supports authenticating users
@@ -123,8 +117,7 @@
 The internal SSH daemon now supports additional configuration
 settings to reduce the risk of abuse.
 
-Administration
-~~~~~~~~~~~~~~
+=== Administration
 * issue 558 Allow Access rights to be edited by clicking on them.
 
 * New 'Project Owner' system group to define default rights
@@ -186,11 +179,9 @@
 prevent the older (pre-filter-branch) history from being reintroduced
 into the repository.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 * issue 498 Enable Keyboard navigation after change submit
 * issue 691 Make ']' on last file go up to change
 * issue 741 Make ENTER work for 'Create Group'
@@ -224,13 +215,11 @@
 are not project owners may now only view access rights for branches
 they have at least READ +1 permission on.
 
-Change Query
-~~~~~~~~~~~~
+=== Change Query
 * issue 689 Fix age:4days to parse correctly
 * Make branch: operator slightly less ambiguous
 
-Push Support
-~~~~~~~~~~~~
+=== Push Support
 * issue 695 Permit changing only the author of a commit
 +
 Correcting only the author of a change failed to upload the new patch
@@ -257,8 +246,7 @@
 create changes for review were unable to push to a project.  Fixed.
 This (finally) makes Gerrit a replacement for Gitosis or Gitolite.
 
-Replication
-~~~~~~~~~~~
+=== Replication
 * issue 683 Don't assume authGroup = "Registered Users" in replication
 +
 Previously a misconfigured authGroup in replication.config may have
@@ -281,8 +269,7 @@
 
 * issue 658 Allow refspec shortcuts (push = master) for replication
 
-User Management
-~~~~~~~~~~~~~~~
+=== User Management
 * Ensure proper escaping of LDAP group names
 +
 Some special characters may appear in LDAP group names, these must be
@@ -295,8 +282,7 @@
 but cannot because it is already in use by another user on this
 server, the new account won't be created.
 
-Administration
-~~~~~~~~~~~~~~
+=== Administration
 * gerrit.sh: actually verify running processes
 +
 Previously `gerrit.sh check` claimed a server was running if the
@@ -317,6 +303,5 @@
 child project.  Permissions can now be overidden if the category,
 group name and reference name all match.
 
-Version
--------
+== Version
 ef16a1816f293d00c33de9f90470021e2468a709
diff --git a/ReleaseNotes/ReleaseNotes-2.1.7.2.txt b/ReleaseNotes/ReleaseNotes-2.1.7.2.txt
index fdd7725..9c9e6e1 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.7.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.7.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.7.2
-================================
+= Release notes for Gerrit 2.1.7.2
 
 Gerrit 2.1.7.2 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.7.2.war[https://www.gerritcodereview.com/download/gerrit-2.1.7.2.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * issue 997 Resolve Project Owners when checking access rights
 +
 Members of the 'Project Owners' magical group did not always have
diff --git a/ReleaseNotes/ReleaseNotes-2.1.7.txt b/ReleaseNotes/ReleaseNotes-2.1.7.txt
index 5123279..ad440b5 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.7.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.7.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.7
-==============================
+= Release notes for Gerrit 2.1.7
 
 Gerrit 2.1.7 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.7.war[https://www.gerritcodereview.com/download/gerrit-2.1.7.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING* This release contains multiple schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -18,8 +16,7 @@
   java -jar gerrit.war ExportReviewNotes -d site_path
 ----
 
-Memory Usage Increase
----------------------
+== Memory Usage Increase
 *WARNING* The JGit delta base cache, whose size is controlled by
 `core.deltaBaseCacheLimit`, has changed in this release from being a
 JVM-wide singleton to per-thread. This alters the memory usage, going
@@ -27,17 +24,14 @@
 change improves performance on big repositories, but may need a larger
 `container.heapLimit` if the number of concurrent operations is high.
 
-New Features
-------------
+== New Features
 
-Change Data
-~~~~~~~~~~~
+=== Change Data
 * issue 64 Create Git notes for submitted changes
 +
 Git notes are automatically added to the `refs/notes/review`.
 
-Query
-~~~~~
+=== Query
 * Search project names by substring
 +
 Entering a word with no operator (for example `gerrit`) will be
@@ -49,8 +43,7 @@
 search for changes whose owner or that has a reviewer in (or not
 in if prefixed with `-`) the specified group.
 
-Web UI
-~~~~~~
+=== Web UI
 * Add reviewer/verifier name beside check/plus/minus
 +
 Change lists (such as from a search result, or in a user's dashboard)
@@ -90,17 +83,14 @@
 SSH public key files by hand.
 
 
-SSH Commands
-~~~~~~~~~~~~
+=== SSH Commands
 * issue 674 Add abandon/restore to `gerrit review`
 * Add `gerrit version` command
 
-Change Upload
-~~~~~~~~~~~~~
+=== Change Upload
 * Display a more verbose "you are not author/committer" message
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 * Detailed error message explanations
 +
 Most common error messages are now described in detail in the
@@ -111,8 +101,7 @@
 * issue 905 Document reverse proxy using Nginx
 * Updated system scaling data in 'System Design'
 
-Outgoing Mail
-~~~~~~~~~~~~~
+=== Outgoing Mail
 * Optionally add Importance and Expiry-Days headers
 +
 New gerrit.config variable `sendemail.importance` can be set to `high`
@@ -122,8 +111,7 @@
 
 * Add support for SMTP AUTH LOGIN
 
-Administration
-~~~~~~~~~~~~~~
+=== Administration
 * Group option to make group visible to all users
 +
 A new group option permits the group to be visible to all users,
@@ -181,8 +169,7 @@
 path used for the authentication cookie, which may be necessary if
 a reverse proxy maps requests to the managed gitweb.
 
-Replication
-~~~~~~~~~~~
+=== Replication
 * Add adminUrl to replication for repository creation
 +
 Replication remotes can be configured with `remote.name.adminUrl` to
@@ -196,8 +183,7 @@
 Replication can now be performed over an authenticated smart HTTP
 transport, in addition to anonymous Git and authenticated SSH.
 
-Misc.
-~~~~~
+=== Misc.
 * Alternative URL for Gerrit's managed Gitweb
 +
 The internal gitweb served from `/gitweb` can now appear to be from a
@@ -210,11 +196,9 @@
 to 1.6, Apache Commons Net to 2.2, Apache Commons Pool to 1.5.5, JGit
 to 0.12.1.53-g5ec4977, MINA SSHD to 0.5.1-r1095809.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 * issue 853 Incorrect side-by-side display of modified lines
 +
 A bug in JGit lead to the side-by-side view displaying wrong and
@@ -261,12 +245,10 @@
 * Always display button text in black
 * Always disable content merge option if user can't change project
 
-commit-msg Hook
-~~~~~~~~~~~~~~~
+=== commit-msg Hook
 * issue 922 Fix commit-msg hook to run on Solaris
 
-Outgoing Mail
-~~~~~~~~~~~~~
+=== Outgoing Mail
 * issue 780 E-mail about failed merge should not use Anonymous Coward
 +
 Some email was sent as Anonymous Coward, even when the user had a
@@ -281,8 +263,7 @@
 * Do not email reviewers adding themselves as reviewers
 * Fix comma/space separation in email templates
 
-Pushing Changes
-~~~~~~~~~~~~~~~
+=== Pushing Changes
 * Avoid huge pushes during refs/for/BRANCH push
 +
 With Gerrit 2.1.6, clients started to push possibly hundreds of
@@ -356,8 +337,7 @@
 no mention if it on the server error log. Now it is reported so the
 site administrator also knows about it.
 
-SSH Commands
-~~~~~~~~~~~~
+=== SSH Commands
 * issue 755 Send new patchset event after its available
 * issue 814 Evict initial members of group created by SSH
 * issue 879 Fix replication of initial empty commit in new project
@@ -365,8 +345,7 @@
 * Automatically create user account(s) as necessary
 * Move SSH command creation off NioProcessor threads
 
-Administration
-~~~~~~~~~~~~~~
+=== Administration
 * Enable git reflog for all newly created projects
 +
 Previously branch updates were not being recorded in the native Git
@@ -388,8 +367,7 @@
 * gerrit.sh: Fix issues on Solaris
 * gerrit.sh: Support spaces in JAVA_HOME
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 * issue 800 documentation: Show example of review -m
 * issue 896 Clarify that $\{name\} is required for replication.
 * Fix spelling mistake in 'Searching Changes' documentation
diff --git a/ReleaseNotes/ReleaseNotes-2.1.8.txt b/ReleaseNotes/ReleaseNotes-2.1.8.txt
index 476e312..e1ed11c 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.8.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.8.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.8
-==============================
+= Release notes for Gerrit 2.1.8
 
 Gerrit 2.1.8 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.8.war[https://www.gerritcodereview.com/download/gerrit-2.1.8.war]
 
-New Features
-------------
+== New Features
 * Add cache for tag advertisements
 +
 When READ level access controls are used on references/branches, this
@@ -39,8 +37,7 @@
 MS-DOS compatibility may have permitted access to special device
 files in any directory, rather than just the "\\.\" device namespace.
 
-Bug Fixes
----------
+== Bug Fixes
 * issue 518 Fix MySQL counter resets
 +
 MySQL databases lost their change_id, account_id counters after
diff --git a/ReleaseNotes/ReleaseNotes-2.1.9.txt b/ReleaseNotes/ReleaseNotes-2.1.9.txt
index 2efc5b6..63bcb20 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.9.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.9.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.1.9
-==============================
+= Release notes for Gerrit 2.1.9
 
 There are no schema changes from link:ReleaseNotes-2.1.8.html[2.1.8].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.1.9.war[https://www.gerritcodereview.com/download/gerrit-2.1.9.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Patch JGit security hole
 +
 The security hole may permit a modified Git client to gain access
diff --git a/ReleaseNotes/ReleaseNotes-2.1.txt b/ReleaseNotes/ReleaseNotes-2.1.txt
index 127ab09..28cc90d 100644
--- a/ReleaseNotes/ReleaseNotes-2.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.1.txt
@@ -1,20 +1,17 @@
-Release notes for Gerrit 2.1
-============================
+= Release notes for Gerrit 2.1
 
 Gerrit 2.1 is now available in the usual location:
 
 link:https://www.gerritcodereview.com/download/index.html[https://www.gerritcodereview.com/download/index.html]
 
 
-New site_path Layout
---------------------
+== New site_path Layout
 
 The layout of the `$site_path` directory has been changed in 2.1.
 Configuration files are now stored within the `etc/` subdirectory
 and will be automatically moved there by the init subcommand.
 
-Upgrading From 2.0.x
---------------------
+== Upgrading From 2.0.x
 
   If the server is running a version older than 2.0.24, upgrade the
   database schema to the current schema version of 19.  Download
@@ -37,8 +34,7 @@
 sendemail.smtpPass and ldap.password out of gerrit.config into a
 read-protected secure.config file.
 
-New Daemon Mode
----------------
+== New Daemon Mode
 
 Gerrit 2.1 and later embeds the Jetty servlet container, and
 runs it automatically as part of `java -jar gerrit.war daemon`.
@@ -57,8 +53,7 @@
 link:http://gerrit.googlecode.com/svn/documentation/2.1/index.html[http://gerrit.googlecode.com/svn/documentation/2.1/index.html]
 
 
-New Features
-------------
+== New Features
 
 * issue 19     Link to issue tracker systems from commits
 +
@@ -184,8 +179,7 @@
 +
 Most dependencies were updated to their current stable versions.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 259    Improve search hint to include owner:email
 +
@@ -268,8 +262,7 @@
 `$HOME/.gerritcodereview/tmp`, which should be isolated from
 the host system's /tmp cleaner.
 
-Other=
-------
+== Other=
 
 * Pick up gwtexpui 1.1.4-SNAPSHOT
 * Merge change Ia64286d3
diff --git a/ReleaseNotes/ReleaseNotes-2.10.1.txt b/ReleaseNotes/ReleaseNotes-2.10.1.txt
index 3065492..72d26d1 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.1
-===============================
+= Release notes for Gerrit 2.10.1
 
 There are no schema changes from link:ReleaseNotes-2.10.html[2.10].
 
@@ -7,8 +6,7 @@
 link:https://www.gerritcodereview.com/download/gerrit-2.10.1.war[
 https://www.gerritcodereview.com/download/gerrit-2.10.1.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2260[Issue 2260]:
 LDAP horrendous login time due to recursive lookup.
@@ -19,8 +17,7 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=3211[Issue 3211]:
 Intermittent Null Pointer Exception when showing process queue.
 
-LDAP
-----
+== LDAP
 
 * Several performance improvements when using LDAP, both in the number of LDAP
 requests and in the amount of data transferred.
@@ -28,13 +25,11 @@
 * Sites using LDAP for authentication but otherwise rely on local Gerrit groups
 should set the new `ldap.fetchMemberOfEagerly` option to `false`.
 
-OAuth
------
+== OAuth
 
 * Expose extension point for generic OAuth providers.
 
-OpenID
-------
+== OpenID
 
 * Add support for Launchpad on the login form.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.10.2.txt b/ReleaseNotes/ReleaseNotes-2.10.2.txt
index ac7c866..49be04e 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.2
-===============================
+= Release notes for Gerrit 2.10.2
 
 There are no schema changes from link:ReleaseNotes-2.10.1.html[2.10.1].
 
@@ -7,8 +6,7 @@
 link:https://www.gerritcodereview.com/download/gerrit-2.10.2.war[
 https://www.gerritcodereview.com/download/gerrit-2.10.2.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Work around MyersDiff infinite loop in PatchListLoader. If the MyersDiff diff
 doesn't finish within 5 seconds, interrupt it and fall back to a different diff
@@ -16,15 +14,13 @@
 loop is detected is that the files in the commit will not be compared in-depth,
 which will result in bigger edit regions.
 
-Secondary Index
----------------
+== Secondary Index
 
 * Online reindexing: log the number of done/failed changes in the error_log.
 Administrators can use the logged information to decide whether to activate the
 new index version or not.
 
-Gitweb
-------
+== Gitweb
 
 * Do not return `Forbidden` when clicking on Gitweb breadcrumb. Now when the
 user clicks on the parent folder, redirect to Gerrit projects list screen with
diff --git a/ReleaseNotes/ReleaseNotes-2.10.3.1.txt b/ReleaseNotes/ReleaseNotes-2.10.3.1.txt
index 39312eb..7777bd8 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.3.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.3.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.3.1
-=================================
+= Release notes for Gerrit 2.10.3.1
 
 There are no schema changes from link:ReleaseNotes-2.10.3.html[2.10.3].
 
diff --git a/ReleaseNotes/ReleaseNotes-2.10.3.txt b/ReleaseNotes/ReleaseNotes-2.10.3.txt
index f7a69c3..1dd96e7 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.10.3
-===============================
+= Release notes for Gerrit 2.10.3
 
 Download:
 link:https://www.gerritcodereview.com/download/gerrit-2.10.3.war[
 https://www.gerritcodereview.com/download/gerrit-2.10.3.war]
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* There are no schema changes from
 link:ReleaseNotes-2.10.2.html[2.10.2], but Bouncycastle was upgraded to 1.51.
@@ -26,8 +24,7 @@
   java -jar gerrit.war init -d site_path
 ----
 
-New Features
-------------
+== New Features
 
 * Support hybrid OpenID and OAuth2 authentication
 +
@@ -36,15 +33,13 @@
 Particularly, linking of user identities across protocol boundaries and even from
 one OAuth2 identity to another OAuth2 identity wasn't implemented yet.
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * Allow to configure
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10.3/config-gerrit.html#sshd.rekeyBytesLimit[
 SSHD rekey parameters].
 
-SSH
----
+== SSH
 
 * Update SSHD to 0.14.0.
 +
@@ -56,8 +51,7 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=2797[Issue 2797]:
 Add support for ECDSA based public key authentication.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Prevent wrong content type for CSS files.
 +
@@ -69,8 +63,7 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=3289[Issue 3289]:
 Prevent NullPointerException in Gitweb servlet.
 
-Replication plugin
-~~~~~~~~~~~~~~~~~~
+=== Replication plugin
 
 * Set connection timeout to 120 seconds for SSH remote operations.
 +
@@ -78,8 +71,7 @@
 operation. By setting a timeout, we ensure the operation does not get stuck
 forever, essentially blocking all future remote git creation operations.
 
-OAuth extension point
-~~~~~~~~~~~~~~~~~~~~~
+=== OAuth extension point
 
 * Respect servlet context path in URL for login token
 +
@@ -90,34 +82,29 @@
 +
 After web session cache expiration there is no way to re-sign-in into Gerrit.
 
-Daemon
-~~~~~~
+=== Daemon
 
 * Print proper names for tasks in output of `show-queue` command.
 +
 Some tasks were not displayed with the proper name.
 
-Web UI
-~~~~~~
+=== Web UI
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3044[Issue 3044]:
 Remove stripping `#` in login redirect.
 
-SSH
-~~~
+=== SSH
 
 * Prevent double authentication for the same public key.
 
 
-Performance
------------
+== Performance
 
 * Improved performance when creating a new branch on a repository with a large
 number of changes.
 
 
-Upgrades
---------
+== Upgrades
 
 * Update Bouncycastle to 1.51.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.10.4.txt b/ReleaseNotes/ReleaseNotes-2.10.4.txt
index c16e7e9..c69a946 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.4.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.4
-===============================
+= Release notes for Gerrit 2.10.4
 
 There are no schema changes from link:ReleaseNotes-2.10.3.1.html[2.10.3.1].
 
@@ -7,8 +6,7 @@
 link:https://www.gerritcodereview.com/download/gerrit-2.10.4.war[
 https://www.gerritcodereview.com/download/gerrit-2.10.4.war]
 
-New Features
-------------
+== New Features
 
 * Support identity linking in hybrid OpenID and OAuth2 authentication.
 +
@@ -20,8 +18,7 @@
 Linking of user identities from one OAuth2 identity to another OAuth2
 identity is supported.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3300[Issue 3300]:
 Fix >10x performance degradation for Git push and replication operations.
@@ -35,15 +32,13 @@
 The padding was not flushed, which caused the downloaded patch to not be
 valid base64.
 
-OAuth extension point
-~~~~~~~~~~~~~~~~~~~~~
+=== OAuth extension point
 
 * Check for session validity during logout.
 +
 When user was trying to log out, after Gerrit restart, the session was
 invalidated and IllegalStateException was recorded in the error_log.
 
-Updates
--------
+== Updates
 
 * Update jgit to 4.0.0.201505050340-m2.
diff --git a/ReleaseNotes/ReleaseNotes-2.10.5.txt b/ReleaseNotes/ReleaseNotes-2.10.5.txt
index eb48c31..a221b58 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.5.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.5
-===============================
+= Release notes for Gerrit 2.10.5
 
 There are no schema changes from link:ReleaseNotes-2.10.4.html[2.10.4].
 
@@ -7,8 +6,7 @@
 link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.5.war[
 https://gerrit-releases.storage.googleapis.com/gerrit-2.10.5.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Update JGit to include a memory leak fix as discussed
 link:https://groups.google.com/forum/#!topic/repo-discuss/RRQT_xCqz4o[here]
@@ -21,7 +19,6 @@
 * Fixed a regression caused by the defaultValue feature which broke the ability
 to remove labels in subprojects
 
-Updates
--------
+== Updates
 
 * Update JGit to v4.0.0.201506090130-r
diff --git a/ReleaseNotes/ReleaseNotes-2.10.6.txt b/ReleaseNotes/ReleaseNotes-2.10.6.txt
index 94a95bd..7c12d11 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.6.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.6
-===============================
+= Release notes for Gerrit 2.10.6
 
 There are no schema changes from link:ReleaseNotes-2.10.5.html[2.10.5].
 
@@ -7,8 +6,7 @@
 link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.6.war[
 https://gerrit-releases.storage.googleapis.com/gerrit-2.10.6.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix generation of licenses in documentation.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.10.7.txt b/ReleaseNotes/ReleaseNotes-2.10.7.txt
index 28cf37b..f369999 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.7.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.7.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10.7
-===============================
+= Release notes for Gerrit 2.10.7
 
 There are no schema changes from link:ReleaseNotes-2.10.6.html[2.10.6].
 
@@ -7,8 +6,7 @@
 link:https://gerrit-releases.storage.googleapis.com/gerrit-2.10.7.war[
 https://gerrit-releases.storage.googleapis.com/gerrit-2.10.7.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3361[Issue 3361]:
 Synchronize Myers diff and Histogram diff invocations to prevent pack file
diff --git a/ReleaseNotes/ReleaseNotes-2.10.txt b/ReleaseNotes/ReleaseNotes-2.10.txt
index f6bd951..4f068cc 100644
--- a/ReleaseNotes/ReleaseNotes-2.10.txt
+++ b/ReleaseNotes/ReleaseNotes-2.10.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.10
-=============================
+= Release notes for Gerrit 2.10
 
 
 Gerrit 2.10 is now available:
@@ -14,8 +13,7 @@
 link:ReleaseNotes-2.9.4.html[Gerrit 2.9.4].
 These bug fixes are *not* listed in these release notes.
 
-Important Notes
----------------
+== Important Notes
 
 
 *WARNING:* This release contains schema changes.  To upgrade:
@@ -44,8 +42,7 @@
 link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
 
 
-Release Highlights
-------------------
+== Release Highlights
 
 
 * Support for externally loaded plugins.
@@ -62,16 +59,13 @@
 can configure the default contents of the menu.
 
 
-New Features
-------------
+== New Features
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 
-Global
-^^^^^^
+==== Global
 
 * Add 'All-Users' project to store meta data for all users.
 
@@ -82,8 +76,7 @@
 * Allow UiActions to perform redirects without JavaScript.
 
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 
 * Display avatar for author, committer, and change owner.
@@ -104,8 +97,7 @@
 Allow to customize Submit button label and tooltip.
 
 
-Side-by-Side Diff Screen
-^^^^^^^^^^^^^^^^^^^^^^^^
+==== Side-by-Side Diff Screen
 
 * Allow the user to select the syntax highlighter.
 
@@ -116,8 +108,7 @@
 * Add syntax highlighting of the commit message.
 
 
-Change List / Dashboards
-^^^^^^^^^^^^^^^^^^^^^^^^
+==== Change List / Dashboards
 
 * Remove age operator when drilling down from a dashboard to a query.
 
@@ -132,8 +123,7 @@
 when 'R' is pressed.  The same binding is added for custom dashboards.
 
 
-Project Screens
-^^^^^^^^^^^^^^^
+==== Project Screens
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2751[Issue 2751]:
 Add support for filtering by regex in project list screen.
@@ -142,8 +132,7 @@
 
 * Add branch actions to 'Projects > Branches' view.
 
-User Preferences
-^^^^^^^^^^^^^^^^
+==== User Preferences
 
 
 * Users can customize the contents of the 'My' menu from the preferences
@@ -156,8 +145,7 @@
 names and 'Show Username' to show usernames in the change list.
 
 
-Secondary Index / Search
-~~~~~~~~~~~~~~~~~~~~~~~~
+=== Secondary Index / Search
 
 
 * Allow to search projects by prefix.
@@ -177,8 +165,7 @@
 rather than just the project name.
 
 
-ssh
-~~~
+=== ssh
 
 
 * Expose SSHD backend in
@@ -187,12 +174,10 @@
 
 * Add support for JCE (Java Cryptography Extension) ciphers.
 
-REST API
-~~~~~~~~
+=== REST API
 
 
-General
-^^^^^^^
+==== General
 
 
 * Remove `kind` attribute from REST containers.
@@ -201,8 +186,7 @@
 
 * Accept `HEAD` in RestApiServlet.
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-accounts.html#get-user-preferences[
@@ -211,8 +195,7 @@
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-accounts.html#set-user-preferences[
 Set user preferences].
 
-Changes
-^^^^^^^
+==== Changes
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2338[Issue 2338]:
@@ -226,8 +209,7 @@
 If the `other-branches` option is specified, the mergeability will also be
 checked for all other branches.
 
-Config
-^^^^^^
+==== Config
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#list-tasks[
@@ -254,8 +236,7 @@
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-config.html#get-summary[
 Get server summary].
 
-Projects
-^^^^^^^^
+==== Projects
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-projects.html#ban-commit[
@@ -276,8 +257,7 @@
 list projects endpoint] to support query offset.
 
 
-Daemon
-~~~~~~
+=== Daemon
 
 
 * Add change subject to output of change URL on push.
@@ -292,8 +272,7 @@
 Add change kind to PatchSetCreatedEvent.
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * Use
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/config-gerrit.html#core.useRecursiveMerge[
@@ -344,8 +323,7 @@
 configure Tomcat] to allow embedded slashes.
 
 
-Misc
-~~~~
+=== Misc
 
 * Don't allow empty user name and passwords in InternalAuthBackend.
 
@@ -353,8 +331,7 @@
 Add change-owner parameter to gerrit hooks.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
 * Support for externally loaded plugins.
 +
@@ -428,11 +405,9 @@
 ** Star/unstar changes
 ** Check if revision needs rebase
 
-Bug Fixes
----------
+== Bug Fixes
 
-General
-~~~~~~~
+=== General
 
 * Use fixed rate instead of fixed delay for log file compression.
 +
@@ -453,8 +428,7 @@
 made, but one was not being closed. This eventually caused resource exhaustion
 and LDAP authentications failed.
 
-Access Permissions
-~~~~~~~~~~~~~~~~~~
+=== Access Permissions
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2995[Issue 2995]:
 Fix faulty behaviour in `BLOCK` permission.
@@ -464,11 +438,9 @@
 case the `BLOCK` would always win for the child, even though the `BLOCK` was
 overruled in the parent.
 
-Web UI
-~~~~~~
+=== Web UI
 
-General
-^^^^^^^
+==== General
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2595[Issue 2595]:
 Make gitweb redirect to login.
@@ -486,8 +458,7 @@
 if a site administrator ran `java -war gerrit.war init -d /some/existing/site --batch`.
 
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 * Don't linkify trailing dot or comma in messages.
 +
@@ -525,8 +496,7 @@
 * Fix exception when clicking on a binary file without being signed in.
 
 
-Side-By-Side Diff
-^^^^^^^^^^^^^^^^^
+==== Side-By-Side Diff
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2970[Issue 2970]:
 Fix misalignment of side A and side B for long insertion/deletion blocks.
@@ -556,30 +526,25 @@
 * Include content on identical files with mode change.
 
 
-User Settings
-^^^^^^^^^^^^^
+==== User Settings
 
 * Avoid loading all SSH keys when adding a new one.
 
 
-Secondary Index / Search
-~~~~~~~~~~~~~~~~~~~~~~~~
+=== Secondary Index / Search
 
 
 * Omit corrupt changes from search results.
 
 * Allow illegal label names from default search predicate.
 
-REST
-~~~~
+=== REST
 
-General
-^^^^^^^
+==== General
 
 * Fix REST API responses for 3xx and 4xx classes.
 
-Changes
-^^^^^^^
+==== Changes
 
 * Fix inconsistent behaviour in the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#add-reviewer[
@@ -589,8 +554,7 @@
 to add a user who had no visibility to the change or whose account was invalid.
 
 
-Changes
-^^^^^^^
+==== Changes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2583[Issue 2583]:
 Reject inline comments on files that do not exist in the patch set.
@@ -620,8 +584,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.10/rest-api-changes.html#list-comments[
 List Comments] endpoint.
 
-SSH
-~~~
+=== SSH
 
 
 * Prevent double authentication for the same public key.
@@ -641,8 +604,7 @@
 from being logged when `git receive-pack` was executed instead of `git-receive-pack`.
 
 
-Daemon
-~~~~~~
+=== Daemon
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2284[Issue 2284]:
@@ -661,11 +623,9 @@
 directly pushed changes.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
-General
-^^^^^^^
+==== General
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2895[Issue 2895]:
@@ -680,8 +640,7 @@
 
 * Fix ChangeListener auto-registered implementations.
 
-Replication
-^^^^^^^^^^^
+==== Replication
 
 
 * Move replication logs into a separate file.
@@ -691,8 +650,7 @@
 * Show replication ID in the log and in show-queue command.
 
 
-Upgrades
---------
+== Upgrades
 
 
 * Update Guava to 17.0
diff --git a/ReleaseNotes/ReleaseNotes-2.11.1.txt b/ReleaseNotes/ReleaseNotes-2.11.1.txt
index be19fc5..3583421 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.1
-===============================
+= Release notes for Gerrit 2.11.1
 
 Gerrit 2.11.1 is now available:
 
@@ -14,8 +13,7 @@
 There are no schema changes from link:ReleaseNotes-2.11.html[2.11].
 
 
-New Features
-------------
+== New Features
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=321[Issue 321]:
 Use in-memory Lucene index for a better reviewer suggestion.
@@ -27,11 +25,9 @@
 suggest.fullTextSearchRefresh] parameter.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-Performance
-~~~~~~~~~~~
+=== Performance
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3363[Issue 3363]:
 Fix performance degrade in background mergeability checks.
@@ -57,8 +53,7 @@
 +
 The change edit information was being loaded twice.
 
-Index
-~~~~~
+=== Index
 
 * Fix `PatchLineCommentsUtil.draftByChangeAuthor`.
 +
@@ -67,8 +62,7 @@
 
 * Don't show stack trace when failing to build BloomFilter during reindex.
 
-Permissions
-~~~~~~~~~~~
+=== Permissions
 
 * Require 'View Plugins' capability to list plugins through SSH.
 +
@@ -83,8 +77,7 @@
 edit the `project.config` file.
 
 
-Change Screen / Diff / Inline Edit
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== Change Screen / Diff / Inline Edit
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3191[Issue 3191]:
 Always show 'Not Current' as state when looking at old patch set.
@@ -103,8 +96,7 @@
 In the side-by-side diff, the cursor is placed on the first column of the diff,
 rather than at the end.
 
-Web Container
-~~~~~~~~~~~~~
+=== Web Container
 
 * Fix `gc_log` when running in a web container.
 +
@@ -117,8 +109,7 @@
 web container with the site path configured using the `gerrit.site_path`
 property.
 
-Plugins
-~~~~~~~
+=== Plugins
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3310[Issue 3310]:
 Fix disabling plugins when Gerrit is running on Windows.
@@ -137,8 +128,7 @@
 When `replicateOnStartup` is enabled, the plugin was not emitting the status
 events after the initial sync.
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3328[Issue 3328]:
 Allow to push a tag that points to a non-commit object.
@@ -168,8 +158,7 @@
 
 * Assume change kind is 'rework' if `LargeObjectException` occurs.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3325[Issue 3325]:
 Add missing `--newrev` parameter to the
@@ -185,8 +174,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11.1/config-gerrit.html#auth.registerUrl[
 auth types].
 
-Updates
--------
+== Updates
 
 * Update CodeMirror to 5.0.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.11.10.txt b/ReleaseNotes/ReleaseNotes-2.11.10.txt
index 9ad34b6..a352aac 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.10.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.10.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.10
-================================
+= Release notes for Gerrit 2.11.10
 
 Gerrit 2.11.10 is now available:
 
@@ -8,8 +7,7 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.9.html[2.11.9].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix synchronization of Myers diff and Histogram diff invocations.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.11.2.txt b/ReleaseNotes/ReleaseNotes-2.11.2.txt
index 07f99ae..98e66b0 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.2
-===============================
+= Release notes for Gerrit 2.11.2
 
 Gerrit 2.11.2 is now available:
 
@@ -12,8 +11,7 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.1.html[2.11.1].
 
-New Features
-------------
+== New Features
 
 New SSH commands:
 
@@ -29,8 +27,7 @@
 problems.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2761[Issue 2761]:
 Fix incorrect file list when comparing patchsets.
@@ -96,8 +93,7 @@
 
 * Print proper name for reindex after update tasks in `show-queue` command.
 
-Updates
--------
+== Updates
 
 * Update JGit to 4.0.1.201506240215-r.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.11.3.txt b/ReleaseNotes/ReleaseNotes-2.11.3.txt
index 0df3a29..f705d1e 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.3.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.3
-===============================
+= Release notes for Gerrit 2.11.3
 
 Gerrit 2.11.3 is now available:
 
@@ -9,8 +8,7 @@
 There are no schema changes from link:ReleaseNotes-2.11.2.html[2.11.2].
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Do not suggest inactive accounts.
 +
@@ -86,8 +84,7 @@
 caused by plugins not loading that an admin should pay attention to and try to
 resolve.
 
-Updates
--------
+== Updates
 
 * Update Guice to 4.0.
 * Replace parboiled 1.1.7 with grappa 1.0.4.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.4.txt b/ReleaseNotes/ReleaseNotes-2.11.4.txt
index 6037edd..cfa8576 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.4.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.4
-===============================
+= Release notes for Gerrit 2.11.4
 
 Gerrit 2.11.4 is now available:
 
@@ -13,8 +12,7 @@
 There are no schema changes from link:ReleaseNotes-2.11.3.html[2.11.3].
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix NullPointerException in `ls-project` command with `--has-acl-for` option.
 +
@@ -131,8 +129,7 @@
 was a merge commit, or if the change being viewed conflicted with an open merge
 commit.
 
-Plugin Bugfixes
----------------
+== Plugin Bugfixes
 
 * singleusergroup: Allow to add a user to a project's ACL using `user/username`.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.11.5.txt b/ReleaseNotes/ReleaseNotes-2.11.5.txt
index d7758cb..6957827 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.5.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.5
-===============================
+= Release notes for Gerrit 2.11.5
 
 Gerrit 2.11.5 is now available:
 
@@ -9,8 +8,7 @@
 There are no schema changes from link:ReleaseNotes-2.11.4.html[2.11.4].
 
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* This release uses a forked version of buck.
 
@@ -25,8 +23,7 @@
 ----
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3442[Issue 3442]:
 Handle commit validation errors when creating/editing changes via REST.
diff --git a/ReleaseNotes/ReleaseNotes-2.11.6.txt b/ReleaseNotes/ReleaseNotes-2.11.6.txt
index d6f939f..977ea14 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.6.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.6
-===============================
+= Release notes for Gerrit 2.11.6
 
 Gerrit 2.11.6 is now available:
 
@@ -8,11 +7,9 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.5.html[2.11.5].
 
-Bug Fixes
----------
+== Bug Fixes
 
-General
-~~~~~~~
+=== General
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3742[Issue 3742]:
 Use merge strategy for mergeability testing on 'Rebase if Necessary' strategy.
@@ -51,8 +48,7 @@
 couldn't be added as a reviewer by selecting it from the suggested list of
 accounts.
 
-Authentication
-~~~~~~~~~~~~~~
+=== Authentication
 
 * Fix handling of lowercase HTTP username.
 +
@@ -69,8 +65,7 @@
 of the user for the claimed identity would fail, causing a new account to be
 created.
 
-UI
-~~
+=== UI
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3714[Issue 3714]:
 Improve visibility of comments on dark themes.
@@ -78,8 +73,7 @@
 * Fix highlighting of search results and trailing whitespaces in intraline
 diff chunks.
 
-Plugins
-~~~~~~~
+=== Plugins
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3768[Issue 3768]:
 Fix usage of `EqualsFilePredicate` in plugins.
@@ -115,8 +109,7 @@
 
 ** Allow to use GWTORM `Key` classes.
 
-Documentation Updates
----------------------
+== Documentation Updates
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=412[Issue 412]:
 Update documentation of `commentlink.match` regular expression to clarify
diff --git a/ReleaseNotes/ReleaseNotes-2.11.7.txt b/ReleaseNotes/ReleaseNotes-2.11.7.txt
index 7a0de2d..6742279 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.7.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.7.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.7
-===============================
+= Release notes for Gerrit 2.11.7
 
 Gerrit 2.11.7 is now available:
 
@@ -8,8 +7,7 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.6.html[2.11.6].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3882[Issue 3882]:
 Fix 'No user on email thread' exception when label with group parameter is
diff --git a/ReleaseNotes/ReleaseNotes-2.11.8.txt b/ReleaseNotes/ReleaseNotes-2.11.8.txt
index 0f9dc21..0aa8dfc 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.8.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.8.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.8
-===============================
+= Release notes for Gerrit 2.11.8
 
 Gerrit 2.11.8 is now available:
 
@@ -8,8 +7,7 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.7.html[2.11.7].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Upgrade Apache commons-collections to version 3.2.2.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.11.9.txt b/ReleaseNotes/ReleaseNotes-2.11.9.txt
index c5b431f..52ee3fe 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.9.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.9.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11.9
-===============================
+= Release notes for Gerrit 2.11.9
 
 Gerrit 2.11.9 is now available:
 
@@ -8,8 +7,7 @@
 
 There are no schema changes from link:ReleaseNotes-2.11.8.html[2.11.8].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=4070[Issue 4070]:
 Don't return current patch set in queries if the current patch set is not
diff --git a/ReleaseNotes/ReleaseNotes-2.11.txt b/ReleaseNotes/ReleaseNotes-2.11.txt
index 44c5398..1ca6825 100644
--- a/ReleaseNotes/ReleaseNotes-2.11.txt
+++ b/ReleaseNotes/ReleaseNotes-2.11.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.11
-=============================
+= Release notes for Gerrit 2.11
 
 
 Gerrit 2.11 is now available:
@@ -14,8 +13,7 @@
 These bug fixes are *not* listed in these release notes.
 
 
-Important Notes
----------------
+== Important Notes
 
 
 *WARNING:* This release contains schema changes.  To upgrade:
@@ -82,8 +80,7 @@
 
 *WARNING:* The deprecated '/query' URL is removed and will now return `Not Found`.
 
-Release Highlights
-------------------
+== Release Highlights
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=505[Issue 505]:
@@ -95,16 +92,13 @@
 * The old change screen is removed.
 
 
-New Features
-------------
+== New Features
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 [[inline-editing]]
-Inline Editing
-^^^^^^^^^^^^^^
+==== Inline Editing
 
 Refer to the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/user-inline-edit.html[
@@ -133,8 +127,7 @@
 
 * Files can be added, deleted, restored and modified directly in browser.
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 * Remove the 'Edit Message' button from the change screen.
 +
@@ -175,8 +168,7 @@
 * Show changes across all projects and branches in the `Same Topic` tab.
 
 
-Side-By-Side Diff
-^^^^^^^^^^^^^^^^^
+==== Side-By-Side Diff
 
 * New button to switch between side-by-side diff and unified diff.
 
@@ -206,8 +198,7 @@
 ** Soy
 
 
-Projects Screen
-^^^^^^^^^^^^^^^
+==== Projects Screen
 
 * Add pagination and filtering on the branch list page.
 
@@ -220,17 +211,14 @@
 browser, which is useful since it is possible that not all configuration options
 are available in the UI.
 
-REST
-~~~~
+=== REST
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 * Add new link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-accounts.html#suggest-account[
 Suggest Account endpoint].
 
-Changes
-^^^^^^^
+==== Changes
 
 * The link:https://gerrit-review.googlesource.com/Documentation/2.10/rest-api-changes.html#message[
 Edit Commit Message] endpoint is removed in favor of the new
@@ -260,8 +248,7 @@
 Get Change Detail] endpoint.
 
 
-Change Edits
-^^^^^^^^^^^^
+==== Change Edits
 
 Several new endpoints are added to support the inline edit feature.
 
@@ -296,8 +283,7 @@
 Delete Change Edit].
 
 
-Projects
-^^^^^^^^
+==== Projects
 
 * Add new
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-projects.html#delete-branches[
@@ -316,8 +302,7 @@
 Get Tag] endpoint.
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * Add support for
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#auth.httpExternalIdHeader[
@@ -395,8 +380,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/config-gerrit.html#container.daemonOpt[
 options to pass to the daemon].
 
-Daemon
-~~~~~~
+=== Daemon
 
 * Allow to enable the http daemon when running in slave mode.
 +
@@ -416,8 +400,7 @@
 * Don't send 'new patch set' notification emails for trivial rebases.
 
 
-SSH
-~~~
+=== SSH
 
 * Add new commands
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-logging-ls-level.html[
@@ -448,8 +431,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/cmd-set-account.html[
 `set-account` SSH command].
 
-Email
-~~~~~
+=== Email
 
 * Add `$change.originalSubject` field for email templates.
 +
@@ -464,11 +446,9 @@
 field during first use.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
-General
-^^^^^^^
+==== General
 
 * Plugins can listen to account group membership changes.
 +
@@ -518,17 +498,14 @@
 ** Get comments and drafts.
 ** Get change edit.
 
-Replication
-^^^^^^^^^^^
+==== Replication
 
 * Projects can be specified with wildcard in the `start` command.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-Daemon
-~~~~~~
+=== Daemon
 
 * Change 'Merge topic' to 'Merge changes from topic'.
 +
@@ -564,8 +541,7 @@
 of a repository with a space in its name was impossible.
 
 
-Secondary Index / Search
-~~~~~~~~~~~~~~~~~~~~~~~~
+=== Secondary Index / Search
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2822[Issue 2822]:
 Improve Lucene analysis of words linked with underscore or dot.
@@ -576,8 +552,7 @@
 * Fix support for `change~branch~id` in query syntax.
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 [[remove-generate-http-password-capability]]
 * Remove the 'Generate HTTP Password' capability.
@@ -608,17 +583,14 @@
 read from `hooks.changeMerged`. Fix to use `hooks.changeMergedHook` as
 documented.
 
-Web UI
-~~~~~~
+=== Web UI
 
-Change List
-^^^^^^^^^^^
+==== Change List
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3304[Issue 3304]:
 Always show a tooltip on the label column entries.
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3147[Issue 3147]:
 Allow to disable muting of common path prefixes in the file list.
@@ -742,8 +714,7 @@
 Align parent weblinks with parent commits in the commit box.
 
 
-Side-By-Side Diff
-^^^^^^^^^^^^^^^^^
+==== Side-By-Side Diff
 
 * Return to normal mode after editing a draft comment.
 +
@@ -756,18 +727,15 @@
 highlighter.
 
 
-Project Screen
-^^^^^^^^^^^^^^
+==== Project Screen
 
 * Fix alignment of checkboxes on project access screen.
 +
 The 'Exclusive' checkbox was not aligned with the other checkboxes.
 
-REST API
-~~~~~~~~
+=== REST API
 
-Changes
-^^^^^^^
+==== Changes
 
 * Remove the administrator restriction on the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.11/rest-api-changes.html#index-change[
@@ -800,8 +768,7 @@
 `409 Conflict`.
 
 
-Projects
-^^^^^^^^
+==== Projects
 
 * Make it mandatory to specify at least one of the `--prefix`, `--match` or `--regex`
 options in the
@@ -826,11 +793,9 @@
 others.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
-Replication
-^^^^^^^^^^^
+==== Replication
 
 * Create missing repositories on the remote when replicating with the git
 protocol.
@@ -843,8 +808,7 @@
 create a project on the remote.
 
 
-Upgrades
---------
+== Upgrades
 
 * Update Antlr to 3.5.2.
 
diff --git a/ReleaseNotes/ReleaseNotes-2.12.1.txt b/ReleaseNotes/ReleaseNotes-2.12.1.txt
index f49de7d..e746d6e 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.12.1
-===============================
+= Release notes for Gerrit 2.12.1
 
 Gerrit 2.12.1 is now available:
 
@@ -11,8 +10,7 @@
 link:ReleaseNotes-2.11.7.html[Gerrit 2.11.7]. These bug fixes are *not*
 listed in these release notes.
 
-Schema Upgrade
---------------
+== Schema Upgrade
 
 *WARNING:* This version includes a manual schema upgrade when upgrading
 from 2.12.
@@ -48,11 +46,9 @@
 necessary and should be omitted.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-General
-~~~~~~~
+=== General
 
 * Fix column type for signed push certificates.
 +
@@ -160,8 +156,7 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=3883[Issue 3883]:
 Respect the `core.commentchar` setting from `.gitconfig` in `commit-msg` hook.
 
-UI
-~~
+=== UI
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3894[Issue 3894]:
 Fix display of 'Related changes' after change is rebased in web UI:
@@ -194,8 +189,7 @@
 * Improve tooltip on 'Submit' button when 'Submit whole topic' is enabled
 and the topic can't be submitted due to some changes not being ready.
 
-Plugins
-~~~~~~~
+=== Plugins
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=3821[Issue 3821]:
 Fix repeated reloading of plugins when running on OpenJDK 8.
@@ -223,14 +217,12 @@
 Allow plugins to suggest reviewers based on either change or project
 resources.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
 * Update documentation of `commentlink` to reflect changed search URL.
 
 * Add missing documentation of valid `database.type` values.
 
-Upgrades
---------
+== Upgrades
 
 * Upgrade JGit to 4.1.2.201602141800-r.
diff --git a/ReleaseNotes/ReleaseNotes-2.12.2.txt b/ReleaseNotes/ReleaseNotes-2.12.2.txt
index 500b015..8292eb5 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.2.txt
@@ -1,13 +1,11 @@
-Release notes for Gerrit 2.12.2
-===============================
+= Release notes for Gerrit 2.12.2
 
 Gerrit 2.12.2 is now available:
 
 link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war[
 https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war]
 
-Schema Upgrade
---------------
+== Schema Upgrade
 
 *WARNING:* There are no schema changes from link:ReleaseNotes-2.12.1.html[
 2.12.1] but a manual schema upgrade is necessary when upgrading from 2.12.
@@ -43,8 +41,7 @@
 done the migration, this manual step is not necessary and should be omitted.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Upgrade Apache commons-collections to version 3.2.2.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.12.3.txt b/ReleaseNotes/ReleaseNotes-2.12.3.txt
index dba10e9..f51d739 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.3.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.12.3
-===============================
+= Release notes for Gerrit 2.12.3
 
 Gerrit 2.12.3 is now available:
 
@@ -11,8 +10,7 @@
 link:ReleaseNotes-2.11.9.html[Gerrit 2.11.9]. These bug fixes are *not*
 listed in these release notes.
 
-Schema Upgrade
---------------
+== Schema Upgrade
 
 *WARNING:* There are no schema changes from link:ReleaseNotes-2.12.2.html[
 2.12.2] but a manual schema upgrade is necessary when upgrading from 2.12.
@@ -49,8 +47,7 @@
 should be omitted.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix SSL security issue in the SMTP email relay.
 +
@@ -111,7 +108,6 @@
 
 * Show an error message when trying to add a non-existent group to an ACL.
 
-Updates
--------
+== Updates
 
 * Update commons-validator to 1.5.1.
diff --git a/ReleaseNotes/ReleaseNotes-2.12.txt b/ReleaseNotes/ReleaseNotes-2.12.txt
index e8e8aec..84644e8 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.12
-=============================
+= Release notes for Gerrit 2.12
 
 
 Gerrit 2.12 is now available:
@@ -7,8 +6,7 @@
 link:https://www.gerritcodereview.com/download/gerrit-2.12.war[
 https://www.gerritcodereview.com/download/gerrit-2.12.war]
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
@@ -39,8 +37,7 @@
 `refs/*/master` instead of `Plain` and `master`.
 
 
-Release Highlights
-------------------
+== Release Highlights
 
 This release includes the following new features. See the sections below for
 further details.
@@ -50,11 +47,9 @@
 * Support for GPG Keys and signed pushes.
 
 
-New Features
-------------
+== New Features
 
-New Change Submission Workflows
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== New Change Submission Workflows
 
 * New 'Submit Whole Topic' setting.
 +
@@ -76,8 +71,7 @@
 enter the 'Submitted, Merge Pending' state.
 
 
-GPG Keys and Signed Pushes
-~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== GPG Keys and Signed Pushes
 
 * Signed push can be enabled by setting
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.enableSignedPush[
@@ -102,8 +96,7 @@
 `receive.certNonceSlop`].
 
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3333[Issue 3333]:
 Support searching for changes by author and committer.
@@ -141,11 +134,9 @@
 sense to enforce all changes to be written to disk ASAP.
 
 
-UI
-~~
+=== UI
 
-General
-^^^^^^^
+==== General
 
 * Edit and diff preferences can be modified from the user preferences screen.
 +
@@ -165,14 +156,12 @@
 users.
 
 
-Project Screen
-^^^^^^^^^^^^^^
+==== Project Screen
 
 * New tab to list the project's tags, similar to the branch list.
 
 
-Inline Editor
-^^^^^^^^^^^^^
+==== Inline Editor
 
 * Store and load edit preferences in git.
 +
@@ -187,8 +176,7 @@
 * Add support for Emacs and Vim key maps.
 
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3318[Issue 3318]:
 Highlight 'Reply' button if there are draft comments on any patch set.
@@ -216,8 +204,7 @@
 This helps to identify changes when the subject is truncated in the list.
 
 
-Side-By-Side Diff
-^^^^^^^^^^^^^^^^^
+==== Side-By-Side Diff
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3293[Issue 3293]:
 Add syntax highlighting for Puppet.
@@ -226,40 +213,34 @@
 Add syntax highlighting for VHDL.
 
 
-Group Screen
-^^^^^^^^^^^^
+==== Group Screen
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=1479[Issue 1479]:
 The group screen now includes an 'Audit Log' panel showing member additions,
 removals, and the user who made the change.
 
 
-API
-~~~
+=== API
 
 Several new APIs are added.
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 * Suggest accounts.
 
-Tags
-^^^^
+==== Tags
 
 * List tags.
 
 * Get tag.
 
 
-REST API
-~~~~~~~~
+=== REST API
 
 New REST API endpoints and new options on existing endpoints.
 
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#set-username[
 Set Username]: Set the username of an account.
@@ -276,8 +257,7 @@
 account.
 
 
-Changes
-^^^^^^^
+==== Changes
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
 Set Review]: Add an option to omit duplicate comments.
@@ -294,8 +274,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
 Set Review]: Add an option to publish draft comments on all revisions.
 
-Config
-^^^^^^
+==== Config
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-config.html#get-info[
 Get Server Info]: Return information about the Gerrit server configuration.
@@ -304,8 +283,7 @@
 Confirm Email]: Confirm that the user owns an email address.
 
 
-Groups
-^^^^^^
+==== Groups
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-groups.html#list-group[
 List Groups]: Add option to suggest groups.
@@ -317,8 +295,7 @@
 additions, removals, and the user who made the change.
 
 
-Projects
-^^^^^^^^
+==== Projects
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-projects.html#run-gc[
 Run GC]: Add `aggressive` option to specify whether or not to run an aggressive
@@ -329,8 +306,7 @@
 `--start` and `--end`.
 
 
-SSH
-~~~
+=== SSH
 
 * Add support for ZLib Compression.
 +
@@ -340,11 +316,9 @@
 
 * Add support for hmac-sha2-256 and hmac-sha2-512 as MACs.
 
-Plugins
-~~~~~~~
+=== Plugins
 
-General
-^^^^^^^
+==== General
 
 * Gerrit client can now pass JavaScriptObjects to extension panels.
 
@@ -387,8 +361,7 @@
 ** Allow to use GWTORM `Key` classes.
 
 
-Other
-~~~~~
+=== Other
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3401[Issue 3401]:
 Add option to
@@ -433,8 +406,7 @@
 and queues, and invoke the index REST API on changes.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=3499[Issue 3499]:
 Fix syntax highlighting of raw string literals in go.
@@ -536,8 +508,7 @@
 +
 Under some circumstances it was possible to fail with an IO error.
 
-Documentation Updates
----------------------
+== Documentation Updates
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=412[Issue 412]:
 Update documentation of `commentlink.match` regular expression to clarify
@@ -549,8 +520,7 @@
 
 * Document that `ldap.groupBase` and `ldap.accountBase` are repeatable.
 
-Upgrades
---------
+== Upgrades
 
 * Upgrade Asciidoctor to 1.5.2
 
diff --git a/ReleaseNotes/ReleaseNotes-2.13.1.txt b/ReleaseNotes/ReleaseNotes-2.13.1.txt
new file mode 100644
index 0000000..958e726
--- /dev/null
+++ b/ReleaseNotes/ReleaseNotes-2.13.1.txt
@@ -0,0 +1,21 @@
+= Release notes for Gerrit 2.13.1
+
+Gerrit 2.13.1 is now available:
+
+link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war[
+https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war]
+
+== Schema Upgrade
+
+There are no schema changes from link:ReleaseNotes-2.13.html[2.13].
+
+== Bug Fixes
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4618[Issue 4618]:
+Fix internal server error after online reindexing completed.
+
+* Fix internal server error when cloning from slaves and not all refs are
+visible.
+
+* Fix JSON deserialization error causing stream event client to no longer receive
+events.
diff --git a/ReleaseNotes/ReleaseNotes-2.13.2.txt b/ReleaseNotes/ReleaseNotes-2.13.2.txt
new file mode 100644
index 0000000..c7be976
--- /dev/null
+++ b/ReleaseNotes/ReleaseNotes-2.13.2.txt
@@ -0,0 +1,46 @@
+= Release notes for Gerrit 2.13.2
+
+Gerrit 2.13.2 is now available:
+
+link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war[
+https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war]
+
+== Schema Upgrade
+
+There are no schema changes from link:ReleaseNotes-2.13.1.html[2.13.1].
+
+== Bug Fixes
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4630[Issue 4630]:
+Fix server error when navigating up to change while 'Working' is displayed.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4631[Issue 4631]:
+Read project watches from database.
++
+Project watches were being read from the git backend by default, but the
+migration to git is not yet completed.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4632[Issue 4632]:
+Fix server error when deleting multiple SSH keys from the Web UI.
++
+Attempting to delete multiple keys in parallel resulted in a lock failure
+when removing the keys from the git backend.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4645[Issue 4645]:
+Fix malformed account suggestions.
++
+If the query contained several query terms and one of the query terms was
+a substring of 'strong', the suggestion was malformed.
+
+* Hooks plugin: Fix incorrect value passed to `--change-url` parameter.
++
+The URL was being generated using the change's Change-Id rather than the
+change number.
+
+* Check for CLA when creating project config changes from the web UI.
++
+If contributor agreements were enabled and required for a project, and
+the user had not signed a CLA, it was still possible to upload changes
+for review on `refs/meta/config` by making changes in the project access
+editor and pressing 'Save for Review'.
+
diff --git a/ReleaseNotes/ReleaseNotes-2.13.txt b/ReleaseNotes/ReleaseNotes-2.13.txt
new file mode 100644
index 0000000..0afca1a
--- /dev/null
+++ b/ReleaseNotes/ReleaseNotes-2.13.txt
@@ -0,0 +1,471 @@
+= Release notes for Gerrit 2.13
+
+
+Gerrit 2.13 is now available:
+
+link:https://www.gerritcodereview.com/download/gerrit-2.13.war[
+https://www.gerritcodereview.com/download/gerrit-2.13.war]
+
+
+== Important Notes
+
+*WARNING:* This release contains schema changes.  To upgrade:
+----
+  java -jar gerrit.war init -d site_path
+----
+
+*WARNING:* To use online reindexing for `changes` secondary index when upgrading
+to 2.13.x, the server must first be upgraded to 2.8 (or 2.9) and then through
+2.10, 2.11 and 2.12. Skipping a version will prevent the online reindexer from
+working.
+
+Gerrit 2.13 introduces a new secondary index for accounts, and this must be
+indexed offline before starting Gerrit:
+----
+  java -jar gerrit.war reindex --index accounts -d site_path
+----
+
+If reindexing will be done offline, you may ignore these warnings and upgrade
+directly to 2.13.x using the following command that will reindex both `changes`
+and `accounts` secondary indexes:
+----
+  java -jar gerrit.war reindex -d site_path
+----
+
+*WARNING:* The server side hooks functionality is moved to a core plugin. Sites
+that make use of server side hooks must install this plugin during site init.
+
+
+== Release Highlights
+
+* Support for Large File Storage (LFS).
+
+* Metrics interface.
+
+* Hooks plugin.
+
+* Secondary index for accounts.
+
+* File annotations (blame) in side-by-side diff.
+
+== New Features
+
+=== Large File Storage (LFS)
+
+Gerrit provides an
+link:https://gerrit-review.googlesource.com/Documentation/2.13/dev-plugins.html#lfs-extension[
+extension point] that enables development of plugins implementing the
+link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
+LFS protocol].
+
+By setting
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#lfs.plugin[
+`lfs.plugin`] the administrator can configure the name of the plugin
+which handles LFS requests.
+
+=== Access control for git submodule subscriptions
+
+To prevent potential security breaches as described in
+link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3311[issue 3311],
+it is now only possible for a project to subscribe to a submodule if the
+submodule explicitly allows itself to be subscribed.
+
+Please see the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-submodules.html[
+submodules user guide] for details.
+
+Note that when upgrading from an earlier version of Gerrit, permissions for
+any existing subscriptions will be automatically added during the database
+schema migration.
+
+=== Metrics
+
+Metrics about Gerrit's internal state can be sent to external
+monitoring systems.
+
+Plugins can provide implementations of the metrics interface to
+report metrics to different monitoring systems. The following
+plugins are available:
+
+* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
+JMX]
+
+* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
+Graphite]
+
+* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
+Elasticsearch]
+
+Plugins can also provide their own metrics.
+
+See the link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/metrics.html[
+metrics documentation] for further details.
+
+=== Hooks
+
+Server side hooks are moved to the
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+hooks plugin]. Sites that make use of server side hooks should install this
+plugin. After installing the plugin, no additional configuration is needed.
+The plugin uses the same configuration settings in `gerrit.config`.
+
+=== Secondary Index
+
+* The secondary index now supports indexing of accounts.
++
+The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-reindex.html[
+reindex program] by default reindexes all changes and accounts. A new
+option allows to explicitly specify whether to reindex changes or accounts.
++
+The `suggest.fullTextSearch`, `suggest.fullTextSearchMaxMatches` and
+`suggest.fullTextSearchRefresh` configuration options are removed. Full text
+search is supported by default with the account secondary index.
+
+* New ssh command to
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/cmd-index-changes.html[
+reindex changes].
+
+
+=== UI
+
+* The UI can now be loaded in an iFrame by enabling
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#gerrit.canLoadInIFrame[
+gerrit.canLoadInIFrame] in the site configuration.
+
+==== Change Screen
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=106[Issue 106]:
+Allow to select merge commit's parent for diff base in change screen.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3035[Issue 3035]:
+Allow to remove specific votes from a change, while leaving the reviewer on the
+change.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3487[Issue 3487]:
+Use 'Ctrl-Alt-e' instead of 'e' to open edit mode.
+
+==== Diff Screens
+
+* Add all syntax highlighting available in CodeMirror.
+
+* Improve search experience in diff screen
++
+Ctrl-F, Ctrl-G and Shift-Ctrl-G now bind to the search dialog box provided by
+CodeMirror's search add-on. Enter and Shift-Enter navigate among the search
+results from the CodeMirror search, just like they do in a normal browser
+search. Esc now clears the search result.
++
+If the user sets `Render` to `Slow` in the diff preferences and the file is less
+than 4000 lines (huge), then Ctrl-F, Ctrl-G and Shift-Ctrl-G fall back to the
+browser search.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=2968[Issue 2968]:
+Allow to go back to change list by keyboard shortcut from diff screens.
+
+==== Side-By-Side Diff Screen
+
+* Blame annotations
++
+By enabling
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#change.allowBlame[
+`change.allowBlame`], blame annotations can be shown in the side-by-side diff
+screen gutter. Clicking the annotation opens the relevant change.
+
+==== User Preferences
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=989[Issue 989]:
+New option to control email notifications.
++
+Users can now choose between 'Enabled', 'Disabled' and 'CC Me on Comments I Write'.
+
+* New option to control adding 'Signed-off-by' footer in commit message of new changes
+created online.
+
+* New option to control auto-indent width in inline editor.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=890[Issue 890]:
+New diff option to control whether to skip unchanged files when navigating to
+the previous or the next file.
+
+=== Changes
+
+In order to avoid potentially confusing behavior, when submitting changes in a
+batch, submit type rules may not be used to mix submit types on a single branch,
+and trying to submit such a batch will fail.
+
+=== REST API
+
+==== Accounts
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3766[Issue 3766]:
+Allow users with the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#capability_modifyAccount[
+'ModifyAccount' capability] to get the preferences for other users via the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-user-preferences[
+Get User Preferences] endpoint.
+
+* Rename 'Suggest Account' to
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#query-account[
+'Query Account'] and add support for arbitrary account queries.
++
+The `_more_accounts` flag is set on the last result when there are more results
+than the limit. The `DETAILS` and `ALL_EMAILS` options may be set to control
+whether the results should include details (full name, email, username, avatars)
+and all emails, respectively.
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-watched-projects[
+Get Watched Projects].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-watched-projects[
+Set Watched Projects].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#delete-watched-projects[
+Delete Watched Projects].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-stars[
+Get Star Labels from Change].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-stars[
+Update Star Labels on Change].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-oauth-token[
+Get OAuth Access Token].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#list-contributor-agreements[
+List Contributor Agreements].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#sign-contributor-agreement[
+Sign Contributor Agreement].
+
+==== Changes
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3579[Issue 3579]:
+Append submitted info to ChangeInfo.
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-changes.html#move-change[
+Move Change].
+
+==== Groups
+
+* Add `-s` as an alias for `--suggest` on the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-groups.html#suggest-group[
+Suggest Group] endpoint.
+
+==== Projects
+
+* Add `async` option to the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#run-gc[
+Run GC] endpoint to allow garbage collection to run asynchronously.
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-access[
+List Access Rights].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#set-access[
+Add, Update and Delete Access Rights].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#create-tag[
+Create Tag].
+
+* New endpoint:
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-mergeable-info[
+Get Mergeable Information].
+
+=== Plugins
+
+* Secure settings
++
+Plugins may now store secure settings in `etc/$PLUGIN.secure.config` where they
+will be decoded by the Secure Store implementation.
+
+* Exported dependencies
++
+Gson is now an exported dependency. Plugins no longer need to explicitly add
+a dependency on it.
+
+=== Misc
+
+* New project option to reject implicit merge commits.
++
+The 'Reject Implicit Merges' option can be enabled to prevent non-merge commits
+from implicitly bringing unwanted changes into a branch. This can happen for
+example when a commit is made based on one branch but is mistakenly pushed to
+another, for example based on `refs/heads/master` but pushed to `refs/for/stable`.
+
+* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#category_add_patch_set[
+Add Patch Set capability] to control who is allowed to upload a new patch
+set to an existing change.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4015[Issue 4015]:
+Allow setting a
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#message[
+comment message] when uploading a change.
+
+* Allow to specify
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#notify[
+who should be notified by email] when uploading a change.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3220[Issue 3220]:
+Append approval info to every comment-added stream event and hook.
+
+* The `administrateServer` capability can be assigned to groups by setting
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#capability.administrateServer[
+capability.administrateServer] in the site configuration.
++
+Configuring this option can be a useful fail-safe to recover a server in the
+event an administrator removed all groups from the `administrateServer`
+capability, or to ensure that specific groups always have administration
+capabilities.
+
+* New configuration options to configure JGit repository cache parameters.
++
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheCleanupDelay[
+core.repositoryCacheCleanupDelay] and
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheExpireAfter[
+core.repositoryCacheExpireAfter] can be configured.
+
+* Accept `-b` as an alias of `--batch` in the
+link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-init.html[
+init program].
+
+
+== Bug Fixes
+
+* Don't add the same SSH key multiple times.
++
+If an already existing SSH key was added, a duplicate entry was added to the
+list of user's SSH keys.
+
+* Respect the 'Require a valid contributor agreement to upload' setting
+when creating changes via the UI.
++
+If a user had not signed a CLA, it was still possible for them to create a new
+change with the 'Revert' or 'Cherry Pick' button.
+
+* Make Lucene index more stable when being interrupted.
+
+* Don't show the `start` and `idle` columns in the `show-connections`
+output when the ssh backend is NIO2.
++
+The NIO2 backend doesn't provide the start and idle times, and the
+values being displayed were just dummy values. Now these values are
+only displayed for the MINA backend.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4150[Issue 4150]:
+Deleting a draft inline comment no longer causes the change's `Updated` field to
+be bumped.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4099[Issue 4099]:
+Fix SubmitWholeTopic does not update subscriptions.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3603[Issue 3603]:
+Fix editing a submodule via inline edit.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4069[Issue 4069]:
+Fix highlights in scrollbar overview ruler not moved when extending the
+displayed area.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3446[Issue 3446]:
+Respect the `Skip Deleted` diff preference.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3445[Issue 3445]:
+Respect the `Skip Uncommented` diff preference.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4051[Issue 4051]:
+Fix empty `From` email header.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3423[Issue 3423]:
+Fix intraline diff for added spaces.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=1867[Issue 1867]:
+Remove `no changes made` error case when the only difference between a new
+commit and the previous patch set of the change is the committer.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3831[Issue 3831]:
+Prevent creating groups with the same name as a system group.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3754[Issue 3754]:
+Fix `View All Accounts` permission to allow accounts REST endpoint to access
+email info.
+
+* Make `gitweb.type` default to `disabled` when not explicitly set.
++
+Previously the behavior was not documented and it would default to type
+`gitweb`. In cases where there was no gitweb config at all, this would
+result in broken links due to `null` being used as the URL.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4488[Issue 4488]:
+Improve error message when `Change-Id` line is missing in commit message.
++
+The error message now includes the sha1 of the commit, so that it is
+easier to track down which commit failed validation when multiple commits
+are pushed at the same time.
+
+* Don't check mergeability of draft changes.
++
+Draft changes can be deleted but not abandoned so there is no way for
+an administrator to get rid of the them on behalf of the users. This can
+become a problem when there many draft changes because the mergeability
+check can be costly.
++
+The mergeability check is no longer done for draft changes, but will be
+done when the draft change is published.
+
+* Fix internal server error when plugin-provided file history weblink
+is null.
++
+It is valid for a plugin to provide a null weblink, but doing so resulted
+in an internal server error.
+
+== Dependency updates
+
+* Add dependency on blame-cache 0.1-9
+
+* Add dependency on guava-retrying 2.0.0
+
+* Add dependency on jsr305 3.0.1
+
+* Add dependency on metrics-core 3.1.2
+
+* Upgrade auto-value to 1.3-rc1
+
+* Upgrade commons-net to 3.5
+
+* Upgrade CodeMirror to 5.17.0
+
+* Upgrade Guava to 19.0
+
+* Upgrade Gson to 2.7
+
+* Upgrade Guice to 4.1.0
+
+* Upgrade gwtjsonrpc to 1.9
+
+* Upgrade gwtorm to 1.15
+
+* Upgrade javassist to 3.20.0-GA
+
+* Upgrade Jetty to 9.2.14.v20151106
+
+* Upgrade JGit to 4.5.0.201609210915-r
+
+* Upgrade joda-convert to 1.8.1
+
+* Upgrade joda-time to 2.9.4
+
+* Upgrade Lucene to 5.5.0
+
+* Upgrade mina to 2.0.10
+
+* Upgrade sshd-core to 1.2.0
diff --git a/ReleaseNotes/ReleaseNotes-2.2.0.txt b/ReleaseNotes/ReleaseNotes-2.2.0.txt
index 5938f66..5cc54f9 100644
--- a/ReleaseNotes/ReleaseNotes-2.2.0.txt
+++ b/ReleaseNotes/ReleaseNotes-2.2.0.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.2.0
-==============================
+= Release notes for Gerrit 2.2.0
 
 Gerrit 2.2.0 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.2.0.war[https://www.gerritcodereview.com/download/gerrit-2.2.0.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* Upgrading to 2.2.0 requires the server be first upgraded
 to 2.1.7, and then to 2.2.0.
 
@@ -21,11 +19,9 @@
 Git repository. The init based upgrade tool will automatically
 export the current table contents and create the Git data.
 
-New Features
-------------
+== New Features
 
-Project Administration
-~~~~~~~~~~~~~~~~~~~~~~
+=== Project Administration
 * issue 436 List projects by scanning the managed Git directory
 +
 Instead of generating the list of projects from SQL database, the
@@ -52,11 +48,9 @@
 The Access panel of the project administration has been rewritten
 with a new UI that reflects the new Git based storage format.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Project Administration
-~~~~~~~~~~~~~~~~~~~~~~
+=== Project Administration
 * Avoid unnecessary updates to $GIT_DIR/description
 +
 Gerrit always tried to rewrite the gitweb "description" file when the
diff --git a/ReleaseNotes/ReleaseNotes-2.2.1.txt b/ReleaseNotes/ReleaseNotes-2.2.1.txt
index 6a4829e..26aa8db 100644
--- a/ReleaseNotes/ReleaseNotes-2.2.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.2.1.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.2.1
-==============================
+= Release notes for Gerrit 2.2.1
 
 Gerrit 2.2.1 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.2.1.war[https://www.gerritcodereview.com/download/gerrit-2.2.1.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -15,8 +13,7 @@
 *WARNING:* Upgrading to 2.2.x requires the server be first upgraded
 to 2.1.7, and then to 2.2.x.
 
-New Features
-------------
+== New Features
 * Add 'Expand All Comments' checkbox in PatchScreen
 +
 Allows users to save a user preference that automatically expands
@@ -28,8 +25,7 @@
 usage adds a new column of output per project line listing the
 current value of that branch.
 
-Bug Fixes
----------
+== Bug Fixes
 * issue 994 Rename "-- All Projects --" to "All-Projects"
 +
 The name "-- All Projects --.git" is difficult to work with on
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.1.txt b/ReleaseNotes/ReleaseNotes-2.2.2.1.txt
index aabe03a..37f5a76 100644
--- a/ReleaseNotes/ReleaseNotes-2.2.2.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.2.2.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.2.2.1
-================================
+= Release notes for Gerrit 2.2.2.1
 
 Gerrit 2.2.2.1 is now available:
 
@@ -11,8 +10,7 @@
 link:ReleaseNotes-2.2.2.html[ReleaseNotes].
 
 
-Bug Fixes
----------
+== Bug Fixes
 * issue 1139 Fix change state in patch set approval if reviewer is added to
 closed change
 +
@@ -35,8 +33,7 @@
 commit 14246de3c0f81c06bba8d4530e6bf00e918c11b0
 
 
-Documentation
--------------
+== Documentation
 * Update top level SUBMITTING_PATCHES
 +
 This document is out of date, the URLs are from last August.
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.2.txt b/ReleaseNotes/ReleaseNotes-2.2.2.2.txt
index 2747ab0..f50c4e7 100644
--- a/ReleaseNotes/ReleaseNotes-2.2.2.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.2.2.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.2.2.2
-================================
+= Release notes for Gerrit 2.2.2.2
 
 Gerrit 2.2.2.2 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from anything earlier, follow the upgrade
 procedure in the 2.2.2 link:ReleaseNotes-2.2.2.html[ReleaseNotes].
 
-Security Fixes
---------------
+== Security Fixes
 * Some access control sections may be ignored
 +
 Gerrit sometimes ignored an access control section in a project
diff --git a/ReleaseNotes/ReleaseNotes-2.2.2.txt b/ReleaseNotes/ReleaseNotes-2.2.2.txt
index 3889dcc..276714c 100644
--- a/ReleaseNotes/ReleaseNotes-2.2.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.2.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.2.2
-==============================
+= Release notes for Gerrit 2.2.2
 
 Gerrit 2.2.2 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.2.2.war[https://www.gerritcodereview.com/download/gerrit-2.2.2.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -15,11 +13,9 @@
 *WARNING:* Upgrading to 2.2.x requires the server be first upgraded
 to 2.1.7 (or a later 2.1.x version), and then to 2.2.x.
 
-New Features
-------------
+== New Features
 
-Prolog
-~~~~~~
+=== Prolog
 * issue 971 Use Prolog Cafe for ChangeControl.canSubmit()
 
 *  Add per-project prolog submit rule files
@@ -59,8 +55,7 @@
 administrators can play around with by downloading the Gerrit WAR
 file and executing: java -jar gerrit.war prolog-shell
 
-Prolog Predicates
-^^^^^^^^^^^^^^^^^
+==== Prolog Predicates
 *  Add Prolog Predicates to check commit messages and edits
 +
 commit_message returns the commit message as a symbol.
@@ -104,8 +99,7 @@
 it exportable for now until we can come back and clean up the legacy
 approval data code.
 
-Web
-~~~
+=== Web
 
 * Support in Firefox delete key in NpIntTextBox
 +
@@ -118,8 +112,7 @@
 There is a bug in gwt 2.1.0 that prevents pressing special keys like
 Enter, Backspace etc. from being properly recognized and so they have no effect.
 
-ChangeScreen
-^^^^^^^^^^^^
+==== ChangeScreen
 * issue 855 Indicate outdated dependencies on the ChangeScreen
 +
 If a change dependency is no longer the latest patchSet for that
@@ -142,8 +135,7 @@
 permits the submit_rule to make an ApprovalCategory optional, or to
 make a new label required.
 
-Diff Screen
-^^^^^^^^^^^
+==== Diff Screen
 * Add top level menus for a new PatchScreen header
 +
 Modify the PatchScreen so that the header contents is selectable
@@ -183,8 +175,7 @@
 automatic result that Git would create, and the actual result that
 was uploaded by the author/committer of the merge.
 
-Groups
-^^^^^^
+==== Groups
 * Add menu to AccountGroupScreen
 +
 This change introduces a menu in the AccountGroupScreen and
@@ -199,8 +190,7 @@
 'groups' file in the 'refs/meta/config' branch which requires
 the UUID of the group to be known.
 
-Project Access
-^^^^^^^^^^^^^^
+==== Project Access
 * Automatically add new rule when adding new permission
 +
 If a new permission was added to a block, immediately create the new
@@ -218,8 +208,7 @@
 switch back to the "read-only" view where the widgets are all
 disabled and the Edit button is enabled.
 
-Project Branches
-^^^^^^^^^^^^^^^^
+==== Project Branches
 * Display refs/meta/config branch on ProjectBranchesScreen
 +
 The new refs/meta/config branch was not shown in the ProjectBranchesScreen.
@@ -231,8 +220,7 @@
 Since HEAD and refs/meta/config do not represent ordinary branches,
 highlight their rows with a special style in the ProjectBranchesScreen.
 
-URLs
-^^^^
+==== URLs
 * Modernize URLs to be shorter and consistent
 +
 Instead of http://site/#change,1234 we now use a slightly more
@@ -250,8 +238,7 @@
 
 * issue 1018 Accept ~ in linkify() URLs
 
-SSH
-~~~
+=== SSH
 * Added a set-reviewers ssh command
 
 * Support removing more than one reviewer at once
@@ -290,8 +277,7 @@
 will report additional data about the JVM, and tell the caller
 where it is running.
 
-Queries
-^^^^^^^
+==== Queries
 * Output patchset creation date for 'query' command.
 
 * issue 1053 Support comments option in query command
@@ -300,8 +286,7 @@
 used. If --comments is used together with --patch-sets all inline
 comments are included in the output.
 
-Config
-~~~~~~
+=== Config
 * Move batch user priority to a capability
 +
 Instead of using a magical group, use a special capability to
@@ -351,8 +336,7 @@
 This allows aliases which redirect to gerrit's ssh port (say
 from port 22) to be setup and advertised to users.
 
-Dev
-~~~
+=== Dev
 * Updated eclipse settings for 3.7 and m2e 1.0
 
 * Fix build in m2eclipse 1.0
@@ -375,8 +359,7 @@
 switching between different users when using the
 DEVELOPMENT_BECOME_ANY_ACCOUNT authentication type.
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 * Permit adding reviewers to closed changes
 +
 Permit adding a reviewer to closed changes to support post-submit
@@ -412,8 +395,7 @@
 gerrit page load.
 
 
-Performance
------------
+== Performance
 * Bumped Brics version to 1.11.8
 +
 This Brics version fixes a performance issue in some larger Gerrit systems.
@@ -453,8 +435,7 @@
 during the construction of ProjectState is a waste of resources.
 
 
-Upgrades
---------
+== Upgrades
 * Upgrade to GWT 2.3.0
 * Upgrade to Gson to 1.7.1
 * Upgrade to gwtjsonrpc 1.2.4
@@ -463,8 +444,7 @@
 * Upgrade to Brics 1.11.8
 
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix: Issue where Gerrit could not linkify certain URLs
 
 * issue 1015 Fix handling of regex ref patterns in Access panel
@@ -563,11 +543,9 @@
 duplicate account_ids later.
 
 
-Documentation
--------------
+== Documentation
 
-New Documents
-~~~~~~~~~~~~~
+=== New Documents
 * First Cut of Gerrit Walkthrough Introduction documentation.
 +
 Add a new document intended to be a complement for the existing
@@ -580,8 +558,7 @@
 The new document covers quick installation, new project and first
 upload.  It contains lots of quoted output, with a demo style to it.
 
-Access Control
-~~~~~~~~~~~~~~
+=== Access Control
 * Code review
 
 * Conversion table between 2.1 and 2.2
@@ -614,8 +591,7 @@
 +
 Access categories are now sorted to match drop down box in UI
 
-Other Documentation
-~~~~~~~~~~~~~~~~~~~
+=== Other Documentation
 * Added additional information on the install instructions.
 +
 The installation instructions presumes much prior knowledge,
diff --git a/ReleaseNotes/ReleaseNotes-2.3.1.txt b/ReleaseNotes/ReleaseNotes-2.3.1.txt
index 8914c69..627fba5 100644
--- a/ReleaseNotes/ReleaseNotes-2.3.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.3.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.3.1
-==============================
+= Release notes for Gerrit 2.3.1
 
 Gerrit 2.3.1 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from anything earlier, follow the upgrade
 procedure in the 2.3 link:ReleaseNotes-2.3.html[ReleaseNotes].
 
-Security Fixes
---------------
+== Security Fixes
 * Some access control sections may be ignored
 +
 Gerrit sometimes ignored an access control section in a project
diff --git a/ReleaseNotes/ReleaseNotes-2.3.txt b/ReleaseNotes/ReleaseNotes-2.3.txt
index 9cdc886..7a29d0e 100644
--- a/ReleaseNotes/ReleaseNotes-2.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.3
-============================
+= Release notes for Gerrit 2.3
 
 Gerrit 2.3 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.3.war[https://www.gerritcodereview.com/download/gerrit-2.3.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -19,10 +17,8 @@
 upgrade directly to 2.3.x.
 
 
-New Features
-------------
-Drafts
-~~~~~~
+== New Features
+=== Drafts
 * New draft statuses and magic branches
 +
 Adds draft status to Change. DRAFT status in change occurs before NEW
@@ -65,8 +61,7 @@
 * When pushing changes as drafts, output [DRAFT] next to the change link
 
 
-Web
-~~~
+=== Web
 * issue 203 Create project through web interface
 +
 Add a new panel in the Admin->Projects Screen.  It
@@ -106,8 +101,7 @@
 * Disable SSH Keys in the web UI if SSHD is disabled
 
 
-SSH
-~~~
+=== SSH
 * Adds --description (-d) option to ls-projects
 +
 Allows listing of projects together with their respective
@@ -167,8 +161,7 @@
 labels could not be applied due to change being closed.
 
 
-Config
-~~~~~~
+=== Config
 * issue 349 Apply states for projects (active, readonly and hidden)
 +
 Active state indicates the project is regular and is the default value.
@@ -250,8 +243,7 @@
 logic associated with the site header, footer and CSS.
 
 
-Dev
-~~~
+=== Dev
 * Fix 'No source code is available for type org.eclipse.jgit.lib.Constants'
 
 * Fix miscellaneous compiler warnings
@@ -270,8 +262,7 @@
 Fixes java.lang.NoClassDefFoundError: com/google/gwt/dev/DevMode
 
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 * Allow superprojects to subscribe to submodules updates
 +
 The feature introduced in this release allows superprojects to
@@ -370,8 +361,7 @@
 * Improve validation of email registration tokens
 
 
-Upgrades
---------
+== Upgrades
 * Upgrade to gwtorm 1.2
 
 * Upgrade to JGit 1.1.0.201109151100-r.119-gb4495d1
@@ -382,8 +372,7 @@
 * Support Velocity 1.5 (as well as previous 1.6.4)
 
 
-Bug Fixes
----------
+== Bug Fixes
 * Avoid NPE when group is missing
 
 * Do not fail with NPE if context path of request is null
@@ -437,8 +426,7 @@
 * Update top level SUBMITTING_PATCHES URLs
 
 
-Documentation
--------------
+== Documentation
 * Some updates to the design docs
 
 * cmd-index: Fix link to documentation of rename-group command
diff --git a/ReleaseNotes/ReleaseNotes-2.4.1.txt b/ReleaseNotes/ReleaseNotes-2.4.1.txt
index dbe6c4b..f3c4765 100644
--- a/ReleaseNotes/ReleaseNotes-2.4.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.4.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.4.1
-==============================
+= Release notes for Gerrit 2.4.1
 
 Gerrit 2.4.1 is now available:
 
@@ -11,8 +10,7 @@
 link:ReleaseNotes-2.4.html[ReleaseNotes].
 
 
-Bug Fixes
----------
+== Bug Fixes
 * Catch all exceptions when async emailing
 +
 This fixes email notification issues reported
diff --git a/ReleaseNotes/ReleaseNotes-2.4.2.txt b/ReleaseNotes/ReleaseNotes-2.4.2.txt
index 5652d15..d5c2a11 100644
--- a/ReleaseNotes/ReleaseNotes-2.4.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.4.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.4.2
-==============================
+= Release notes for Gerrit 2.4.2
 
 Gerrit 2.4.2 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from anything earlier, follow the upgrade
 procedure in the 2.4 link:ReleaseNotes-2.4.html[ReleaseNotes].
 
-Security Fixes
---------------
+== Security Fixes
 * Some access control sections may be ignored
 +
 Gerrit sometimes ignored an access control section in a project
diff --git a/ReleaseNotes/ReleaseNotes-2.4.3.txt b/ReleaseNotes/ReleaseNotes-2.4.3.txt
index 6745564..ece0bda 100644
--- a/ReleaseNotes/ReleaseNotes-2.4.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.4.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.4.3
-==============================
+= Release notes for Gerrit 2.4.3
 
 There are no schema changes from link:ReleaseNotes-2.4.2.html[2.4.2].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.4.3.war[https://www.gerritcodereview.com/download/gerrit-2.4.3.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Patch JGit security hole
 +
 The security hole may permit a modified Git client to gain access
diff --git a/ReleaseNotes/ReleaseNotes-2.4.4.txt b/ReleaseNotes/ReleaseNotes-2.4.4.txt
index 5570271..f9ea6b5 100644
--- a/ReleaseNotes/ReleaseNotes-2.4.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.4.4.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.4.4
-==============================
+= Release notes for Gerrit 2.4.4
 
 There are no schema changes from link:ReleaseNotes-2.4.4.html[2.4.4].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.4.4.war[https://www.gerritcodereview.com/download/gerrit-2.4.4.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix clone for modern Git clients
 +
 The security fix in 2.4.3 broke clone for recent Git clients,
diff --git a/ReleaseNotes/ReleaseNotes-2.4.txt b/ReleaseNotes/ReleaseNotes-2.4.txt
index 0e11550..1db4ba3 100644
--- a/ReleaseNotes/ReleaseNotes-2.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.4.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.4
-============================
+= Release notes for Gerrit 2.4
 
 Gerrit 2.4 is now available:
 
 link:https://www.gerritcodereview.com/download/gerrit-2.4.war[https://www.gerritcodereview.com/download/gerrit-2.4.war]
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -16,11 +14,9 @@
 a later 2.1.x version), and then to 2.4.x.  If you are upgrading from 2.2.x.x or
 newer, you may ignore this warning and upgrade directly to 2.4.x.
 
-New Features
-------------
+== New Features
 
-Security
-~~~~~~~~
+=== Security
 
 * Restrict visibility to arbitrary user dashboards
 +
@@ -42,8 +38,7 @@
 
 * Indicate that 'not found' may actually be a permission issue
 
-Web
-~~~
+=== Web
 
 * Add user preference to mark files reviewed automatically or manually
 +
@@ -82,14 +77,12 @@
 * Change 'Loading ...' to say 'Working ...' as, often, there is more going on
 than just loading a response.
 
-Performance
-~~~~~~~~~~~
+=== Performance
 
 * Asynchronously send email so it does not block the UI
 * Optimize queries for open/merged changes by project + branch
 
-Git
-~~~
+=== Git
 
 * Implement a multi-sub-task progress monitor for ReceiveCommits
 
@@ -116,8 +109,7 @@
 can be monitored for timeouts and cancelled, and have stalls reported
 to the user from the main thread.
 
-Search
-~~~~~~
+=== Search
 
 * Add the '--dependencies' option to the 'query' command.
 +
@@ -141,15 +133,13 @@
 With this change, we can fetch the comments on a patchset by sending a
 request to 'https://site/query?comments=true'
 
-Access Rights
-~~~~~~~~~~~~~
+=== Access Rights
 
 * Added the 'emailReviewers' as a global capability.
 +
 This replaces the 'emailOnlyAuthors' flag of account groups.
 
-Dev
-~~~
+=== Dev
 
 * issue 1272 Add scripts to create release notes from git log
 +
@@ -164,8 +154,7 @@
 
 * Add '--issues' and '--issue_numbers' options to the 'gitlog2asciidoc.py'
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 
 * Remove perl from 'commit-msg' hook
 +
@@ -174,8 +163,7 @@
 
 * updating contrib 'trivial_rebase.py' for 2.2.2.1
 
-Upgrades
---------
+== Upgrades
 
 * Updated to Guice 3.0.
 * Updated to gwtorm 1.4.
@@ -185,8 +173,7 @@
 The change also shrinks the built WAR from 38M to 23M
 by excluding the now unnecessary GWT server code.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * issue 904 Users who starred a change should receive all the emails about a change.
 
@@ -226,11 +213,9 @@
 * Fix inconsistent behavior when replicating refs/meta/config
 * Fix duplicated results on status:open project:P branch:B
 
-Documentation
--------------
+== Documentation
 
-Access Rights
-~~~~~~~~~~~~~
+=== Access Rights
 * Capabilities introduced
 * Kill and priority capabilities
 * Administrate server capability
@@ -246,8 +231,7 @@
 * Project owner example role
 * Administrator example role
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 * User upload documentation: Replace changes
 * Add visible-to-all flag in the documentation for cmd-create-group
 * Add a contributing guideline for annotations
diff --git a/ReleaseNotes/ReleaseNotes-2.5.1.txt b/ReleaseNotes/ReleaseNotes-2.5.1.txt
index 6e6a481..c2982df 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.5.1
-==============================
+= Release notes for Gerrit 2.5.1
 
 Gerrit 2.5.1 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from a version older than 2.5, follow the upgrade
 procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
 
-Security Fixes
---------------
+== Security Fixes
 * Correctly identify Git-over-HTTP operations
 +
 Git operations over HTTP should be classified as using AccessPath.GIT
@@ -45,8 +43,7 @@
   project by definition has no parent)
 by pushing changes of the `project.config` file to `refs/meta/config`.
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix RequestCleanup bug with Git over HTTP
 +
 Decide if a continuation is going to be used early, before the filter
diff --git a/ReleaseNotes/ReleaseNotes-2.5.2.txt b/ReleaseNotes/ReleaseNotes-2.5.2.txt
index cc436ac..9bedeac 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.5.2
-==============================
+= Release notes for Gerrit 2.5.2
 
 Gerrit 2.5.2 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from any earlier version, follow the upgrade
 procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
 
-Bug Fixes
----------
+== Bug Fixes
 * Improve performance of ReceiveCommits for repos with many refs
 +
 When validating the received commits all existing refs were added as
diff --git a/ReleaseNotes/ReleaseNotes-2.5.3.txt b/ReleaseNotes/ReleaseNotes-2.5.3.txt
index 8e9db0c..6448f1c 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.3.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.5.3
-==============================
+= Release notes for Gerrit 2.5.3
 
 Gerrit 2.5.3 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from a version older than 2.5, follow the upgrade
 procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
 
-Security Fixes
---------------
+== Security Fixes
 * Patch vulnerabilities in OpenID client library
 +
 Installations using OpenID for authentication were vulnerable to a
diff --git a/ReleaseNotes/ReleaseNotes-2.5.4.txt b/ReleaseNotes/ReleaseNotes-2.5.4.txt
index 4d51528..6ea93bb 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.4.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.5.4
-==============================
+= Release notes for Gerrit 2.5.4
 
 Gerrit 2.5.4 is now available:
 
@@ -10,8 +9,7 @@
 However, if upgrading from a version older than 2.5, follow the upgrade
 procedure in the 2.5 link:ReleaseNotes-2.5.html[Release Notes].
 
-Bug Fixes
----------
+== Bug Fixes
 * Require preferred email to be verified
 +
 Some users were able to select a preferred email address that was
diff --git a/ReleaseNotes/ReleaseNotes-2.5.5.txt b/ReleaseNotes/ReleaseNotes-2.5.5.txt
index 146fd40..27dd2b6 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.5.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.5.5
-==============================
+= Release notes for Gerrit 2.5.5
 
 There are no schema changes from link:ReleaseNotes-2.5.4.html[2.5.4].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.5.5.war[https://www.gerritcodereview.com/download/gerrit-2.5.5.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Patch JGit security hole
 +
 The security hole may permit a modified Git client to gain access
diff --git a/ReleaseNotes/ReleaseNotes-2.5.6.txt b/ReleaseNotes/ReleaseNotes-2.5.6.txt
index b1e88f9..393eb93 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.6.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.5.6
-==============================
+= Release notes for Gerrit 2.5.6
 
 There are no schema changes from link:ReleaseNotes-2.5.6.html[2.5.6].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.5.6.war[https://www.gerritcodereview.com/download/gerrit-2.5.6.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Fix clone for modern Git clients
 +
 The security fix in 2.5.4 broke clone for recent Git clients,
diff --git a/ReleaseNotes/ReleaseNotes-2.5.txt b/ReleaseNotes/ReleaseNotes-2.5.txt
index 8519ee9..6bcb87a 100644
--- a/ReleaseNotes/ReleaseNotes-2.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.5.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.5
-============================
+= Release notes for Gerrit 2.5
 
 Gerrit 2.5 is now available:
 
@@ -10,8 +9,7 @@
 link:ReleaseNotes-2.4.2.html[Gerrit 2.4.2]. These bug fixes are *not*
 listed in these release notes.
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -21,8 +19,7 @@
 a later 2.1.x version), and then to 2.5.x.  If you are upgrading from 2.2.x.x or
 newer, you may ignore this warning and upgrade directly to 2.5.x.
 
-Warning on upgrade to schema version 68
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== Warning on upgrade to schema version 68
 
 The migration to schema version 68, may result in a warning, which can
 be ignored when running init in the interactive mode.
@@ -48,19 +45,16 @@
 message you can see that the migration failed because the index exists
 already (as in the example above), you can safely ignore this warning.
 
-Upgrade Warnings
-----------------
+== Upgrade Warnings
 
 [[replication]]
-Replication
-~~~~~~~~~~~
+=== Replication
 
 Gerrit 2.5 no longer includes replication support out of the box.
 Servers that reply upon `replication.config` to copy Git repository
 data to other locations must also install the replication plugin.
 
-Cache Configuration
-~~~~~~~~~~~~~~~~~~~
+=== Cache Configuration
 
 Disk caches are now backed by individual H2 databases, rather than
 Ehcache's own private format. Administrators are encouraged to clear
@@ -98,11 +92,9 @@
 updates are often made to the source without telling Gerrit to reload
 the cache.
 
-New Features
-------------
+== New Features
 
-Plugins
-~~~~~~~
+=== Plugins
 
 The Gerrit server functionality can be extended by installing plugins.
 Depending on how tightly the extension code is coupled with the Gerrit
@@ -261,8 +253,7 @@
 +
 This enables plugins to make use of servlet sessions.
 
-REST API
-~~~~~~~~
+=== REST API
 Gerrit now supports a REST like API available over HTTP. The API is
 suitable for automated tools to build upon, as well as supporting some
 ad-hoc scripting use cases.
@@ -302,11 +293,9 @@
 `site.enableDeprecatedQuery`] parameter in the Gerrit config file. This
 allows to enforce tools to move to the new API.
 
-Web
-~~~
+=== Web
 
-Change Screen
-^^^^^^^^^^^^^
+==== Change Screen
 
 * Display commit message in a box
 +
@@ -384,8 +373,7 @@
 
 * Use more gentle shade of red to highlight outdated dependencies
 
-Patch Screens
-^^^^^^^^^^^^^
+==== Patch Screens
 
 * New patch screen header
 +
@@ -447,8 +435,7 @@
 
 * Use download icons instead of the `Download` text links
 
-User Dashboard
-^^^^^^^^^^^^^^
+==== User Dashboard
 * Support for link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/user-custom-dashboards.html[custom
   dashboards]
 
@@ -472,8 +459,7 @@
 and the oldest stale at the bottom. This may help users to identify
 items to take immediate action on, as they appear closer to the top.
 
-Access Rights Screen
-^^^^^^^^^^^^^^^^^^^^
+==== Access Rights Screen
 
 * Display error if modifying access rights for a ref is forbidden
 +
@@ -512,8 +498,7 @@
 For project owners now also groups to which they are not a member are
 suggested when editing the access rights of the project.
 
-Other
-^^^^^
+==== Other
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=1592[issue 1592]:
   Ask user to login if change is not found
@@ -626,8 +611,7 @@
 The URL for the external system can be configured for the
 link:#custom-extension[`CUSTOM_EXTENSION`] auth type.
 
-Access Rights
-~~~~~~~~~~~~~
+=== Access Rights
 
 * Restrict rebasing of a change in the web UI to the change owner and
   the submitter
@@ -657,8 +641,7 @@
 `Anonymous users` were able to access the `refs/meta/config` branch
 which by default should only be accessible by the project owners.
 
-Search
-~~~~~~
+=== Search
 * Offer suggestions for the search operators in the search panel
 +
 There are many search operators and it's difficult to remember all of
@@ -678,8 +661,7 @@
 
 * `/query` API has been link:#query-deprecation[deprecated]
 
-SSH
-~~~
+=== SSH
 * link:http://code.google.com/p/gerrit/issues/detail?id=1095[issue 1095]
   link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-set-account.html[SSH command to manage
   accounts]
@@ -738,11 +720,9 @@
 command output a tab-separated table containing all available
 information about each group (though not its members).
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
-Commands
-^^^^^^^^
+==== Commands
 
 * document for the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/cmd-create-group.html[`create-group`]
   command that for unknown users an account is automatically created if
@@ -770,8 +750,7 @@
 
 * Fix and complete synopsis of commands
 
-Access Control
-^^^^^^^^^^^^^^
+==== Access Control
 
 * Clarify the ref format for
   link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#category_push_merge[`Push
@@ -785,8 +764,7 @@
   link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/access-control.html#capability_emailReviewers[
   `emailReviewers`] capability
 
-Error
-^^^^^
+==== Error
 * Improve documentation of link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/error-change-closed.html[
   `change closed` error]
 +
@@ -806,8 +784,7 @@
   a tag fails because the tagger is somebody else and the `Forge
   Committer` access right is not assigned.
 
-Dev
-^^^
+==== Dev
 
 * Update push URL in link:../SUBMITTING_PATCHES[SUBMITTING_PATCHES]
 +
@@ -840,8 +817,7 @@
 Document what it takes to make a Gerrit stable or stable-fix release,
 and how to release Gerrit subprojects.
 
-Other
-^^^^^
+==== Other
 * Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/prolog-cookbook.html[Cookbook for Prolog
   submit rules]
 +
@@ -884,8 +860,7 @@
 +
 Correct typos, spelling mistakes, and grammatical errors.
 
-Dev
-~~~
+=== Dev
 * Add link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.5/dev-release.html#plugin-api[script for
   releasing plugin API jars]
 
@@ -948,8 +923,7 @@
 `all` profile so that the plugin modules are always built for release
 builds.
 
-Mail
-~~~~
+=== Mail
 
 * Add unified diff to newchange mail template
 +
@@ -997,8 +971,7 @@
 +
 Show the URL right away in the body.
 
-Miscellaneous
-~~~~~~~~~~~~~
+=== Miscellaneous
 * Back in-memory caches with Guava, disk caches with H2
 +
 Instead of using Ehcache for in-memory caches, use Guava. The Guava
@@ -1264,8 +1237,7 @@
 eventually go away when there is proper support for authentication
 plugins.
 
-Performance
-~~~~~~~~~~~
+=== Performance
 [[performance-issue-on-showing-group-list]]
 * Fix performance issues on showing the list of groups in the Gerrit
   WebUI
@@ -1399,8 +1371,7 @@
 front allows the database to send all requests to the backend as early
 as possible, allowing the network latency to overlap.
 
-Upgrades
---------
+== Upgrades
 * Update Gson to 2.1
 * Update GWT to 2.4.0
 * Update JGit to 2.0.0.201206130900-r.23-gb3dbf19
@@ -1416,11 +1387,9 @@
 inner table did not dynamically resize to handle a larger number
 of cached items, causing O(N) lookup performance for most objects.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Security
-~~~~~~~~
+=== Security
 * Ensure that only administrators can change the global capabilities
 +
 Only Gerrit server administrators (members of the groups that have
@@ -1468,8 +1437,7 @@
 Hence this check is currently not done and these access rights in this
 case have simply no effect.
 
-Web
-~~~
+=== Web
 
 * Do not show "Session cookie not available" on sign in
 +
@@ -1625,8 +1593,7 @@
 and redirect to 'Admin' > 'Projects' to show the projects the caller
 has access to.
 
-Mail
-~~~~
+=== Mail
 
 * Fix: Rebase did not mail all reviewers
 
@@ -1653,8 +1620,7 @@
 `Reverted.vm` were not extracted during the initialization of a new
 site.
 
-SSH
-~~~
+=== SSH
 * Fix reject message if bypassing code review is not allowed
 +
 If a user is not allowed to bypass code review, but tries to push a
@@ -1726,8 +1692,7 @@
 non existing project name this was logged in the `error.log` but
 printing the error out to the user is sufficient.
 
-Authentication
-~~~~~~~~~~~~~~
+=== Authentication
 
 * Fix NPE in LdapRealm caused by non-LDAP users
 +
@@ -1748,8 +1713,7 @@
 is valid. When the URL is missing (e.g. because the provider is
 still broken) rely on the context path of the application instead.
 
-Replication
-~~~~~~~~~~~
+=== Replication
 
 * Fix inconsistent behavior when replicating `refs/meta/config`
 +
@@ -1766,8 +1730,7 @@
 The groupCache was being used before it was set in the class. Fix the
 ordering of the assignment.
 
-Approval Categories
-~~~~~~~~~~~~~~~~~~~
+=== Approval Categories
 
 * Make `NoBlock` and `NoOp` approval category functions work
 +
@@ -1811,8 +1774,7 @@
 collection came out null, which cannot be iterated. Make it always be
 an empty list.
 
-Other
-~~~~~
+=== Other
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=1554[issue 1554]:
   Fix cloning of new projects from slave servers
diff --git a/ReleaseNotes/ReleaseNotes-2.6.1.txt b/ReleaseNotes/ReleaseNotes-2.6.1.txt
index e43b077..94de483 100644
--- a/ReleaseNotes/ReleaseNotes-2.6.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.6.1.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.6.1
-==============================
+= Release notes for Gerrit 2.6.1
 
 There are no schema changes from link:ReleaseNotes-2.6.html[2.6].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.6.1.war[https://www.gerritcodereview.com/download/gerrit-2.6.1.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * Patch JGit security hole
 +
 The security hole may permit a modified Git client to gain access
diff --git a/ReleaseNotes/ReleaseNotes-2.6.txt b/ReleaseNotes/ReleaseNotes-2.6.txt
index dfd5d80..26b0b0e 100644
--- a/ReleaseNotes/ReleaseNotes-2.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.6.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.6
-============================
+= Release notes for Gerrit 2.6
 
 Gerrit 2.6 is now available:
 
@@ -12,8 +11,7 @@
 link:ReleaseNotes-2.5.4.html[Gerrit 2.5.4]. These bug fixes are *not*
 listed in these release notes.
 
-Schema Change
--------------
+== Schema Change
 *WARNING:* This release contains schema changes.  To upgrade:
 ----
   java -jar gerrit.war init -d site_path
@@ -23,8 +21,7 @@
 a later 2.1.x version), and then to 2.6.x.  If you are upgrading from 2.2.x.x or
 newer, you may ignore this warning and upgrade directly to 2.6.x.
 
-Reverse Proxy Configuration Changes
------------------------------------
+== Reverse Proxy Configuration Changes
 
 If you are running a reverse proxy in front of Gerrit (e.g. Apache or Nginx),
 make sure to check your configuration, especially if you are encountering
@@ -34,8 +31,7 @@
 
 Gerrit now requires passed URLs to be unchanged by the proxy.
 
-Release Highlights
-------------------
+== Release Highlights
 * 42x improvement on `git clone` and `git fetch`
 +
 Running link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
@@ -51,14 +47,11 @@
 labels] and link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html[
 Prolog rules].
 
-New Features
-------------
+== New Features
 
-Web UI
-~~~~~~
+=== Web UI
 
-Global
-^^^^^^
+==== Global
 
 * New Login Screens
 +
@@ -93,8 +86,7 @@
 
 * Add a link to the REST API documentation in the top menu.
 
-Search
-^^^^^^
+==== Search
 * Suggest projects, groups and users in search panel
 +
 Suggest projects, groups and users in the search panel as parameter for
@@ -107,8 +99,7 @@
 The values that are suggested for the search operators in the search
 panel are now only quoted if they contain a whitespace.
 
-Change Screens
-^^^^^^^^^^^^^^
+==== Change Screens
 
 * A change's commit message can be edited from the change screen.
 
@@ -182,8 +173,7 @@
 
 * Rename "Old Version History" to "Reference Version".
 
-Patch Screens
-^^^^^^^^^^^^^
+==== Patch Screens
 
 * Support for file comments
 +
@@ -205,8 +195,7 @@
 
 * Enable expanding skipped lines even if 'Syntax Coloring' is off.
 
-Project Screens
-^^^^^^^^^^^^^^^
+==== Project Screens
 
 * Support filtering of projects in the project list screen
 +
@@ -260,8 +249,7 @@
 Improve the error messages that are displayed in the WebUI if the
 creation of a branch fails due to invalid user input.
 
-Group Screens
-^^^^^^^^^^^^^
+==== Group Screens
 
 * Support filtering of groups in the group list screen
 +
@@ -276,8 +264,7 @@
 well-known system groups which are of type 'SYSTEM'. The system groups
 are so well-known that there is no need to display the type for them.
 
-Dashboard Screens
-^^^^^^^^^^^^^^^^^
+==== Dashboard Screens
 
 * Link dashboard title to a URL version of itself
 +
@@ -290,8 +277,7 @@
 
 * Increase time span for "Recently Closed" section in user dashboard to 4 weeks.
 
-Account Screens
-^^^^^^^^^^^^^^^
+==== Account Screens
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1740[Issue 1740]:
   Display description how to generate SSH Key in SshPanel
@@ -302,16 +288,14 @@
 
 * Make the text for "Register" customizable
 
-Plugin Screens
-^^^^^^^^^^^^^^
+==== Plugin Screens
 
 * Show status for enabled plugins in the WebUI as 'Enabled'
 +
 Earlier no status was shown for enabled plugins, which was confusing to
 some users.
 
-REST API
-~~~~~~~~
+=== REST API
 
 * A big chunk of the Gerrit functionality is now available via the
   link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api.html[REST API].
@@ -443,8 +427,7 @@
 HTML thanks to Gson encoding HTML control characters using Unicode
 character escapes within JSON strings.
 
-Project Dashboards
-~~~~~~~~~~~~~~~~~~
+=== Project Dashboards
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-dashboards.html#project-dashboards[
   Support for storing custom dashboards for projects]
 +
@@ -472,8 +455,7 @@
 The `foreach` parameter which will get appended to all the queries in
 the dashboard.
 
-Access Controls
-~~~~~~~~~~~~~~~
+=== Access Controls
 * Allow to overrule `BLOCK` permissions on the same project
 +
 It was impossible to block a permission for a group and allow the same
@@ -567,8 +549,7 @@
 Having the `Push` access right for `refs/meta/config` on the
 `All-Projects` project without being administrator has no effect.
 
-Hooks
-~~~~~
+=== Hooks
 * Change topic is passed to hooks as `--topic NAME`.
 * link:https://code.google.com/p/gerrit/issues/detail?id=1200[Issue 1200]:
 New `reviewer-added` hook and stream event when a reviewer is added.
@@ -581,8 +562,7 @@
 
 * Add `--is-draft` parameter to `comment-added` hook
 
-Git
-~~~
+=== Git
 * Add options to `refs/for/` magic branch syntax
 +
 Git doesn't want to modify the network protocol to support passing
@@ -629,8 +609,7 @@
 
 * Add `oldObjectId` and `newObjectId` to the `GitReferenceUpdatedListener.Update`
 
-SSH
-~~~
+=== SSH
 * New SSH command to http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
   run Git garbage collection]
 +
@@ -659,8 +638,7 @@
 * http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-test-submit-type.html[
   test-submit type] tests the Prolog submit type with a chosen change.
 
-Query
-~~~~~
+=== Query
 * Allow `{}` to be used for quoting in query expressions
 +
 This makes it a little easier to query for group names that contain
@@ -676,8 +654,7 @@
 * When a file is renamed the old file name is included in the Patch
   attribute
 
-Plugins
-~~~~~~~
+=== Plugins
 * Plugins can contribute Prolog facts/predicates from Java.
 * Plugins can prompt for parameters during `init` with `InitStep`.
 * Plugins can now contribute JavaScript to the web UI. UI plugins can
@@ -697,8 +674,7 @@
 delegate to the plugin servlet's magic handling for static files and
 documentation. Add JAR attributes to configure these prefixes.
 
-Prolog
-~~~~~~
+=== Prolog
 [[submit-type-from-prolog]]
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/prolog-cookbook.html#HowToWriteSubmitType[
   Support controlling the submit type for changes from Prolog]
@@ -723,8 +699,7 @@
 
 * A new `max_with_block` predicate was added for more convenient usage
 
-Email
-~~~~~
+=== Email
 * Notify project watchers if draft change is published
 * Notify users mentioned in commit footer on draft publish
 * Add new notify type that allows watching of new patch sets
@@ -747,8 +722,7 @@
 which review updates should send email, and which categories of users
 on a change should get that email.
 
-Labels
-~~~~~~
+=== Labels
 * Approval categories stored in the database have been replaced with labels
   configured in `project.config`. Existing categories are migrated to
   `project.config` in `All-Projects` as part of the schema upgrade; no user
@@ -765,8 +739,7 @@
 or their own build system they can now trivially add the `Verified`
 category by pasting 5 lines into `project.config`.
 
-Dev
-~~~
+=== Dev
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-readme.html#debug-javascript[
   Support loading debug JavaScript]
@@ -815,8 +788,7 @@
 * "Become Any Account" can be used for accounts whose full name is an empty string.
 
 
-Performance
-~~~~~~~~~~~
+=== Performance
 * Bitmap Optimizations
 +
 On running the http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/cmd-gc.html[
@@ -904,8 +876,7 @@
 database traffic a cache for changes was introduced. This cache is
 disabled by default since it can mess up multi-server setups.
 
-Misc
-~~~~
+=== Misc
 * Add config parameter to make new groups by default visible to all
 +
 Add a new http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/config-gerrit.html#groups.newGroupsVisibleToAll[
@@ -1018,8 +989,7 @@
 
 * Show path to gerrit.war in command for upgrade schema
 
-Upgrades
-~~~~~~~~
+=== Upgrades
 * link:https://code.google.com/p/gerrit/issues/detail?id=1619[Issue 1619]:
 Embedded Jetty is now 8.1.7.v20120910.
 
@@ -1033,11 +1003,9 @@
 +
 Fixes some issues with IE9 and IE10.
 
-Bug Fixes
----------
+== Bug Fixes
 
-Web UI
-~~~~~~
+=== Web UI
 * link:https://code.google.com/p/gerrit/issues/detail?id=1662[Issue 1662]:
   Don't show error on ACL modification if empty permissions are added
 +
@@ -1234,8 +1202,7 @@
 Correctly keep patch set ordering after a new patch set is added via
 the Web UI.
 
-REST API
-~~~~~~~~
+=== REST API
 * Fix returning of 'Email Reviewers' capability via REST
 +
 The `/accounts/self/capabilities/` didn't return the 'Email Reviewers'
@@ -1249,8 +1216,7 @@
 * Provide a more descriptive error message for unauthenticated REST
   API access
 
-Git
-~~~
+=== Git
 * The wildcard `.` is now permitted in reference regex rules.
 
 * Checking if a change is mergeable no longer writes to the repository.
@@ -1356,8 +1322,7 @@
 "Only 0 of 0 new change refs created in xxx; aborting"
 could appear in the error log.
 
-SSH
-~~~
+=== SSH
 * `review --restore` allows a review score to be added on the restored change.
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1721[Issue 1721]:
@@ -1379,8 +1344,7 @@
 
 * Fix setting account's full name via ssh.
 
-Query
-~~~~~
+=== Query
 * link:https://code.google.com/p/gerrit/issues/detail?id=1729[Issue 1729]:
   Fix query by 'label:Verified=0'
 
@@ -1389,8 +1353,7 @@
 
 * Fix query cost for "status:merged commit:c0ffee"
 
-Plugins
-~~~~~~~
+=== Plugins
 * Skip disabled plugins on rescan
 +
 In a background thread Gerrit periodically scans for new or changed
@@ -1450,8 +1413,7 @@
   Allow InternalUser (aka plugins) to see any internal group, and run
   plugin startup and shutdown as PluginUser.
 
-Email
-~~~~~
+=== Email
 * Merge failure emails are only sent once per day.
 * Unused macros are removed from the mail templates.
 * Unnecessary ellipses are no longer applied to email subjects.
@@ -1467,8 +1429,7 @@
 If a user is watching 'All Comments' on `All-Projects` this should
 apply to all projects.
 
-Misc
-~~~~
+=== Misc
 * Provide more descriptive message for NoSuchProjectException
 
 * On internal error due to receive timeout include the value of
@@ -1630,15 +1591,13 @@
 standard `Authorization` header unspecified and available for use in
 HTTP reverse proxies.
 
-Documentation
--------------
+== Documentation
 
 The link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/index.html[
 documentation index] is restructured to make it easier to use for different kinds of
 users.
 
-User Documentation
-~~~~~~~~~~~~~~~~~~
+=== User Documentation
 * Split link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/rest-api.html[
   REST API documentation] and have one page per top level resource
 
@@ -1750,8 +1709,7 @@
 * Fix external links in 2.0.21 and 2.0.24 release notes
 * Manual pages can be optionally created/installed for core gerrit ssh commands.
 
-Developer And Maintainer Documentation
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== Developer And Maintainer Documentation
 * Updated the link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/dev-eclipse.html#maven[
   Maven plugin installation instructions] for Eclipse 3.7 (Indigo).
 
diff --git a/ReleaseNotes/ReleaseNotes-2.7.txt b/ReleaseNotes/ReleaseNotes-2.7.txt
index 9782b08..0870cbf 100644
--- a/ReleaseNotes/ReleaseNotes-2.7.txt
+++ b/ReleaseNotes/ReleaseNotes-2.7.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.7
-============================
+= Release notes for Gerrit 2.7
 
 
 Gerrit 2.7 is now available:
@@ -10,8 +9,7 @@
 Gerrit 2.7 includes the bug fixes done with link:ReleaseNotes-2.6.1.html[Gerrit 2.6.1].
 These bug fixes are *not* listed in these release notes.
 
-Schema Change
--------------
+== Schema Change
 
 
 *WARNING:* This release contains schema changes.  To upgrade:
@@ -25,8 +23,7 @@
 
 
 
-Gerrit Trigger Plugin in Jenkins
---------------------------------
+== Gerrit Trigger Plugin in Jenkins
 
 
 *WARNING:* Upgrading to 2.7 may cause the Gerrit Trigger Plugin in Jenkins to
@@ -34,8 +31,7 @@
 below.
 
 
-Release Highlights
-------------------
+== Release Highlights
 
 
 * New `copyMaxScore` setting for labels.
@@ -46,12 +42,10 @@
 * Several new REST APIs.
 
 
-New Features
-------------
+== New Features
 
 
-General
-~~~~~~~
+=== General
 
 * New `copyMaxScore` setting for labels.
 +
@@ -105,12 +99,10 @@
 * Allow administrators to see all groups.
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 
-Global
-^^^^^^
+==== Global
 
 * User avatars are displayed in more places in the Web UI.
 
@@ -120,8 +112,7 @@
 mouse over avatar images.
 
 
-Change Screens
-^^^^^^^^^^^^^^
+==== Change Screens
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=667[Issue 667]:
@@ -140,8 +131,7 @@
 change comments.
 
 
-Diff Screens
-^^^^^^^^^^^^
+==== Diff Screens
 
 * Show images in side-by-side and unified diffs.
 
@@ -150,15 +140,13 @@
 * Harmonize unified diff's styling of images with that of text.
 
 
-REST API
-~~~~~~~~
+=== REST API
 
 
 Several new link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api.html[
 REST API endpoints] are added.
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-accounts.html#get-diff-preferences[
@@ -168,8 +156,7 @@
 Set account diff preferences]
 
 
-Changes
-^^^^^^^
+==== Changes
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1820[Issue 1820]:
@@ -181,16 +168,14 @@
 
 
 
-Projects
-^^^^^^^^
+==== Projects
 
 
 * link:http://gerrit-documentation.googlecode.com/svn/Documentation/2.7/rest-api-projects.html#get-config[
 Get project configuration]
 
 
-ssh
-~~~
+=== ssh
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1088[Issue 1088]:
@@ -198,11 +183,9 @@
 Kerberos authentication for ssh interaction].
 
 
-Bug Fixes
----------
+== Bug Fixes
 
-General
-~~~~~~~
+=== General
 
 * Postpone check for first account until adding an account.
 
@@ -240,8 +223,7 @@
 draft was already deleted.
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 
 * Properly handle double-click on external group in GroupTable.
@@ -282,8 +264,7 @@
 Fix browser null-pointer exception when ChangeCache is incomplete.
 
 
-REST API
-~~~~~~~~
+=== REST API
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1819[Issue 1819]:
@@ -294,37 +275,32 @@
 * Correct URL encoding in 'GroupInfo'.
 
 
-Email
-~~~~~
+=== Email
 
 * Log failure to access reviewer list for notification emails.
 
 * Log when appropriate if email delivery is skipped.
 
 
-ssh
-~~~
+=== ssh
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2016[Issue 2016]:
 Flush caches after adding or deleting ssh keys via the `set-account` ssh command.
 
-Tools
-~~~~~
+=== Tools
 
 
 * The release build now builds for all browser configurations.
 
 
-Upgrades
---------
+== Upgrades
 
 * `gwtexpui` is now built in the gerrit tree rather than linking a separate module.
 
 
 
-Documentation
--------------
+== Documentation
 
 
 * Update the access control documentation to clarify how to set
diff --git a/ReleaseNotes/ReleaseNotes-2.8.1.txt b/ReleaseNotes/ReleaseNotes-2.8.1.txt
index 26414a1..5e32cf5 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.1.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.8.1
-==============================
+= Release notes for Gerrit 2.8.1
 
 There are no schema changes from link:ReleaseNotes-2.8.html[2.8].
 
 link:https://www.gerritcodereview.com/download/gerrit-2.8.1.war[https://www.gerritcodereview.com/download/gerrit-2.8.1.war]
 
-Bug Fixes
----------
+== Bug Fixes
 * link:https://code.google.com/p/gerrit/issues/detail?id=2073[Issue 2073]:
 Changes that depend on outdated patch sets were missing in the related changes list.
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.8.2.txt b/ReleaseNotes/ReleaseNotes-2.8.2.txt
index 926db02..99cb437 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.2.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8.2
-==============================
+= Release notes for Gerrit 2.8.2
 
 There are no schema changes from link:ReleaseNotes-2.8.1.html[2.8.1].
 
@@ -8,8 +7,7 @@
 https://www.gerritcodereview.com/download/gerrit-2.8.2.war]
 
 
-Lucene Index
-------------
+== Lucene Index
 
 * Support committing Lucene writes within a fixed interval.
 +
@@ -24,8 +22,7 @@
 indexes are committed.
 
 
-General
--------
+== General
 
 * Only add "cherry picked from" when cherry picking a merged change.
 +
@@ -142,8 +139,7 @@
 The joda time library was being unnecessarily packaged in the root of
 the gerrit.war file.
 
-Change Screen / Diff Screen
----------------------------
+== Change Screen / Diff Screen
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2398[Issue 2398]:
@@ -231,8 +227,7 @@
 +
 Now, an error message will be displayed in the UI.
 
-ssh
----
+== ssh
 
 
 * Support for nio2 backend is removed.
@@ -263,8 +258,7 @@
 * link:https://code.google.com/p/gerrit/issues/detail?id=2515[Issue 2515]:
 Fix internal server error when updating an existing label with `gerrit review`.
 
-Replication Plugin
-------------------
+== Replication Plugin
 
 
 * Never replicate automerge-cache commits.
@@ -287,14 +281,12 @@
 * Update documentation to clarify replication of refs/meta/config when
 refspec is 'all refs'.
 
-Upgrades
---------
+== Upgrades
 
 
 * JGit is upgraded to 3.2.0.201312181205-r
 
-Documentation
--------------
+== Documentation
 
 
 * Add missing documentation of the secondary index configuration.
diff --git a/ReleaseNotes/ReleaseNotes-2.8.3.txt b/ReleaseNotes/ReleaseNotes-2.8.3.txt
index 2bd4aa7..f94dce0 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.3.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8.3
-==============================
+= Release notes for Gerrit 2.8.3
 
 There are no schema changes from link:ReleaseNotes-2.8.2.html[2.8.2].
 
@@ -8,8 +7,7 @@
 https://www.gerritcodereview.com/download/gerrit-2.8.3.war]
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Fix for merging multiple changes with "Cherry Pick", "Merge Always" and
 "Merge If Necessary" strategies.
@@ -19,8 +17,7 @@
 them to actually land into the branch.
 
 
-Documentation
--------------
+== Documentation
 
 * Minor fixes in the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8.3/dev-buck.html[
diff --git a/ReleaseNotes/ReleaseNotes-2.8.4.txt b/ReleaseNotes/ReleaseNotes-2.8.4.txt
index b80ac17..8aac71c 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.4.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8.4
-==============================
+= Release notes for Gerrit 2.8.4
 
 There are no schema changes from link:ReleaseNotes-2.8.3.html[2.8.3].
 
@@ -8,12 +7,10 @@
 https://www.gerritcodereview.com/download/gerrit-2.8.4.war]
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 
 * Disable `commitWithin` when running Reindex.
@@ -31,8 +28,7 @@
 `SubIndex.NrtFuture` objects were being added as listeners of `searchManager`
 and never released.
 
-Change Screen
-~~~~~~~~~~~~~
+=== Change Screen
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2456[Issue 2456]:
@@ -84,8 +80,7 @@
 tooltip on the up arrow but did not show the tooltip on the left
 or right arrows.
 
-Plugins
-~~~~~~~
+=== Plugins
 
 
 * Fix ChangeListener auto-registered implementations.
@@ -98,8 +93,7 @@
 Plugins could be built, but not loaded, if they had any manifest entries
 that contained a dollar sign.
 
-Misc
-~~~~
+=== Misc
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2564[Issue 2564],
@@ -156,8 +150,7 @@
 so stream-events consumers can properly detect who uploaded the
 rebased patch set.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1273[Issue 1273]:
diff --git a/ReleaseNotes/ReleaseNotes-2.8.5.txt b/ReleaseNotes/ReleaseNotes-2.8.5.txt
index db18083..ae30530 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.5.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.8.5
-==============================
+= Release notes for Gerrit 2.8.5
 
 Download:
 link:https://www.gerritcodereview.com/download/gerrit-2.8.5.war[
 https://www.gerritcodereview.com/download/gerrit-2.8.5.war]
 
-Schema Changes and Upgrades
----------------------------
+== Schema Changes and Upgrades
 
 
 * There are no schema changes from link:ReleaseNotes-2.8.4.html[2.8.4].
@@ -24,19 +22,16 @@
   java -jar gerrit.war init -d site_path
 ----
 
-Bug Fixes
----------
+== Bug Fixes
 
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 
 * Fix deadlocks on index shutdown.
 
 
-Change Screen
-~~~~~~~~~~~~~
+=== Change Screen
 
 
 * Only permit current patch set to edit the commit message.
@@ -68,8 +63,7 @@
 * Fix failure to load side-by-side diff due to "ISE EditIterator out of bounds"
 error.
 
-ssh
-~~~
+=== ssh
 
 * Upgrade SSHD to version 0.11.0.
 +
@@ -90,8 +84,7 @@
 link:https://issues.apache.org/jira/browse/SSHD-252[bug in SSHD].  That bug
 was fixed in SSHD version 0.10.0, so now we can re-enable nio2.
 
-Misc
-~~~~
+=== Misc
 
 
 * Keep old timestamps during data migration.
diff --git a/ReleaseNotes/ReleaseNotes-2.8.6.1.txt b/ReleaseNotes/ReleaseNotes-2.8.6.1.txt
index d1ed9e9..81e7297 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.6.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.6.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8.6.1
-================================
+= Release notes for Gerrit 2.8.6.1
 
 There are no schema changes from link:ReleaseNotes-2.8.6.html[2.8.6].
 
@@ -7,8 +6,7 @@
 link:https://www.gerritcodereview.com/download/gerrit-2.8.6.1.war[
 https://www.gerritcodereview.com/download/gerrit-2.8.6.1.war]
 
-Bug Fixes
----------
+== Bug Fixes
 
 * The fix in 2.8.6 for the merge queue race condition caused a regression
 in database transaction handling.
@@ -17,7 +15,6 @@
 database support.
 
 
-Updates
--------
+== Updates
 
 * gwtorm is updated to 1.7.3
diff --git a/ReleaseNotes/ReleaseNotes-2.8.6.txt b/ReleaseNotes/ReleaseNotes-2.8.6.txt
index ab79a20..a810ad0 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.6.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.6.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8.6
-==============================
+= Release notes for Gerrit 2.8.6
 
 There are no schema changes from link:ReleaseNotes-2.8.5.html[2.8.5].
 
@@ -10,8 +9,7 @@
 *Warning*: Support for MySQL's MyISAM storage engine is discontinued.
 Only transactional storage engines are supported.
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2034[Issue 2034],
 link:https://code.google.com/p/gerrit/issues/detail?id=2383[Issue 2383],
@@ -40,8 +38,7 @@
 * Fix sporadic SSHD handshake failures
 (link:https://issues.apache.org/jira/browse/SSHD-330[SSHD-330]).
 
-Updates
--------
+== Updates
 
 * gwtorm is updated to 1.7.1
 * sshd is updated to 0.11.1-atlassian-1
diff --git a/ReleaseNotes/ReleaseNotes-2.8.txt b/ReleaseNotes/ReleaseNotes-2.8.txt
index 2d1dc7a..472f0dc 100644
--- a/ReleaseNotes/ReleaseNotes-2.8.txt
+++ b/ReleaseNotes/ReleaseNotes-2.8.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.8
-============================
+= Release notes for Gerrit 2.8
 
 
 Gerrit 2.8 is now available:
@@ -8,8 +7,7 @@
 https://www.gerritcodereview.com/download/gerrit-2.8.war]
 
 
-Schema Change
--------------
+== Schema Change
 
 
 *WARNING:* This release contains schema changes.  To upgrade:
@@ -46,8 +44,7 @@
 site initialization].
 
 
-Release Highlights
-------------------
+== Release Highlights
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/intro-change-screen.html[
@@ -70,11 +67,9 @@
 * New core plugin: Download Commands.
 
 
-New Features
-------------
+== New Features
 
-Build
-~~~~~
+=== Build
 
 * Gerrit is now built with
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/dev-buck.html[
@@ -83,8 +78,7 @@
 * Documentation is now built with Buck and link:http://asciidoctor.org[Asciidoctor].
 
 
-Indexing and Search
-~~~~~~~~~~~~~~~~~~~
+=== Indexing and Search
 
 Gerrit can be configured to use a
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#__a_id_index_a_section_index[
@@ -111,8 +105,7 @@
 reindex program] before restarting the Gerrit server.
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * Project owners can define `receive.maxObjectSizeLimit` in the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-gerrit.html#receive.maxObjectSizeLimit[
@@ -168,17 +161,14 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/config-labels.html#httpd.label_copyAllScoresOnTrivialRebase[
 configured to copy scores forward to new patch sets for trivial rebases].
 
-Web UI
-~~~~~~
+=== Web UI
 
 
-Global
-^^^^^^
+==== Global
 
 * The change status is shown in a separate column on dashboards and search results.
 
-Change Screens
-^^^^^^^^^^^^^^
+==== Change Screens
 
 
 * New change screen with completely redesigned UI, using the REST API.
@@ -220,8 +210,7 @@
 are uploaded.
 
 
-REST API
-~~~~~~~~
+=== REST API
 
 * Several new link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api.html[
 REST API endpoints] are added.
@@ -230,15 +219,13 @@
 
 * REST views can handle 'HTTP 422 Unprocessable Entity' responses.
 
-Access Rights
-^^^^^^^^^^^^^
+==== Access Rights
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-access.html#list-access[
 List access rights for project(s)]
 
-Accounts
-^^^^^^^^
+==== Accounts
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#create-account[
@@ -310,8 +297,7 @@
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-accounts.html#unstar-change[
 Unstar change]
 
-Changes
-^^^^^^^
+==== Changes
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-changes.html#rebase-change[
@@ -345,8 +331,7 @@
 Get included in]
 
 
-Config
-^^^^^^
+==== Config
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-config.html#get-capabilities[
 Get capabilities]
@@ -355,8 +340,7 @@
 Get version] (of the Gerrit server)
 
 
-Projects
-^^^^^^^^
+==== Projects
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/rest-api-projects.html#list-branches[
@@ -381,8 +365,7 @@
 Set configuration]
 
 
-Capabilities
-~~~~~~~~~~~~
+=== Capabilities
 
 
 New global capabilities are added.
@@ -403,8 +386,7 @@
 explicitly.
 
 
-Emails
-~~~~~~
+=== Emails
 
 * The `RebasedPatchSet` template is removed.  Email notifications for rebased
 changes are now sent with the `ReplacePatchSet` template.
@@ -413,12 +395,10 @@
 to, and links to the file(s) in which comments are made.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
 
-Global
-^^^^^^
+==== Global
 
 
 * Plugins may now contribute buttons to various parts of the UI using the
@@ -475,14 +455,12 @@
 
 
 
-Commit Message Length Checker
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+==== Commit Message Length Checker
 
 
 * Commits whose subject or body length exceeds the limit can be rejected.
 
-Replication
-^^^^^^^^^^^
+==== Replication
 
 * Automatically create missing repositories on the destination.
 +
@@ -528,8 +506,7 @@
 delay.
 
 
-ssh
-~~~
+=== ssh
 
 
 * The `commit-msg` hook installation command is now
@@ -557,8 +534,7 @@
 * The 'CHANGEID,PATCHSET' format for specifying a patch set in the `review` command
 is no longer considered to be a 'legacy' feature that will be removed in future.
 
-Daemon
-~~~~~~
+=== Daemon
 
 
 * Add `--init` option to Daemon to initialize site on daemon start.
@@ -567,12 +543,10 @@
 non-interactive (batch) mode.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 
-General
-~~~~~~~
+=== General
 
 
 * Use the parent change on the same branch for rebases.
@@ -619,8 +593,7 @@
 lots of time can be saved when pushing to complex Gits.
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 
 * Do not persist default project state in `project.config`.
@@ -644,12 +617,10 @@
 
 * Fix JdbcSQLException when numbers are read from cache.
 
-Web UI
-~~~~~~
+=== Web UI
 
 
-Global
-^^^^^^
+==== Global
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1574[Issue 1574]:
@@ -669,8 +640,7 @@
 If a user voted '-1', and then another user voted '+1' for a label, the
 label was shown as a red '1' in the change list instead of red '-1'.
 
-Change Screens
-^^^^^^^^^^^^^^
+==== Change Screens
 
 
 * Default review comment visibility is changed to expand all recent.
@@ -694,8 +664,7 @@
 
 * Prevent duplicate permitted_labels from being shown in labels list.
 
-Diff Screens
-^^^^^^^^^^^^
+==== Diff Screens
 
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=1233[Issue 1233]:
@@ -708,8 +677,7 @@
 that comment was not shown if there was no code changed between
 the two patch sets
 
-Project Screens
-^^^^^^^^^^^^^^^
+==== Project Screens
 
 
 * Only enable the delete branch button when branches are selected.
@@ -717,16 +685,14 @@
 * Disable the delete branch button while branch deletion requests are
 still being processed.
 
-User Profile Screens
-^^^^^^^^^^^^^^^^^^^^
+==== User Profile Screens
 
 
 * The preferred email address field is shown as empty if the user has no
 preferred email address.
 
 
-REST API
-~~~~~~~~
+=== REST API
 
 
 * Support raw input also in POST requests.
@@ -735,8 +701,7 @@
 
 * Return all revisions when `o=ALL_REVISIONS` is set on `/changes/`.
 
-ssh
-~~~
+=== ssh
 
 
 * The `--force-message` option is removed from the
@@ -762,11 +727,9 @@
 * Improve the error message when rejecting upload for review to a read-only project.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
-Global
-^^^^^^
+==== Global
 
 * Better error message when a Javascript plugin cannot be loaded.
 
@@ -784,8 +747,7 @@
 * Make plugin servlet's context path authorization aware.
 
 
-Review Notes
-^^^^^^^^^^^^
+==== Review Notes
 
 * Do not try to create review notes for ref deletion events.
 
@@ -800,22 +762,19 @@
 
 * Correct documentation of the export command.
 
-Emails
-~~~~~~
+=== Emails
 
 * Email notifications are sent for new changes created via actions in the
 Web UI such as cherry-picking or reverting a change.
 
 
-Tools
-~~~~~
+=== Tools
 
 
 * git-exproll.sh: return non-zero on errors
 
 
-Documentation
--------------
+== Documentation
 
 
 * The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.8/index.html[
@@ -832,8 +791,7 @@
 Documentation of the query operator is fixed.
 
 
-Upgrades
---------
+== Upgrades
 
 * Update JGit to 3.1.0.201310021548-r
 * Update gwtorm to 1.7
diff --git a/ReleaseNotes/ReleaseNotes-2.9.1.txt b/ReleaseNotes/ReleaseNotes-2.9.1.txt
index 3377df4..b584193 100644
--- a/ReleaseNotes/ReleaseNotes-2.9.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.9.1.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.9.1
-==============================
+= Release notes for Gerrit 2.9.1
 
 There are no schema changes from link:ReleaseNotes-2.9.html[2.9].
 
@@ -13,8 +12,7 @@
 startup failure described in
 link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2801[Issue 2801]:
 Set default for review SSH command to `notify=ALL`.
diff --git a/ReleaseNotes/ReleaseNotes-2.9.2.txt b/ReleaseNotes/ReleaseNotes-2.9.2.txt
index 4e5de01..ec5b77e 100644
--- a/ReleaseNotes/ReleaseNotes-2.9.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.9.2.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.9.2
-==============================
+= Release notes for Gerrit 2.9.2
 
 Download:
 link:https://www.gerritcodereview.com/download/gerrit-2.9.2.war[
 https://www.gerritcodereview.com/download/gerrit-2.9.2.war]
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* There are no schema changes from
 link:ReleaseNotes-2.9.1.html[2.9.1], but when upgrading from an existing site
@@ -28,11 +26,9 @@
 startup failure described in
 link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
 
-Bug Fixes
----------
+== Bug Fixes
 
-ssh
-~~~
+=== ssh
 
 * Update SSHD to 0.13.0.
 +
@@ -41,8 +37,7 @@
 +
 Also update SSHD Mina to 2.0.8 and Bouncycastle to 1.51.
 
-Database
-~~~~~~~~
+=== Database
 
 * Update gwtorm to 1.14.
 +
@@ -54,8 +49,7 @@
 were initialized with Gerrit version 2.6 or later, the primary key column
 order will be fixed during initialization when upgrading to 2.9.2.
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 * Fix "400 cannot create query for index" error in "Conflicts With" list.
 +
@@ -79,8 +73,7 @@
 the database. If a user clicks on search result from a stale change, they will
 get a 404 page and the change will be removed from the index.
 
-Change Screen
-~~~~~~~~~~~~~
+=== Change Screen
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2964[Issue 2964]:
 Fix comment box font colors of dark CodeMirror themes.
@@ -107,8 +100,7 @@
 
 * Remove 'send email' checkbox from reply box on change screen.
 
-Plugins
-~~~~~~~
+=== Plugins
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=543[Issue 543]
 Replication plugin: Prevent creating repos on extra servers.
@@ -120,8 +112,7 @@
 By ensuring the authGroup can see the project first, the repository is
 not created if it's not needed.
 
-Security
-~~~~~~~~
+=== Security
 
 * Do not throw away bytes from the CNSPRG when generating HTTP passwords.
 +
@@ -141,8 +132,7 @@
 with BLOCK or DENY action were considered as project owners.
 
 
-Miscellaneous Fixes
-~~~~~~~~~~~~~~~~~~~
+=== Miscellaneous Fixes
 
 * link:https://code.google.com/p/gerrit/issues/detail?id=2911[Issue 2911]:
 Fix Null Pointer Exception after a MergeValidationListener throws
diff --git a/ReleaseNotes/ReleaseNotes-2.9.3.txt b/ReleaseNotes/ReleaseNotes-2.9.3.txt
index e6c8573..1b732cb 100644
--- a/ReleaseNotes/ReleaseNotes-2.9.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.9.3.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.9.3
-==============================
+= Release notes for Gerrit 2.9.3
 
 Download:
 link:https://www.gerritcodereview.com/download/gerrit-2.9.3.war[
 https://www.gerritcodereview.com/download/gerrit-2.9.3.war]
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* There are no schema changes from
 link:ReleaseNotes-2.9.2.html[2.9.2], but when upgrading from an existing site
@@ -28,8 +26,7 @@
 startup failure described in
 link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
 
-Bug Fixes
----------
+== Bug Fixes
 
 *Downgrade SSHD to 0.9.0-4-g5967cfd*
 
diff --git a/ReleaseNotes/ReleaseNotes-2.9.4.txt b/ReleaseNotes/ReleaseNotes-2.9.4.txt
index 5063489..e2ad6ac 100644
--- a/ReleaseNotes/ReleaseNotes-2.9.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.9.4.txt
@@ -1,12 +1,10 @@
-Release notes for Gerrit 2.9.4
-==============================
+= Release notes for Gerrit 2.9.4
 
 Download:
 link:https://www.gerritcodereview.com/download/gerrit-2.9.4.war[
 https://www.gerritcodereview.com/download/gerrit-2.9.4.war]
 
-Important Notes
----------------
+== Important Notes
 
 *WARNING:* There are no schema changes from
 link:ReleaseNotes-2.9.3.html[2.9.3], but when upgrading from an existing site
@@ -28,8 +26,7 @@
 startup failure described in
 link:https://code.google.com/p/gerrit/issues/detail?id=3084[Issue 3084].
 
-Bug Fixes
----------
+== Bug Fixes
 
 * Update JGit to 3.4.2.201412180340-r
 +
diff --git a/ReleaseNotes/ReleaseNotes-2.9.txt b/ReleaseNotes/ReleaseNotes-2.9.txt
index 3387f98..c026914 100644
--- a/ReleaseNotes/ReleaseNotes-2.9.txt
+++ b/ReleaseNotes/ReleaseNotes-2.9.txt
@@ -1,5 +1,4 @@
-Release notes for Gerrit 2.9
-============================
+= Release notes for Gerrit 2.9
 
 
 Gerrit 2.9 is now available:
@@ -20,8 +19,7 @@
 link:ReleaseNotes-2.8.6.1.html[Gerrit 2.8.6.1].
 These bug fixes are *not* listed in these release notes.
 
-Important Notes
----------------
+== Important Notes
 
 
 *WARNING:* This release contains schema changes.  To upgrade:
@@ -98,8 +96,7 @@
 the site's `plugins` folder.
 
 
-Release Highlights
-------------------
+== Release Highlights
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2065[Issue 2065]:
@@ -116,22 +113,18 @@
 to the old change screen.
 
 
-New Features
-------------
+== New Features
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 
-Global
-^^^^^^
+==== Global
 
 * Project links by default link to the project dashboard.
 
 
-New Change Screen
-^^^^^^^^^^^^^^^^^
+==== New Change Screen
 
 
 * The new change screen is now the default change screen.
@@ -197,8 +190,7 @@
 New copy-to-clipboard button for commit ID.
 
 
-New Side-by-Side Diff Screen
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+==== New Side-by-Side Diff Screen
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=348[Issue 348]:
 The lines of a patch file are linkable.
@@ -220,8 +212,7 @@
 The full file path is shown.
 
 
-Change List / Dashboards
-^^^^^^^^^^^^^^^^^^^^^^^^
+==== Change List / Dashboards
 
 * The `Status` column shows `Merge Conflict` for changes that are not
 mergeable.
@@ -240,8 +231,7 @@
 without the `limit` operator.
 
 
-Project Screens
-^^^^^^^^^^^^^^^
+==== Project Screens
 
 * The general project screen provides a copyable clone command that
 automatically installs the `commit-msg` hook.
@@ -263,15 +253,13 @@
 Run Garbage Collection] global capability.
 
 
-User Preferences
-^^^^^^^^^^^^^^^^
+==== User Preferences
 
 * Users can choose the UK date format to render dates and timestamps in
 the UI.
 
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 * Support for query via the SQL index is removed. The usage of
 a secondary index is now mandatory.
@@ -280,8 +268,7 @@
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/pgm-reindex.html[
 reindex] program.
 
-ssh
-~~~
+=== ssh
 
 * New `--notify` option on the
 link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-review.html[
@@ -305,12 +292,10 @@
 New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/cmd-create-branch.html[
 create-branch] command.
 
-REST API
-~~~~~~~~
+=== REST API
 
 
-Changes
-^^^^^^^
+==== Changes
 
 
 [[sortkey-deprecation]]
@@ -325,22 +310,19 @@
 Queries with sortkeys are still supported against old index versions, to enable
 online reindexing while clients have an older JS version.
 
-Projects
-^^^^^^^^
+==== Projects
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-projects.html#get-content[
 Get content of a file from HEAD of a branch].
 
-Documentation
-^^^^^^^^^^^^^
+==== Documentation
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/rest-api-documentation.html#search-documentation.html[
 Search documentation].
 
-Access Rights
-~~~~~~~~~~~~~
+=== Access Rights
 
 
 * New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/access-control.html#capability_viewAllAccounts[
@@ -374,8 +356,7 @@
 Gerrit but not in LDAP are authenticated with their HTTP password from
 the Gerrit database.
 
-Search
-~~~~~~
+=== Search
 
 * New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/user-search.html#mergeable[
 is:mergeable] search operator.
@@ -411,8 +392,7 @@
 ** `p` = `project`
 ** `f` = `file`
 
-Daemon
-~~~~~~
+=== Daemon
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/dev-inspector.html[
@@ -421,8 +401,7 @@
 New `-s` option is added to the Daemon to start an interactive Jython shell for inspection and
 troubleshooting of live data of the Gerrit instance.
 
-Documentation
-~~~~~~~~~~~~~
+=== Documentation
 
 
 * The documentation is now
@@ -442,8 +421,7 @@
 Newly structured documentation index].
 
 
-Configuration
-~~~~~~~~~~~~~
+=== Configuration
 
 * New init step for installing the `Verified` label.
 
@@ -463,8 +441,7 @@
 Allow the text of the "Report Bug" link to be configured.
 
 
-Misc
-~~~~
+=== Misc
 
 * The removal of reviewers and their votes is recorded as a change
 message.
@@ -478,8 +455,7 @@
 * Stable CSS class names.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
 
 * Plugin API to invoke the REST API.
@@ -499,8 +475,7 @@
 Remote plugin administration is by default disabled].
 
 
-Extension Points
-^^^^^^^^^^^^^^^^
+==== Extension Points
 
 
 * Extension point to provide a "Message Of The Day".
@@ -531,8 +506,7 @@
 DataSource Interception].
 
 
-JavaScript Plugins
-^^^^^^^^^^^^^^^^^^
+==== JavaScript Plugins
 
 
 * link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.9/js-api.html#self_on[
@@ -545,19 +519,16 @@
 insert arbitrary HTML fragments from plugins.
 
 
-Bug Fixes
----------
+== Bug Fixes
 
 
-Access Rights
-~~~~~~~~~~~~~
+=== Access Rights
 
 
 * Fix possibility to overcome BLOCK permissions.
 
 
-Web UI
-~~~~~~
+=== Web UI
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2652[Issue 2652]:
@@ -622,8 +593,7 @@
 Fix copying from copyable label in Safari.
 
 
-Secondary Index
-~~~~~~~~~~~~~~~
+=== Secondary Index
 
 * Fix Online Reindexing.
 
@@ -640,16 +610,14 @@
 Reindex change after updating commit message.
 
 
-REST
-~~~~
+=== REST
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2568[Issue 2568]:
 Update description file during `PUT /projects/{name}/config`.
 
 
-SSH
-~~~
+=== SSH
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2516[Issue 2516]:
@@ -659,8 +627,7 @@
 Clarify for review command when `--verified` can be used.
 
 
-Plugins
-~~~~~~~
+=== Plugins
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2551[Issue 2551]:
@@ -670,16 +637,14 @@
 Respect servlet context path in URL for top menu items.
 
 
-Other
-~~~~~
+=== Other
 
 
 * link:http://code.google.com/p/gerrit/issues/detail?id=2382[Issue 2382]:
 Clean left over data migration after removal of TrackingIds table.
 
 
-Upgrades
---------
+== Upgrades
 
 * Update JGit to 3.4.0.201405051725-m7
 +
@@ -704,11 +669,9 @@
 * Update GWT to 2.6.0
 
 
-Plugins
--------
+== Plugins
 
-Replication
-~~~~~~~~~~~
+=== Replication
 
 * Default push refSpec is changed to `refs/*:refs/*` (non-forced push).
 +
@@ -728,8 +691,7 @@
 * Configuration changes can be detected and replication is
 automatically restarted.
 
-Issue Tracker System plugins
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+=== Issue Tracker System plugins
 
 *WARNING:* The `hooks-*` plugins (`plugins/hooks-bugzilla`,
 `plugins/hooks-jira` and `plugins/hooks-rtc`) are deprecated with
diff --git a/ReleaseNotes/asciidoc.conf b/ReleaseNotes/asciidoc.conf
deleted file mode 100644
index 527f58f..0000000
--- a/ReleaseNotes/asciidoc.conf
+++ /dev/null
@@ -1,19 +0,0 @@
-[attributes]
-asterisk=&#42;
-plus=&#43;
-caret=&#94;
-startsb=&#91;
-endsb=&#93;
-tilde=&#126;
-max-width=55em
-
-[specialsections]
-GERRIT=gerrituplink
-
-[gerrituplink]
-<hr style="
-  height: 2px;
-  color: silver;
-  margin-top: 1.2em;
-  margin-bottom: 0.5em;
-">
diff --git a/ReleaseNotes/config.defs b/ReleaseNotes/config.defs
new file mode 100644
index 0000000..86b7603
--- /dev/null
+++ b/ReleaseNotes/config.defs
@@ -0,0 +1,14 @@
+def release_notes_attributes():
+  return [
+    'toc',
+    'newline="\\n"',
+    'asterisk="&#42;"',
+    'plus="&#43;"',
+    'caret="&#94;"',
+    'startsb="&#91;"',
+    'endsb="&#93;"',
+    'tilde="&#126;"',
+    'last-update-label!',
+    'stylesheet=DEFAULT',
+    'linkcss=true',
+  ]
diff --git a/ReleaseNotes/index.txt b/ReleaseNotes/index.txt
index 09e9b73..945f09f 100644
--- a/ReleaseNotes/index.txt
+++ b/ReleaseNotes/index.txt
@@ -1,9 +1,13 @@
-Gerrit Code Review - Release Notes
-==================================
+= Gerrit Code Review - Release Notes
 
-[[2_12]]
-Version 2.12.x
---------------
+[[s2_13]]
+== Version 2.13.x
+* link:ReleaseNotes-2.13.2.html[2.13.2]
+* link:ReleaseNotes-2.13.1.html[2.13.1]
+* link:ReleaseNotes-2.13.html[2.13]
+
+[[s2_12]]
+== Version 2.12.x
 * link:ReleaseNotes-2.12.5.html[2.12.5]
 * link:ReleaseNotes-2.12.4.html[2.12.4]
 * link:ReleaseNotes-2.12.3.html[2.12.3]
@@ -11,9 +15,8 @@
 * link:ReleaseNotes-2.12.1.html[2.12.1]
 * link:ReleaseNotes-2.12.html[2.12]
 
-[[2_11]]
-Version 2.11.x
---------------
+[[s2_11]]
+== Version 2.11.x
 * link:ReleaseNotes-2.11.10.html[2.11.10]
 * link:ReleaseNotes-2.11.9.html[2.11.9]
 * link:ReleaseNotes-2.11.8.html[2.11.8]
@@ -26,9 +29,8 @@
 * link:ReleaseNotes-2.11.1.html[2.11.1]
 * link:ReleaseNotes-2.11.html[2.11]
 
-[[2_10]]
-Version 2.10.x
---------------
+[[s2_10]]
+== Version 2.10.x
 * link:ReleaseNotes-2.10.7.html[2.10.7]
 * link:ReleaseNotes-2.10.6.html[2.10.6]
 * link:ReleaseNotes-2.10.5.html[2.10.5]
@@ -39,18 +41,16 @@
 * link:ReleaseNotes-2.10.1.html[2.10.1]
 * link:ReleaseNotes-2.10.html[2.10]
 
-[[2_9]]
-Version 2.9.x
--------------
+[[s2_9]]
+== Version 2.9.x
 * link:ReleaseNotes-2.9.4.html[2.9.4]
 * link:ReleaseNotes-2.9.3.html[2.9.3]
 * link:ReleaseNotes-2.9.2.html[2.9.2]
 * link:ReleaseNotes-2.9.1.html[2.9.1]
 * link:ReleaseNotes-2.9.html[2.9]
 
-[[2_8]]
-Version 2.8.x
--------------
+[[s2_8]]
+== Version 2.8.x
 * link:ReleaseNotes-2.8.6.1.html[2.8.6.1]
 * link:ReleaseNotes-2.8.6.html[2.8.6]
 * link:ReleaseNotes-2.8.5.html[2.8.5]
@@ -60,20 +60,17 @@
 * link:ReleaseNotes-2.8.1.html[2.8.1]
 * link:ReleaseNotes-2.8.html[2.8]
 
-[[2_7]]
-Version 2.7.x
--------------
+[[s2_7]]
+== Version 2.7.x
 * link:ReleaseNotes-2.7.html[2.7]
 
-[[2_6]]
-Version 2.6.x
--------------
+[[s2_6]]
+== Version 2.6.x
 * link:ReleaseNotes-2.6.1.html[2.6.1]
 * link:ReleaseNotes-2.6.html[2.6]
 
-[[2_5]]
-Version 2.5.x
--------------
+[[s2_5]]
+== Version 2.5.x
 * link:ReleaseNotes-2.5.6.html[2.5.6]
 * link:ReleaseNotes-2.5.5.html[2.5.5]
 * link:ReleaseNotes-2.5.4.html[2.5.4]
@@ -82,33 +79,29 @@
 * link:ReleaseNotes-2.5.1.html[2.5.1]
 * link:ReleaseNotes-2.5.html[2.5]
 
-[[2_4]]
-Version 2.4.x
--------------
+[[s2_4]]
+== Version 2.4.x
 * link:ReleaseNotes-2.4.4.html[2.4.4]
 * link:ReleaseNotes-2.4.3.html[2.4.3]
 * link:ReleaseNotes-2.4.2.html[2.4.2]
 * link:ReleaseNotes-2.4.1.html[2.4.1]
 * link:ReleaseNotes-2.4.html[2.4]
 
-[[2_3]]
-Version 2.3.x
--------------
+[[s2_3]]
+== Version 2.3.x
 * link:ReleaseNotes-2.3.1.html[2.3.1]
 * link:ReleaseNotes-2.3.html[2.3]
 
-[[2_2]]
-Version 2.2.x
--------------
+[[s2_2]]
+== Version 2.2.x
 * link:ReleaseNotes-2.2.2.2.html[2.2.2.2]
 * link:ReleaseNotes-2.2.2.1.html[2.2.2.1]
 * link:ReleaseNotes-2.2.2.html[2.2.2]
 * link:ReleaseNotes-2.2.1.html[2.2.1]
 * link:ReleaseNotes-2.2.0.html[2.2.0]
 
-[[2_1]]
-Version 2.1.x
--------------
+[[s2_1]]
+== Version 2.1.x
 * link:ReleaseNotes-2.1.10.html[2.1.10]
 * link:ReleaseNotes-2.1.9.html[2.1.9]
 * link:ReleaseNotes-2.1.8.html[2.1.8]
@@ -129,9 +122,8 @@
 * link:ReleaseNotes-2.1.1.html[2.1.1]
 * link:ReleaseNotes-2.1.html[2.1]
 
-[[2_0]]
-Version 2.0.x
--------------
+[[s2_0]]
+== Version 2.0.x
 * link:ReleaseNotes-2.0.24.html[2.0.24.2]
 * link:ReleaseNotes-2.0.24.html[2.0.24.1]
 * link:ReleaseNotes-2.0.24.html[2.0.24]
diff --git a/VERSION b/VERSION
index 1b0baa6..e31db73 100644
--- a/VERSION
+++ b/VERSION
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = '2.12.8'
+GERRIT_VERSION = '2.13.10'
diff --git a/WORKSPACE b/WORKSPACE
new file mode 100644
index 0000000..d465b37
--- /dev/null
+++ b/WORKSPACE
@@ -0,0 +1,699 @@
+ANTLR_VERS = '3.5.2'
+
+maven_jar(
+  name = 'java_runtime',
+  artifact = 'org.antlr:antlr-runtime:' + ANTLR_VERS,
+  sha1 = 'cd9cd41361c155f3af0f653009dcecb08d8b4afd',
+)
+
+maven_jar(
+  name = 'stringtemplate',
+  artifact = 'org.antlr:stringtemplate:4.0.2',
+  sha1 = 'e28e09e2d44d60506a7bcb004d6c23ff35c6ac08',
+)
+
+maven_jar(
+  name = 'org_antlr',
+  artifact = 'org.antlr:antlr:' + ANTLR_VERS,
+  sha1 = 'c4a65c950bfc3e7d04309c515b2177c00baf7764',
+)
+
+maven_jar(
+  name = 'antlr27',
+  artifact = 'antlr:antlr:2.7.7',
+  sha1 = '83cd2cd674a217ade95a4bb83a8a14f351f48bd0',
+)
+
+GUICE_VERS = '4.0'
+
+maven_jar(
+  name = 'guice_library',
+  artifact = 'com.google.inject:guice:' + GUICE_VERS,
+  sha1 = '0f990a43d3725781b6db7cd0acf0a8b62dfd1649',
+)
+
+maven_jar(
+  name = 'guice_assistedinject',
+  artifact = 'com.google.inject.extensions:guice-assistedinject:' + GUICE_VERS,
+  sha1 = '8fa6431da1a2187817e3e52e967535899e2e46ca',
+)
+
+maven_jar(
+  name = 'guice_servlet',
+  artifact = 'com.google.inject.extensions:guice-servlet:' + GUICE_VERS,
+  sha1 = '4503da866f4c402b5090579b40c1c4aaefabb164',
+)
+
+maven_jar(
+  name = 'aopalliance',
+  artifact = 'aopalliance:aopalliance:1.0',
+  sha1 = '0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8',
+)
+
+maven_jar(
+  name = 'javax_inject',
+  artifact = 'javax.inject:javax.inject:1',
+  sha1 = '6975da39a7040257bd51d21a231b76c915872d38',
+)
+
+maven_jar(
+  name = 'servlet_api_3_1',
+  artifact = 'org.apache.tomcat:tomcat-servlet-api:8.0.24',
+  sha1 = '5d9e2e895e3111622720157d0aa540066d5fce3a',
+)
+
+GWT_VERS = '2.7.0'
+
+maven_jar(
+  name = 'user',
+  artifact = 'com.google.gwt:gwt-user:' + GWT_VERS,
+  sha1 = 'bdc7af42581745d3d79c2efe0b514f432b998a5b',
+)
+
+maven_jar(
+  name = 'dev',
+  artifact = 'com.google.gwt:gwt-dev:' + GWT_VERS,
+  sha1 = 'c2c3dd5baf648a0bb199047a818be5e560f48982',
+)
+
+maven_jar(
+  name = 'javax_validation',
+  artifact = 'javax.validation:validation-api:1.0.0.GA',
+  sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
+)
+
+JGIT_VERS = '4.4.1.201607150455-r.105-g81ba2be'
+
+maven_jar(
+  name = 'jgit',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
+  sha1 = 'c07c9c66da7983095a40945c0bfab211a473c4c5',
+)
+
+maven_jar(
+  name = 'jgit_servlet',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
+  sha1 = 'bb01841b74a48abe506c2e44f238e107188e6c8f',
+)
+
+# TODO(davido): Remove this hack when maven_jar supports pulling sources
+# https://github.com/bazelbuild/bazel/issues/308
+http_file(
+  name = 'jgit_src',
+  sha256 = '881906cb1e6743cb78df6dd3788cab7e974308fbb98cab4915e6591a62aa9374',
+  url = 'http://gerrit-maven.storage.googleapis.com/org/eclipse/jgit/org.eclipse.jgit/' +
+      '%s/org.eclipse.jgit-%s-sources.jar' % (JGIT_VERS, JGIT_VERS),
+)
+
+maven_jar(
+  name = 'ewah',
+  artifact = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
+  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
+)
+
+maven_jar(
+  name = 'jgit_archive',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
+  sha1 = 'fc3bc40e070c54198a046fcd3a1f7cac47163961',
+)
+
+maven_jar(
+  name = 'jgit_junit',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
+  sha1 = 'b4565ee84a6e1d0952010282b9fcf705ac6171a7',
+)
+
+maven_jar(
+  name = 'gwtjsonrpc',
+  artifact = 'com.google.gerrit:gwtjsonrpc:1.8',
+  sha1 = 'c264bf2f543cffddceada5cdf031eea06dbd44a0',
+)
+
+http_jar(
+  name = 'gwtjsonrpc_src',
+  sha256 = '2ef86396861a7c555c404b5a20a72dc6599b541ce2d1370a62f6470eefe7142d',
+  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtjsonrpc/1.8/gwtjsonrpc-1.8-sources.jar',
+)
+
+maven_jar(
+  name = 'gson',
+  artifact = 'com.google.code.gson:gson:2.6.2',
+  sha1 = 'f1bc476cc167b18e66c297df599b2377131a8947',
+)
+
+maven_jar(
+  name = 'gwtorm_client',
+  artifact = 'com.google.gerrit:gwtorm:1.15',
+  sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
+)
+
+http_jar(
+  name = 'gwtorm_client_src',
+  sha256 = 'e0cf9382ed8c3cd1f0884ab77dabe634a04546676c4960d8b4c4b64a20132ef6',
+  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtorm/1.15/gwtorm-1.15-sources.jar',
+)
+
+maven_jar(
+  name = 'protobuf',
+  artifact = 'com.google.protobuf:protobuf-java:2.5.0',
+  sha1 = 'a10732c76bfacdbd633a7eb0f7968b1059a65dfa',
+)
+
+maven_jar(
+  name = 'joda_time',
+  artifact = 'joda-time:joda-time:2.8',
+  sha1 = '9f2785d7184b97d005a44241ccaf980f43b9ccdb',
+)
+
+maven_jar(
+  name = 'joda_convert',
+  artifact = 'org.joda:joda-convert:1.2',
+  sha1 = '35ec554f0cd00c956cc69051514d9488b1374dec',
+)
+
+maven_jar(
+  name = 'guava',
+  artifact = 'com.google.guava:guava:19.0',
+  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
+)
+
+maven_jar(
+  name = 'velocity',
+  artifact = 'org.apache.velocity:velocity:1.7',
+  sha1 = '2ceb567b8f3f21118ecdec129fe1271dbc09aa7a',
+)
+
+maven_jar(
+  name = 'jsch',
+  artifact = 'com.jcraft:jsch:0.1.53',
+  sha1 = '658b682d5c817b27ae795637dfec047c63d29935',
+)
+
+maven_jar(
+  name = 'juniversalchardet',
+  artifact = 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3',
+  sha1 = 'cd49678784c46aa8789c060538e0154013bb421b',
+)
+
+SLF4J_VERS = '1.7.7'
+
+maven_jar(
+  name = 'log_api',
+  artifact = 'org.slf4j:slf4j-api:' + SLF4J_VERS,
+  sha1 = '2b8019b6249bb05d81d3a3094e468753e2b21311',
+)
+
+maven_jar(
+  name = 'log_nop',
+  artifact = 'org.slf4j:slf4j-nop:' + SLF4J_VERS,
+  sha1 = '6cca9a3b999ff28b7a35ca762b3197cd7e4c2ad1',
+)
+
+maven_jar(
+  name = 'impl_log4j',
+  artifact = 'org.slf4j:slf4j-log4j12:' + SLF4J_VERS,
+  sha1 = '58f588119ffd1702c77ccab6acb54bfb41bed8bd',
+)
+
+maven_jar(
+  name = 'jcl_over_slf4j',
+  artifact = 'org.slf4j:jcl-over-slf4j:' + SLF4J_VERS,
+  sha1 = '56003dcd0a31deea6391b9e2ef2f2dc90b205a92',
+)
+
+maven_jar(
+  name = 'log4j',
+  artifact = 'log4j:log4j:1.2.17',
+  sha1 = '5af35056b4d257e4b64b9e8069c0746e8b08629f',
+)
+
+maven_jar(
+  name = 'jsonevent_layout',
+  artifact = 'net.logstash.log4j:jsonevent-layout:1.7',
+  sha1 = '507713504f0ddb75ba512f62763519c43cf46fde',
+)
+
+maven_jar(
+  name = 'json_smart',
+  artifact = 'net.minidev:json-smart:1.1.1',
+  sha1 = '24a2f903d25e004de30ac602c5b47f2d4e420a59',
+)
+
+maven_jar(
+  name = 'args4j',
+  artifact = 'args4j:args4j:2.0.26',
+  sha1 = '01ebb18ebb3b379a74207d5af4ea7c8338ebd78b',
+)
+
+maven_jar(
+  name = 'commons_codec',
+  artifact = 'commons-codec:commons-codec:1.4',
+  sha1 = '4216af16d38465bbab0f3dff8efa14204f7a399a',
+)
+
+maven_jar(
+  name = 'commons_collections',
+  artifact = 'commons-collections:commons-collections:3.2.2',
+  sha1 = '8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5',
+)
+
+maven_jar(
+  name = 'commons_compress',
+  artifact = 'org.apache.commons:commons-compress:1.7',
+  sha1 = 'ab365c96ee9bc88adcc6fa40d185c8e15a31410d',
+)
+
+maven_jar(
+  name = 'commons_lang',
+  artifact = 'commons-lang:commons-lang:2.6',
+  sha1 = '0ce1edb914c94ebc388f086c6827e8bdeec71ac2',
+)
+
+maven_jar(
+  name = 'commons_dbcp',
+  artifact = 'commons-dbcp:commons-dbcp:1.4',
+  sha1 = '30be73c965cc990b153a100aaaaafcf239f82d39',
+)
+
+maven_jar(
+  name = 'commons_pool',
+  artifact = 'commons-pool:commons-pool:1.5.5',
+  sha1 = '7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b',
+)
+
+maven_jar(
+  name = 'commons_net',
+  artifact = 'commons-net:commons-net:2.2',
+  sha1 = '07993c12f63c78378f8c90de4bc2ee62daa7ca3a',
+)
+
+maven_jar(
+  name = 'commons_oro',
+  artifact = 'oro:oro:2.0.8',
+  sha1 = '5592374f834645c4ae250f4c9fbb314c9369d698',
+)
+
+maven_jar(
+  name = 'commons_validator',
+  artifact = 'commons-validator:commons-validator:1.5.1',
+  sha1 = '86d05a46e8f064b300657f751b5a98c62807e2a0',
+)
+
+maven_jar(
+  name = 'automaton',
+  artifact = 'dk.brics.automaton:automaton:1.11-8',
+  sha1 = '6ebfa65eb431ff4b715a23be7a750cbc4cc96d0f',
+)
+
+maven_jar(
+  name = 'pegdown',
+  artifact = 'org.pegdown:pegdown:1.4.2',
+  sha1 = 'd96db502ed832df867ff5d918f05b51ba3879ea7',
+)
+
+maven_jar(
+  name = 'grappa',
+  artifact = 'com.github.parboiled1:grappa:1.0.4',
+  sha1 = 'ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5',
+)
+
+maven_jar(
+  name = 'jitescript',
+  artifact = 'me.qmx.jitescript:jitescript:0.4.0',
+  sha1 = '2e35862b0435c1b027a21f3d6eecbe50e6e08d54',
+)
+
+OW2_VERS = '5.0.3'
+
+maven_jar(
+  name = 'ow2_asm',
+  artifact = 'org.ow2.asm:asm:' + OW2_VERS,
+  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
+)
+
+maven_jar(
+  name = 'ow2_asm_analysis',
+  artifact = 'org.ow2.asm:asm-analysis:' + OW2_VERS,
+  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
+)
+
+maven_jar(
+  name = 'ow2_asm_commons',
+  artifact = 'org.ow2.asm:asm-commons:' + OW2_VERS,
+  sha1 = 'a7111830132c7f87d08fe48cb0ca07630f8cb91c',
+)
+
+maven_jar(
+  name = 'ow2_asm_tree',
+  artifact = 'org.ow2.asm:asm-tree:' + OW2_VERS,
+  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
+)
+
+maven_jar(
+  name = 'ow2_asm_util',
+  artifact = 'org.ow2.asm:asm-util:' + OW2_VERS,
+  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
+)
+
+maven_jar(
+  name = 'auto_value',
+  artifact = 'com.google.auto.value:auto-value:1.2',
+  sha1 = '6873fed014fe1de1051aae2af68ba266d2934471',
+)
+
+maven_jar(
+  name = 'tukaani_xz',
+  artifact = 'org.tukaani:xz:1.4',
+  sha1 = '18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3',
+)
+
+LUCENE_VERS = '5.4.1'
+
+maven_jar(
+  name = 'lucene_core',
+  artifact = 'org.apache.lucene:lucene-core:' + LUCENE_VERS,
+  sha1 = 'c52b2088e2c30dfd95fd296ab6fb9cf8de9855ab',
+)
+
+maven_jar(
+  name = 'lucene_analyzers_common',
+  artifact = 'org.apache.lucene:lucene-analyzers-common:' + LUCENE_VERS,
+  sha1 = 'c2aa2c4e00eb9cdeb5ac00dc0495e70c441f681e',
+)
+
+maven_jar(
+  name = 'backward_codecs',
+  artifact = 'org.apache.lucene:lucene-backward-codecs:' + LUCENE_VERS,
+  sha1 = '5273da96380dfab302ad06c27fe58100db4c4e2f',
+)
+
+maven_jar(
+  name = 'lucene_misc',
+  artifact = 'org.apache.lucene:lucene-misc:' + LUCENE_VERS,
+  sha1 = '95f433b9d7dd470cc0aa5076e0f233907745674b',
+)
+
+maven_jar(
+  name = 'lucene_queryparser',
+  artifact = 'org.apache.lucene:lucene-queryparser:' + LUCENE_VERS,
+  sha1 = 'dccd5279bfa656dec21af444a7a66820eb1cd618',
+)
+
+maven_jar(
+  name = 'mime_util',
+  artifact = 'eu.medsea.mimeutil:mime-util:2.1.3',
+  sha1 = '0c9cfae15c74f62491d4f28def0dff1dabe52a47',
+)
+
+PROLOG_VERS = '1.4.1'
+
+maven_jar(
+  name = 'prolog_runtime',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'com.googlecode.prolog-cafe:prolog-runtime:' + PROLOG_VERS,
+  sha1 = 'c5d9f92e49c485969dcd424dfc0c08125b5f8246',
+)
+
+maven_jar(
+  name = 'prolog_compiler',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'com.googlecode.prolog-cafe:prolog-compiler:' + PROLOG_VERS,
+  sha1 = 'ac24044c6ec166fdcb352b78b80d187ead3eff41',
+)
+
+maven_jar(
+  name = 'prolog_io',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'com.googlecode.prolog-cafe:prolog-io:' + PROLOG_VERS,
+  sha1 = 'b072426a4b1b8af5e914026d298ee0358a8bb5aa',
+)
+
+maven_jar(
+  name = 'cafeteria',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + PROLOG_VERS,
+  sha1 = '8cbc3b0c19e7167c42d3f11667b21cb21ddec641',
+)
+
+maven_jar(
+  name = 'guava_retrying',
+  artifact = 'com.github.rholder:guava-retrying:2.0.0',
+  sha1 = '974bc0a04a11cc4806f7c20a34703bd23c34e7f4',
+)
+
+maven_jar(
+  name = 'jsr305',
+  artifact = 'com.google.code.findbugs:jsr305:2.0.2',
+  sha1 = '516c03b21d50a644d538de0f0369c620989cd8f0',
+)
+
+maven_jar(
+  name = 'blame_cache',
+  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  artifact = 'com/google/gitiles:blame-cache:0.1-9',
+  sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
+)
+
+maven_jar(
+  name = 'dropwizard_core',
+  artifact = 'io.dropwizard.metrics:metrics-core:3.1.2',
+  sha1 = '224f03afd2521c6c94632f566beb1bb5ee32cf07',
+)
+
+# This version must match the version that also appears in
+# gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+BC_VERS = '1.52'
+
+maven_jar(
+  name = 'bcprov',
+  artifact = 'org.bouncycastle:bcprov-jdk15on:' + BC_VERS,
+  sha1 = '88a941faf9819d371e3174b5ed56a3f3f7d73269',
+)
+
+maven_jar(
+  name = 'bcpg',
+  artifact = 'org.bouncycastle:bcpg-jdk15on:' + BC_VERS,
+  sha1 = 'ff4665a4b5633ff6894209d5dd10b7e612291858',
+)
+
+maven_jar(
+  name = 'bcpkix',
+  artifact = 'org.bouncycastle:bcpkix-jdk15on:' + BC_VERS,
+  sha1 = 'b8ffac2bbc6626f86909589c8cc63637cc936504',
+)
+
+maven_jar(
+  name = 'sshd',
+  artifact = 'org.apache.sshd:sshd-core:1.2.0',
+  sha1 = '4bc24a8228ba83dac832680366cf219da71dae8e',
+)
+
+maven_jar(
+  name = 'mina_core',
+  artifact = 'org.apache.mina:mina-core:2.0.10',
+  sha1 = 'a1cb1136b104219d6238de886bf5a3ea4554eb58',
+)
+
+maven_jar(
+  name = 'h2',
+  artifact = 'com.h2database:h2:1.3.176',
+  sha1 = 'fd369423346b2f1525c413e33f8cf95b09c92cbd',
+)
+
+HTTPCOMP_VERS = '4.4.1'
+
+maven_jar(
+  name = 'fluent_hc',
+  artifact = 'org.apache.httpcomponents:fluent-hc:' + HTTPCOMP_VERS,
+  sha1 = '96fb842b68a44cc640c661186828b60590c71261',
+)
+
+maven_jar(
+  name = 'httpclient',
+  artifact = 'org.apache.httpcomponents:httpclient:' + HTTPCOMP_VERS,
+  sha1 = '016d0bc512222f1253ee6b64d389c84e22f697f0',
+)
+
+maven_jar(
+  name = 'httpcore',
+  artifact = 'org.apache.httpcomponents:httpcore:' + HTTPCOMP_VERS,
+  sha1 = 'f5aa318bda4c6c8d688c9d00b90681dcd82ce636',
+)
+
+maven_jar(
+  name = 'httpmime',
+  artifact = 'org.apache.httpcomponents:httpmime:' + HTTPCOMP_VERS,
+  sha1 = '2f8757f5ac5e38f46c794e5229d1f3c522e9b1df',
+)
+
+# Test-only dependencies below.
+
+maven_jar(
+  name = 'jimfs',
+  artifact = 'com.google.jimfs:jimfs:1.0',
+  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
+)
+
+maven_jar(
+  name = 'junit',
+  artifact = 'junit:junit:4.11',
+  sha1 = '4e031bb61df09069aeb2bffb4019e7a5034a4ee0',
+)
+
+maven_jar(
+  name = 'hamcrest_core',
+  artifact = 'org.hamcrest:hamcrest-core:1.3',
+  sha1 = '42a25dc3219429f0e5d060061f71acb49bf010a0',
+)
+
+maven_jar(
+  name = 'truth',
+  artifact = 'com.google.truth:truth:0.28',
+  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+)
+
+maven_jar(
+  name = 'easymock',
+  artifact = 'org.easymock:easymock:3.4', # When bumping the version
+  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
+)
+
+maven_jar(
+  name = 'cglib_2_2',
+  artifact = 'cglib:cglib-nodep:2.2.2',
+  sha1 = '00d456bb230c70c0b95c76fb28e429d42f275941',
+)
+
+maven_jar(
+  name = 'objenesis',
+  artifact = 'org.objenesis:objenesis:2.2',
+  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
+)
+
+POWERM_VERS = '1.6.4'
+
+maven_jar(
+  name = 'powermock_module_junit4',
+  artifact = 'org.powermock:powermock-module-junit4:' + POWERM_VERS,
+  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
+)
+
+maven_jar(
+  name = 'powermock_module_junit4_common',
+  artifact = 'org.powermock:powermock-module-junit4-common:' + POWERM_VERS,
+  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
+)
+
+maven_jar(
+  name = 'powermock_reflect',
+  artifact = 'org.powermock:powermock-reflect:' + POWERM_VERS,
+  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
+)
+
+maven_jar(
+  name = 'powermock_api_easymock',
+  artifact = 'org.powermock:powermock-api-easymock:' + POWERM_VERS,
+  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
+)
+
+maven_jar(
+  name = 'powermock_api_support',
+  artifact = 'org.powermock:powermock-api-support:' + POWERM_VERS,
+  sha1 = '314daafb761541293595630e10a3699ebc07881d',
+)
+
+maven_jar(
+  name = 'powermock_core',
+  artifact = 'org.powermock:powermock-core:' + POWERM_VERS,
+  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
+)
+
+maven_jar(
+  name = 'javassist',
+  artifact = 'org.javassist:javassist:3.20.0-GA',
+  sha1 = 'a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0',
+)
+
+maven_jar(
+  name = 'derby',
+  artifact = 'org.apache.derby:derby:10.11.1.1',
+  sha1 = 'df4b50061e8e4c348ce243b921f53ee63ba9bbe1',
+)
+
+JETTY_VERS = '9.2.14.v20151106'
+
+maven_jar(
+  name = 'jetty_servlet',
+  artifact = 'org.eclipse.jetty:jetty-servlet:' + JETTY_VERS,
+  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
+)
+
+maven_jar(
+  name = 'jetty_security',
+  artifact = 'org.eclipse.jetty:jetty-security:' + JETTY_VERS,
+  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
+)
+
+maven_jar(
+  name = 'jetty_servlets',
+  artifact = 'org.eclipse.jetty:jetty-servlets:' + JETTY_VERS,
+  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
+)
+
+maven_jar(
+  name = 'jetty_server',
+  artifact = 'org.eclipse.jetty:jetty-server:' + JETTY_VERS,
+  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
+)
+
+maven_jar(
+  name = 'jetty_jmx',
+  artifact = 'org.eclipse.jetty:jetty-jmx:' + JETTY_VERS,
+  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
+)
+
+maven_jar(
+  name = 'jetty_continuation',
+  artifact = 'org.eclipse.jetty:jetty-continuation:' + JETTY_VERS,
+  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
+)
+
+maven_jar(
+  name = 'jetty_http',
+  artifact = 'org.eclipse.jetty:jetty-http:' + JETTY_VERS,
+  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
+)
+
+maven_jar(
+  name = 'jetty_io',
+  artifact = 'org.eclipse.jetty:jetty-io:' + JETTY_VERS,
+  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
+)
+
+maven_jar(
+  name = 'jetty_util',
+  artifact = 'org.eclipse.jetty:jetty-util:' + JETTY_VERS,
+  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
+)
+
+maven_jar(
+  name = 'openid_consumer',
+  artifact = 'org.openid4java:openid4java:0.9.8',
+  sha1 = 'de4f1b33d3b0f0b2ab1d32834ec1190b39db4160',
+)
+
+maven_jar(
+  name = 'nekohtml',
+  artifact = 'net.sourceforge.nekohtml:nekohtml:1.9.10',
+  sha1 = '14052461031a7054aa094f5573792feb6686d3de',
+)
+
+maven_jar(
+  name = 'xerces',
+  artifact = 'xerces:xercesImpl:2.8.1',
+  sha1 = '25101e37ec0c907db6f0612cbf106ee519c1aef1',
+)
diff --git a/bucklets/gerrit_plugin.bucklet b/bucklets/gerrit_plugin.bucklet
index cd2edae..367fe71 100644
--- a/bucklets/gerrit_plugin.bucklet
+++ b/bucklets/gerrit_plugin.bucklet
@@ -14,7 +14,7 @@
 # When compiling from standalone cookbook-plugin, bucklets directory points
 # to cloned bucklets library that includes real gerrit_plugin.bucklet code.
 
-GERRIT_GWT_API = ['//gerrit-plugin-gwtui/gerrit:gwtui-api']
+GERRIT_GWT_API = ['//gerrit-plugin-gwtui:gwtui-api']
 GERRIT_PLUGIN_API = ['//gerrit-plugin-api:lib']
 GERRIT_TESTS = ['//gerrit-acceptance-framework:lib']
 
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 32edf84..5f5b9ef 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -72,11 +72,17 @@
     parser.add_option('-m', '--message', dest='message',
                       metavar='STRING', default=None,
                       help='Custom message to append to abandon message')
+    parser.add_option('--branch', dest='branches', metavar='BRANCH_NAME',
+                      default=[], action='append',
+                      help='Abandon changes only on the given branch')
     parser.add_option('--exclude-branch', dest='exclude_branches',
                       metavar='BRANCH_NAME',
                       default=[],
                       action='append',
                       help='Do not abandon changes on given branch')
+    parser.add_option('--project', dest='projects', metavar='PROJECT_NAME',
+                      default=[], action='append',
+                      help='Abandon changes only on the given project')
     parser.add_option('--exclude-project', dest='exclude_projects',
                       metavar='PROJECT_NAME',
                       default=[],
@@ -126,9 +132,15 @@
         stale_changes = []
         offset = 0
         step = 500
-        query_terms = ["status:new", "age:%s" % options.age] + \
-                      ["-branch:%s" % b for b in options.exclude_branches] + \
-                      ["-project:%s" % p for p in options.exclude_projects]
+        query_terms = ["status:new", "age:%s" % options.age]
+        if options.branches:
+            query_terms += ["branch:%s" % b for b in options.branches]
+        elif options.exclude_branches:
+            query_terms += ["-branch:%s" % b for b in options.exclude_branches]
+        if options.projects:
+            query_terms += ["project:%s" % p for p in options.projects]
+        elif options.exclude_projects:
+            query_terms = ["-project:%s" % p for p in options.exclude_projects]
         if options.owner:
             query_terms += ["owner:%s" % options.owner]
         query = "%20".join(query_terms)
diff --git a/contrib/bash_completion b/contrib/bash_completion
index 6772235..19060a5 100644
--- a/contrib/bash_completion
+++ b/contrib/bash_completion
@@ -65,7 +65,7 @@
     COMPREPLY=()
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
-    opts="check restart run start status stop supervise"
+    opts="check restart run start status stop supervise threads"
 
     COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
 }
diff --git a/contrib/git-exproll.sh b/contrib/git-exproll.sh
index 066c57c..9ad7a85 100644
--- a/contrib/git-exproll.sh
+++ b/contrib/git-exproll.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 # Copyright (c) 2012, Code Aurora Forum. All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
new file mode 100644
index 0000000..c35f82c
--- /dev/null
+++ b/contrib/populate-fixture-data.py
@@ -0,0 +1,298 @@
+#!/usr/bin/env python
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This script will populate an empty standard Gerrit instance with some
+data for local testing.
+
+This script requires 'requests'. If you do not have this module, run
+'pip3 install requests' to install it.
+
+TODO(hiesel): Make real git commits instead of empty changes
+TODO(hiesel): Add comments
+"""
+
+import atexit
+import json
+import os
+import random
+import shutil
+import subprocess
+import tempfile
+
+import requests
+import requests.auth
+
+DEFAULT_TMP_PATH = "/tmp"
+TMP_PATH = ""
+BASE_URL = "http://localhost:8080/a/"
+ACCESS_URL = BASE_URL + "access/"
+ACCOUNTS_URL = BASE_URL + "accounts/"
+CHANGES_URL = BASE_URL + "changes/"
+CONFIG_URL = BASE_URL + "config/"
+GROUPS_URL = BASE_URL + "groups/"
+PLUGINS_URL = BASE_URL + "plugins/"
+PROJECTS_URL = BASE_URL + "projects/"
+
+ADMIN_DIGEST = requests.auth.HTTPDigestAuth("admin", "secret")
+
+# GROUP_ADMIN stores a GroupInfo for the admin group (see Gerrit rest docs)
+# In addition, GROUP_ADMIN["name"] stores the admin group"s name.
+GROUP_ADMIN = {}
+
+HEADERS = {"Content-Type": "application/json", "charset": "UTF-8"}
+
+# Random names from US Census Data
+FIRST_NAMES = [
+  "Casey", "Yesenia", "Shirley", "Tara", "Wanda", "Sheryl", "Jaime", "Elaine",
+  "Charlotte", "Carly", "Bonnie", "Kirsten", "Kathryn", "Carla", "Katrina",
+  "Melody", "Suzanne", "Sandy", "Joann", "Kristie", "Sally", "Emma", "Susan",
+  "Amanda", "Alyssa", "Patty", "Angie", "Dominique", "Cynthia", "Jennifer",
+  "Theresa", "Desiree", "Kaylee", "Maureen", "Jeanne", "Kellie", "Valerie",
+  "Nina", "Judy", "Diamond", "Anita", "Rebekah", "Stefanie", "Kendra", "Erin",
+  "Tammie", "Tracey", "Bridget", "Krystal", "Jasmin", "Sonia", "Meghan",
+  "Rebecca", "Jeanette", "Meredith", "Beverly", "Natasha", "Chloe", "Selena",
+  "Teresa", "Sheena", "Cassandra", "Rhonda", "Tami", "Jodi", "Shelly", "Angela",
+  "Kimberly", "Terry", "Joanna", "Isabella", "Lindsey", "Loretta", "Dana",
+  "Veronica", "Carolyn", "Laura", "Karen", "Dawn", "Alejandra", "Cassie",
+  "Lorraine", "Yolanda", "Kerry", "Stephanie", "Caitlin", "Melanie", "Kerri",
+  "Doris", "Sandra", "Beth", "Carol", "Vicki", "Shelia", "Bethany", "Rachael",
+  "Donna", "Alexandra", "Barbara", "Ana", "Jillian", "Ann", "Rachel", "Lauren",
+  "Hayley", "Misty", "Brianna", "Tanya", "Danielle", "Courtney", "Jacqueline",
+  "Becky", "Christy", "Alisha", "Phyllis", "Faith", "Jocelyn", "Nancy",
+  "Gloria", "Kristen", "Evelyn", "Julie", "Julia", "Kara", "Chelsey", "Cassidy",
+  "Jean", "Chelsea", "Jenny", "Diana", "Haley", "Kristine", "Kristina", "Erika",
+  "Jenna", "Alison", "Deanna", "Abigail", "Melissa", "Sierra", "Linda",
+  "Monica", "Tasha", "Traci", "Yvonne", "Tracy", "Marie", "Maria", "Michaela",
+  "Stacie", "April", "Morgan", "Cathy", "Darlene", "Cristina", "Emily"
+  "Ian", "Russell", "Phillip", "Jay", "Barry", "Brad", "Frederick", "Fernando",
+  "Timothy", "Ricardo", "Bernard", "Daniel", "Ruben", "Alexis", "Kyle", "Malik",
+  "Norman", "Kent", "Melvin", "Stephen", "Daryl", "Kurt", "Greg", "Alex",
+  "Mario", "Riley", "Marvin", "Dan", "Steven", "Roberto", "Lucas", "Leroy",
+  "Preston", "Drew", "Fred", "Casey", "Wesley", "Elijah", "Reginald", "Joel",
+  "Christopher", "Jacob", "Luis", "Philip", "Mark", "Rickey", "Todd", "Scott",
+  "Terrence", "Jim", "Stanley", "Bobby", "Thomas", "Gabriel", "Tracy", "Marcus",
+  "Peter", "Michael", "Calvin", "Herbert", "Darryl", "Billy", "Ross", "Dustin",
+  "Jaime", "Adam", "Henry", "Xavier", "Dominic", "Lonnie", "Danny", "Victor",
+  "Glen", "Perry", "Jackson", "Grant", "Gerald", "Garrett", "Alejandro",
+  "Eddie", "Alan", "Ronnie", "Mathew", "Dave", "Wayne", "Joe", "Craig",
+  "Terry", "Chris", "Randall", "Parker", "Francis", "Keith", "Neil", "Caleb",
+  "Jon", "Earl", "Taylor", "Bryce", "Brady", "Max", "Sergio", "Leon", "Gene",
+  "Darin", "Bill", "Edgar", "Antonio", "Dalton", "Arthur", "Austin", "Cristian",
+  "Kevin", "Omar", "Kelly", "Aaron", "Ethan", "Tom", "Isaac", "Maurice",
+  "Gilbert", "Hunter", "Willie", "Harry", "Dale", "Darius", "Jerome", "Jason",
+  "Harold", "Kerry", "Clarence", "Gregg", "Shane", "Eduardo", "Micheal",
+  "Howard", "Vernon", "Rodney", "Anthony", "Levi", "Larry", "Franklin", "Jimmy",
+  "Jonathon", "Carl",
+]
+
+LAST_NAMES = [
+  "Savage", "Hendrix", "Moon", "Larsen", "Rocha", "Burgess", "Bailey", "Farley",
+  "Moses", "Schmidt", "Brown", "Hoover", "Klein", "Jennings", "Braun", "Rangel",
+  "Casey", "Dougherty", "Hancock", "Wolf", "Henry", "Thomas", "Bentley",
+  "Barnett", "Kline", "Pitts", "Rojas", "Sosa", "Paul", "Hess", "Chase",
+  "Mckay", "Bender", "Colins", "Montoya", "Townsend", "Potts", "Ayala", "Avery",
+  "Sherman", "Tapia", "Hamilton", "Ferguson", "Huang", "Hooper", "Zamora",
+  "Logan", "Lloyd", "Quinn", "Monroe", "Brock", "Ibarra", "Fowler", "Weiss",
+  "Montgomery", "Diaz", "Dixon", "Olson", "Robertson", "Arias", "Benjamin",
+  "Abbott", "Stein", "Schroeder", "Beck", "Velasquez", "Barber", "Nichols",
+  "Ortiz", "Burns", "Moody", "Stokes", "Wilcox", "Rush", "Michael", "Kidd",
+  "Rowland", "Mclean", "Saunders", "Chung", "Newton", "Potter", "Hickman",
+  "Ray", "Larson", "Figueroa", "Duncan", "Sparks", "Rose", "Hodge", "Huynh",
+  "Joseph", "Morales", "Beasley", "Mora", "Fry", "Ross", "Novak", "Hahn",
+  "Wise", "Knight", "Frederick", "Heath", "Pollard", "Vega", "Mcclain",
+  "Buckley", "Conrad", "Cantrell", "Bond", "Mejia", "Wang", "Lewis", "Johns",
+  "Mcknight", "Callahan", "Reynolds", "Norris", "Burnett", "Carey", "Jacobson",
+  "Oneill", "Oconnor", "Leonard", "Mckenzie", "Hale", "Delgado", "Spence",
+  "Brandt", "Obrien", "Bowman", "James", "Avila", "Roberts", "Barker", "Cohen",
+  "Bradley", "Prince", "Warren", "Summers", "Little", "Caldwell", "Garrett",
+  "Hughes", "Norton", "Burke", "Holden", "Merritt", "Lee", "Frank", "Wiley",
+  "Ho", "Weber", "Keith", "Winters", "Gray", "Watts", "Brady", "Aguilar",
+  "Nicholson", "David", "Pace", "Cervantes", "Davis", "Baxter", "Sanchez",
+  "Singleton", "Taylor", "Strickland", "Glenn", "Valentine", "Roy", "Cameron",
+  "Beard", "Norman", "Fritz", "Anthony", "Koch", "Parrish", "Herman", "Hines",
+  "Sutton", "Gallegos", "Stephenson", "Lozano", "Franklin", "Howe", "Bauer",
+  "Love", "Ali", "Ellison", "Lester", "Guzman", "Jarvis", "Espinoza",
+  "Fletcher", "Burton", "Woodard", "Peterson", "Barajas", "Richard", "Bryan",
+  "Goodman", "Cline", "Rowe", "Faulkner", "Crawford", "Mueller", "Patterson",
+  "Hull", "Walton", "Wu", "Flores", "York", "Dickson", "Barnes", "Fisher",
+  "Strong", "Juarez", "Fitzgerald", "Schmitt", "Blevins", "Villa", "Sullivan",
+  "Velazquez", "Horton", "Meadows", "Riley", "Barrera", "Neal", "Mendez",
+  "Mcdonald", "Floyd", "Lynch", "Mcdowell", "Benson", "Hebert", "Livingston",
+  "Davies", "Richardson", "Vincent", "Davenport", "Osborn", "Mckee", "Marshall",
+  "Ferrell", "Martinez", "Melton", "Mercer", "Yoder", "Jacobs", "Mcdaniel",
+  "Mcmillan", "Peters", "Atkinson", "Wood", "Briggs", "Valencia", "Chandler",
+  "Rios", "Hunter", "Bean", "Hicks", "Hays", "Lucero", "Malone", "Waller",
+  "Banks", "Myers", "Mitchell", "Grimes", "Houston", "Hampton", "Trujillo",
+  "Perkins", "Moran", "Welch", "Contreras", "Montes", "Ayers", "Hayden",
+  "Daniel", "Weeks", "Porter", "Gill", "Mullen", "Nolan", "Dorsey", "Crane",
+  "Estes", "Lam", "Wells", "Cisneros", "Giles", "Watson", "Vang", "Scott",
+  "Knox", "Hanna", "Fields",
+]
+
+
+def clean(json_string):
+  # Strip JSON XSS Tag
+  json_string = json_string.strip()
+  if json_string.startswith(")]}'"):
+    return json_string[5:]
+  return json_string
+
+
+def digest_auth(user):
+  return requests.auth.HTTPDigestAuth(user["username"], user["http_password"])
+
+
+def fetch_admin_group():
+  global GROUP_ADMIN
+  # Get admin group
+  r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
+                                    headers=HEADERS,
+                                    auth=ADMIN_DIGEST).text))
+  admin_group_name = r.keys()[0]
+  GROUP_ADMIN = r[admin_group_name]
+  GROUP_ADMIN["name"] = admin_group_name
+
+
+def generate_random_text():
+  return " ".join([random.choice("lorem ipsum "
+                                 "doleret delendam "
+                                 "\n esse".split(" ")) for _ in xrange(1, 100)])
+
+
+def set_up():
+  global TMP_PATH
+  TMP_PATH = tempfile.mkdtemp()
+  atexit.register(clean_up)
+  os.makedirs(TMP_PATH + "/ssh")
+  os.makedirs(TMP_PATH + "/repos")
+  fetch_admin_group()
+
+
+def get_random_users(num_users):
+  users = [(f, l) for f in FIRST_NAMES for l in LAST_NAMES][:num_users]
+  names = []
+  for u in users:
+    names.append({"firstname": u[0],
+                  "lastname": u[1],
+                  "name": u[0] + " " + u[1],
+                  "username": u[0] + u[1],
+                  "email": u[0] + "." + u[1] + "@gmail.com",
+                  "http_password": "secret",
+                  "groups": []})
+  return names
+
+
+def generate_ssh_keys(gerrit_users):
+  for user in gerrit_users:
+    key_file = TMP_PATH + "/ssh/" + user["username"] + ".key"
+    subprocess.check_output(["ssh-keygen", "-f", key_file, "-N", ""])
+    with open(key_file + ".pub", "r") as f:
+      user["ssh_key"] = f.read()
+
+
+def create_gerrit_groups():
+  groups = [
+    {"name": "iOS-Maintainers", "description": "iOS Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Android-Maintainers", "description": "Android Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Backend-Maintainers", "description": "Backend Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Script-Maintainers", "description": "Script Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Security-Team", "description": "Sec Team",
+     "visible_to_all": False, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]}]
+  for g in groups:
+    requests.put(GROUPS_URL + g["name"],
+                 json.dumps(g),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+  return [g["name"] for g in groups]
+
+
+def create_gerrit_projects(owner_groups):
+  projects = [
+    {"id": "android", "name": "Android", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our android app.",
+     "owners": [owner_groups[0]], "create_empty_commit": True},
+    {"id": "ios", "name": "iOS", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our ios app.",
+     "owners": [owner_groups[1]], "create_empty_commit": True},
+    {"id": "backend", "name": "Backend", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our awesome backend.",
+     "owners": [owner_groups[2]], "create_empty_commit": True},
+    {"id": "scripts", "name": "Scripts", "parent": "All-Projects",
+     "branches": ["master"], "description": "some small scripts.",
+     "owners": [owner_groups[3]], "create_empty_commit": True}]
+  for p in projects:
+    requests.put(PROJECTS_URL + p["name"],
+                 json.dumps(p),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+  return [p["name"] for p in projects]
+
+
+def create_gerrit_users(gerrit_users):
+  for user in gerrit_users:
+    requests.put(ACCOUNTS_URL + user["username"],
+                 json.dumps(user),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+
+
+def create_change(user, project_name):
+  random_commit_message = generate_random_text()
+  change = {
+    "project": project_name,
+    "subject": random_commit_message.split("\n")[0],
+    "branch": "master",
+    "status": "NEW",
+  }
+  requests.post(CHANGES_URL,
+                json.dumps(change),
+                headers=HEADERS,
+                auth=digest_auth(user))
+
+
+def clean_up():
+  shutil.rmtree(TMP_PATH)
+
+
+def main():
+  set_up()
+  gerrit_users = get_random_users(100)
+
+  group_names = create_gerrit_groups()
+  for idx, u in enumerate(gerrit_users):
+    u["groups"].append(group_names[idx % len(group_names)])
+    if idx % 5 == 0:
+      # Also add to security group
+      u["groups"].append(group_names[4])
+
+  generate_ssh_keys(gerrit_users)
+  create_gerrit_users(gerrit_users)
+
+  project_names = create_gerrit_projects(group_names)
+
+  for idx, u in enumerate(gerrit_users):
+    create_change(u, project_names[4 * idx / len(gerrit_users)])
+
+main()
diff --git a/contrib/themes/diffy/static/diffy.svg b/contrib/themes/diffy/static/diffy.svg
new file mode 100644
index 0000000..3e6e6c2
--- /dev/null
+++ b/contrib/themes/diffy/static/diffy.svg
@@ -0,0 +1,326 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="600.06732"
+   height="558.20709"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.48.3.1 r9886"
+   sodipodi:docname="diffy.svg"
+   inkscape:export-filename="/home/sarah/art/diffy.png"
+   inkscape:export-xdpi="500"
+   inkscape:export-ydpi="500">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="0.66618777"
+     inkscape:cx="300.03365"
+     inkscape:cy="258.35521"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:window-width="1366"
+     inkscape:window-height="744"
+     inkscape:window-x="0"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-3069.8852,2251.5084)">
+    <text
+       xml:space="preserve"
+       style="font-size:51.65934372px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:end;line-height:125%;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeSans;-inkscape-font-specification:FreeSans Bold"
+       x="475.83173"
+       y="900.47076"
+       id="text4257"
+       sodipodi:linespacing="125%"
+       transform="scale(1.1411753,0.87628955)"><tspan
+         sodipodi:role="line"
+         x="475.83173"
+         y="900.47076"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:end;text-anchor:end;font-family:Acme;-inkscape-font-specification:Acme Bold"
+         id="tspan3008" /></text>
+    <flowRoot
+       xml:space="preserve"
+       id="flowRoot4263"
+       style="font-size:12px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><flowRegion
+         id="flowRegion4265"><rect
+           id="rect4267"
+           width="313.14728"
+           height="343.45187"
+           x="-335.37064"
+           y="205.85435" /></flowRegion><flowPara
+         id="flowPara4269" /></flowRoot>    <g
+       id="g10119"
+       transform="matrix(0.99201906,-0.12608805,0.12608805,0.99201906,273.85438,408.58042)">
+      <path
+         sodipodi:nodetypes="csccscac"
+         inkscape:connector-curvature="0"
+         id="path4130"
+         d="m 3378.9965,-2206.2972 c 0,0 13.4039,-8.8398 27.0022,-17.1718 13.5984,-8.3319 22.2322,-21.276 22.2322,-21.276 l 34.2799,28.1467 c 0,0 -16.0641,15.553 -24.7742,21.6893 -3.709,2.6129 -10.9463,9.1577 -10.9463,9.1577 0,0 -14.3617,-10.0769 -22.3623,-13.5225 -8.0773,-3.4786 -25.4315,-7.0234 -25.4315,-7.0234 z"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cac"
+         inkscape:connector-curvature="0"
+         d="m 3414.7387,-2204.9828 c 0,0 8.2233,-5.8571 11.9326,-9.2622 3.8224,-3.5089 10.5596,-11.437 10.5596,-11.437"
+         style="fill:#ffffff;stroke:#ff0000;stroke-width:8.97858429;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         id="path4185" />
+      <path
+         sodipodi:nodetypes="cascacsc"
+         inkscape:connector-curvature="0"
+         id="path3307"
+         d="m 3247.6516,-2171.4037 c 0,0 4.3348,-19.2835 10.7071,-26.2701 7.4284,-8.1446 19.072,-14.1542 29.3495,-15.24 27.8177,-2.9386 63.2371,6.1908 63.2371,6.1908 0,0 -30.8042,0.7262 -45.6022,4.5134 -5.1922,1.3289 -14.7941,6.2977 -14.7941,6.2977 0,0 -16.1166,0.8374 -29.8971,12.0927 -13.7804,11.2552 -3.0344,9.7831 -3.0344,9.7831"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="ccsccscscccccscc"
+         inkscape:connector-curvature="0"
+         id="path3011"
+         d="m 3332.9985,-2012.4352 -20.0742,24.9886 c 0,0 -16.3479,-5.082 -40.3255,4.9725 -23.9776,10.0546 -39.2193,26.4042 -39.2193,26.4042 l 7.8897,-0.7975 c 0,0 20.4818,-14.4371 31.9614,-17.1294 11.4796,-2.6923 23.6197,-2.8388 23.6197,-2.8388 0,0 -25.492,10.0264 -30.7497,12.3759 -6.2584,2.7968 -15.0874,9.199 -15.0874,9.199 l 10.4761,-0.2965 c 17.6867,-7.2528 41.3221,-16.0187 59.9685,-15.047 15.5919,1.1274 33.9405,16.7543 33.9405,16.7543 l -3.8165,-9.9895 c 0,0 -10.8307,-8.1932 -16.3866,-13.7491 -5.5558,-5.5558 17.5848,-25.8193 17.5848,-25.8193 -14.3098,-2.8351 -2.1172,-7.9424 -19.7815,-9.0274 z"
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         d="m 3397.9929,-1984.0461 -15.5774,33.0846 c 0,0 -14.4499,-2.2165 -39.5097,4.7139 -25.0597,6.9303 -44.9575,20.0476 -44.9575,20.0476 l 10.5706,1.822 c 0,0 19.7276,-11.1182 30.516,-14.1955 7.747,-2.2097 23.9934,-2.9008 23.9934,-2.9008 0,0 -20.2687,6.6869 -30.057,10.9033 -5.4067,2.329 -15.7817,7.9273 -15.7817,7.9273 l 10.8391,1.5269 c 0,0 31.056,-11.8625 47.4047,-13.3504 11.1103,-1.0112 11.8331,-2.962 33.324,3.1079 10.0509,2.8388 16.3928,6.252 16.3928,6.252 l -3.3122,-5.8696 c 0,0 -5.3909,-4.5711 -8.2712,-6.6111 -3.5982,-2.5484 -11.2202,-7.0059 -11.2202,-7.0059 l 15.4484,-30.858 c -13.3255,-5.9362 -2.8177,-3.6211 -19.8021,-8.5942 z"
+         id="path3013"
+         inkscape:connector-curvature="0"
+         sodipodi:nodetypes="ccsccacaccasccaccc" />
+      <path
+         sodipodi:nodetypes="aaczsccsssca"
+         inkscape:connector-curvature="0"
+         id="path3014"
+         d="m 3138.8926,-2124.2277 c 9.8375,-20.5358 27.6337,-38.9533 48.4705,-48.136 22.7321,-10.018 51.1582,-10.0879 74.4382,-3.5941 7.6944,6.5 10.3115,-17.1804 46.9059,-27.4208 36.5944,-10.2403 91.6429,-6.509 142.1505,22.7854 54.0494,31.3487 -23.7386,41.9213 -8.0812,94.4492 32.3844,113.578 -63.1345,101.5204 -63.1345,101.5204 0,0 -25.2539,-16.1625 -35.3554,-17.1726 -10.1015,-1.0102 -46.6429,-25.1251 -68.8662,-28.6607 -22.2234,-3.5355 -36.9003,18.6222 -107.991,-9.9282 -24.3178,-9.7661 -33.8401,-33.335 -33.8401,-33.335 0,0 -2.0102,-35.2405 5.3033,-50.5076 z"
+         style="fill:#0000ff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cssscscsac"
+         inkscape:connector-curvature="0"
+         id="path3028"
+         d="m 3179.7636,-2035.4822 c 0,0 33.988,-7.9036 57.2215,-14.9746 23.2335,-7.0711 36.1637,-43.7474 71.519,-48.7981 35.3554,-5.0508 68.528,22.1624 80.6498,47.4162 12.1218,25.2539 50.6701,55.6193 50.6701,55.6193 0,0 1.0101,11.1117 -14.1422,18.1828 -15.1522,7.0711 -10.4989,7.6628 -10.4989,7.6628 0,0 -43.1103,-11.4139 -62.396,-21.4139 -19.2857,-10 -61.236,-23.044 -92.9377,-30.7963 -26.2653,-6.4228 -35.0334,2.5255 -80.0856,-12.8982 z"
+         style="fill:#ff6600;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cscccccscc"
+         inkscape:connector-curvature="0"
+         id="path3009"
+         d="m 3470.1209,-2002.8128 c 0,0 13.8937,99.0002 33.6829,157.8517 19.7893,58.8514 78.4464,180.4742 78.4464,180.4742 l 28.1031,-31.8521 -52.5625,-109.7203 60.5152,95.9016 26.0124,-35.7271 c 0,0 -70.663,-88.4878 -93.8954,-140.4597 -23.2324,-51.9718 -37.4945,-152.9082 -37.4945,-152.9082 z"
+         style="fill:#0000ff;fill-opacity:1;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="csscsccccccccccsc"
+         inkscape:connector-curvature="0"
+         id="path3016"
+         d="m 3359.2154,-2191.502 c 0,0 -40.7142,5 -57.1428,42.1428 -16.4286,37.1429 50.7143,115.7143 60.7143,132.1429 10,16.4286 24.2857,38.5714 24.2857,38.5714 0,0 47.6766,32.0335 82.9062,53.8444 32.6567,20.2179 65.2266,33.1109 65.2266,33.1109 0,0 1.1687,-0.4937 7.0265,-19.1963 -12.1218,-21.7183 -38.3216,-53.2543 -54.5036,-69.192 24.2792,14.1167 43.346,28.1254 60.1041,56.5482 0,0 4.9588,-21.3916 6.5659,-30.7894 -3.0357,-9.5763 -47.4771,-56.5685 -47.4771,-56.5685 20.5517,8.7613 33.7508,23.0255 50.0892,44.7732 0,0 3.3263,-21.5598 3.8366,-32.9472 -2.5253,-6.566 -33.7228,-35.0595 -33.7228,-35.0595 13.1654,6.6374 19.714,11.0661 34.4127,24.0476 0,0 -3.0357,-60.7142 -66.6072,-130.7142 -63.5714,-70 -135.7143,-50.7143 -135.7143,-50.7143 z"
+         style="fill:#000080;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <g
+         transform="translate(2935.644,-2513.1499)"
+         id="g3138">
+        <path
+           style="fill:#ff0000;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+           d="m 337.66259,394.0585 c 0,0 -28.86328,-30.61257 -60.78781,-25.80202 -31.92454,4.81054 -41.10831,33.67382 -39.79634,39.79633 1.31197,6.12252 13.557,11.80771 22.30345,15.74361 8.74644,3.9359 32.79917,-0.43732 45.48152,-6.55984 12.68235,-6.12251 32.79918,-23.17808 32.79918,-23.17808 z"
+           id="path3022"
+           inkscape:connector-curvature="0" />
+        <path
+           style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:3;stroke-miterlimit:4"
+           d="m 286.65383,372.49887 c 12.50235,2.18615 20.86528,14.09354 18.67912,26.59589 -2.18615,12.50234 -14.09354,20.86527 -26.59589,18.67911 -12.50235,-2.18616 -20.86528,-14.09354 -18.67912,-26.59588 z"
+           id="path3024"
+           inkscape:connector-curvature="0"
+           sodipodi:nodetypes="csscc" />
+        <path
+           sodipodi:type="arc"
+           style="fill:#000000;fill-opacity:1;stroke:none"
+           id="path3026"
+           sodipodi:cx="229.64285"
+           sodipodi:cy="403.79074"
+           sodipodi:rx="8.2142859"
+           sodipodi:ry="8.2142859"
+           d="m 237.85714,403.79074 c 0,4.53663 -3.67766,8.21429 -8.21429,8.21429 -4.53662,0 -8.21428,-3.67766 -8.21428,-8.21429 0,-4.53662 3.67766,-8.21428 8.21428,-8.21428 4.53663,0 8.21429,3.67766 8.21429,8.21428 z"
+           transform="matrix(1.5694327,0,0,1.5694327,-85.566867,-233.30737)" />
+      </g>
+      <path
+         sodipodi:nodetypes="cssssssc"
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         d="m 3110.2692,-2103.8575 c -32.3249,20.7081 -31.4522,56.0845 -27.4116,60.6301 4.0406,4.5457 8.0812,1.0102 15.6574,-1.5152 7.5761,-2.5254 26.2639,-10.1015 42.4264,-11.1117 16.1624,-1.0101 21.2132,3.0305 23.7386,-0.505 2.5254,-3.5356 4.0406,-15.1523 3.0304,-25.7589 -1.0101,-10.6066 -1.0101,-23.2335 -7.071,-25.2538 -6.061,-2.0203 -38.2484,-5.0718 -50.3702,3.5145 z"
+         id="path3296"
+         inkscape:connector-curvature="0" />
+      <path
+         inkscape:connector-curvature="0"
+         id="path3305"
+         d="m 3251.1404,-2178.2729 c 0,0 12.1428,21.0714 67.1428,32.5 55,11.4286 125.7143,-38.2143 125.7143,-38.2143 l 38.7857,38.6429 c 0,0 -113.0714,25.6428 -150.2143,21 -37.1428,-4.6429 -81.4285,-50.3572 -81.4285,-50.3572"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="ccccc"
+         inkscape:connector-curvature="0"
+         id="path4099"
+         d="m 3453.1169,-2166.1191 -30.2186,14.3667 6.8378,5.5184 29.9689,-14.0733 z"
+         style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans" />
+      <path
+         sodipodi:nodetypes="cacscacsc"
+         inkscape:connector-curvature="0"
+         id="path3303"
+         d="m 3154.8351,-2149.0637 c 0,0 -6.1891,7.2925 -8.7928,11.2888 -2.8625,4.3936 -7.4472,13.8569 -7.4472,13.8569 0,0 49.8667,-24.0831 67.6065,-30.2972 18.2787,-6.4028 54.803,-21.7873 54.803,-21.7873 0,0 -3.8451,-1.8876 -5.9103,-2.3872 -3.1018,-0.7503 -9.5343,-0.868 -9.5343,-0.868 0,0 -25.7837,-0.1235 -42.8032,6.1343 -17.0196,6.2579 -47.9217,24.0597 -47.9217,24.0597 z"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans"
+         d="m 3433.0379,-2169.2212 24.4739,22.8165 -8.1844,3.1976 -24.3243,-22.4616 z"
+         id="path27241"
+         inkscape:connector-curvature="0"
+         sodipodi:nodetypes="ccccc" />
+    </g>
+    <g
+       id="g10229"
+       transform="translate(31.403024,6.4439689)">
+      <g
+         id="text4253"
+         style="font-size:140.50161743px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeSans;-inkscape-font-specification:FreeSans Bold"
+         transform="scale(1.066563,0.93759113)">
+        <path
+           id="path10219"
+           style="font-family:Acme;-inkscape-font-specification:Acme Bold"
+           d="m 2917.8887,-1930.8568 c -6.6505,0 -16.2514,-0.5152 -28.8029,-1.5455 l 1.6861,-34.1419 -1.6861,-62.8043 37.514,0 c 12.645,10e-5 22.527,3.7937 29.6458,11.3807 7.1187,7.5871 10.678,18.1247 10.6781,31.6128 -10e-5,17.0476 -4.4024,30.5826 -13.2071,40.605 -8.8049,9.9288 -20.7475,14.8932 -35.8279,14.8932 m 3.653,-83.3175 c -6.1821,10e-5 -10.5376,0.047 -13.0666,0.1405 l -1.4051,45.101 0.9836,22.7613 c 7.868,0.562 13.1602,0.843 15.8766,0.843 7.3997,0 13.2071,-2.9973 17.4222,-8.9921 4.3087,-5.9947 6.463,-14.7058 6.4631,-26.1333 -1e-4,-11.4274 -2.1076,-19.9043 -6.3226,-25.4308 -4.1214,-5.5263 -10.7718,-8.2895 -19.9512,-8.2896"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10221"
+           style="font-family:Acme;-inkscape-font-specification:Acme Bold"
+           d="m 3002.3389,-2030.051 -1.967,61.1182 1.686,36.5305 -19.6702,0 1.686,-34.1419 -1.686,-59.9942 19.9512,-3.5126"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10223"
+           style="font-family:Acme;-inkscape-font-specification:Acme Bold"
+           d="m 3074.2384,-2014.3148 -29.9268,0 -0.843,25.8523 26.8358,0 -1.5455,14.8932 -25.7118,0 -0.1405,4.6365 1.686,36.5305 -19.6703,0 1.6861,-34.1419 -1.6861,-62.8043 50.8616,0 -1.5455,15.0337"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10225"
+           style="font-family:Acme;-inkscape-font-specification:Acme Bold"
+           d="m 3136.2567,-2014.3148 -29.9268,0 -0.843,25.8523 26.8358,0 -1.5456,14.8932 -25.7117,0 -0.1405,4.6365 1.686,36.5305 -19.6703,0 1.6861,-34.1419 -1.6861,-62.8043 50.8616,0 -1.5455,15.0337"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10227"
+           style="font-family:Acme;-inkscape-font-specification:Acme Bold"
+           d="m 3177.4808,-1987.9005 1.686,0 10.3971,-19.1082 10.5376,-23.1828 17.1412,2.5291 -12.9261,23.4637 -18.2652,35.6874 1.5455,36.109 -19.6702,0 1.405,-33.7204 -17.7032,-35.9684 -13.4882,-24.5878 19.8107,-3.5126 9.1326,21.3563 10.3972,20.9347"
+           inkscape:connector-curvature="0" />
+      </g>
+      <g
+         id="text3010"
+         style="font-size:62.54807663px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Acme;-inkscape-font-specification:Acme"
+         transform="scale(1.1647576,0.85854772)">
+        <path
+           id="path10175"
+           d="m 2645.5222,-2090.4097 0.688,-7.1931 29.7104,0 -0.688,7.1931 -10.8834,-0.9382 -0.2502,20.6408 0.7506,16.2625 -8.7568,0 0.7506,-15.1992 -0.2502,-21.6416 -11.071,0.8757"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10177"
+           d="m 2697.8329,-2070.2692 c 0,-2.8772 -0.2502,-4.7954 -0.7505,-5.7545 -0.5005,-0.959 -1.668,-1.4385 -3.5027,-1.4386 -1.8348,1e-4 -3.9197,0.7298 -6.2548,2.1892 l 0,4.566 0.688,14.6988 -8.444,1.5637 0.7506,-15.1992 -0.7506,-31.6493 8.5691,-1.5637 -0.8131,16.2 0,7.3181 c 0.6254,-0.834 1.3343,-1.6471 2.1266,-2.4394 0.7923,-0.7922 2.0224,-1.6888 3.6903,-2.6896 1.668,-1.0007 3.4401,-1.5011 5.3166,-1.5011 1.9181,0 3.5235,0.7506 4.8162,2.2517 1.2926,1.4595 1.939,3.461 1.939,6.0046 l -0.3753,7.0054 0.688,15.0116 -8.4439,1.5637 0.7505,-16.1374"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10179"
+           d="m 2723.6897,-2060.8245 c 1.2093,0 2.4811,-0.3127 3.8155,-0.9382 1.3343,-0.6255 2.3768,-1.251 3.1274,-1.8764 l 1.1258,-0.9383 2.8772,3.5027 c -0.417,0.7089 -1.0633,1.522 -1.939,2.4394 -0.8757,0.9174 -1.7722,1.7096 -2.6895,2.3768 -0.8757,0.6255 -2.0641,1.2093 -3.5653,1.7514 -1.4594,0.5004 -2.9814,0.7505 -4.566,0.7505 -3.3776,0 -6.1297,-1.2718 -8.2563,-3.8154 -2.1267,-2.5853 -3.19,-5.9003 -3.19,-9.9451 0,-5.0873 1.4803,-9.549 4.4409,-13.3853 2.9606,-3.8363 6.4216,-5.7544 10.383,-5.7544 3.044,0 5.4,0.8548 7.068,2.5644 1.7096,1.7097 2.5644,4.1074 2.5644,7.1931 0,1.8347 -0.3127,3.9197 -0.9382,6.2548 l -1.251,1.3135 -15.637,1.4386 c 0.7089,4.7119 2.9189,7.0679 6.6301,7.0679 m 0,-19.3899 c -1.8347,0 -3.3776,0.7506 -4.6285,2.2517 -1.251,1.4595 -1.9807,3.336 -2.1892,5.6294 l 10.6332,-1.3135 c 0.125,-0.9591 0.1876,-1.7514 0.1876,-2.3769 0,-2.7938 -1.3344,-4.1907 -4.0031,-4.1907"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10181"
+           d="m 2760.1934,-2097.9155 -0.6881,20.3907 15.9498,-20.2656 6.3799,4.2533 -14.3861,16.7003 15.2618,18.0764 -8.0062,5.0038 -15.2617,-21.579 -0.1251,4.6285 0.7506,16.2625 -8.7568,0 0.7506,-15.1992 -0.7506,-26.708 8.8819,-1.5637"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10183"
+           d="m 2793.6018,-2054.4446 c -2.2517,0 -4.0864,-0.7297 -5.5042,-2.1892 -1.376,-1.5011 -2.0641,-3.461 -2.0641,-5.8795 l 0.3753,-7.193 -0.688,-15.0115 8.3814,-1.5638 -0.8131,19.0147 c 0,1.793 0.3127,3.1065 0.9382,3.9405 0.6672,0.834 1.7097,1.251 3.1274,1.251 1.4178,0 3.3776,-0.7089 5.8796,-2.1267 l 0,-3.7529 -0.6881,-16.4501 8.444,-1.5637 -0.7506,15.637 0.063,5.129 c 0,2.6687 0.688,5.3582 2.0641,8.0687 l -6.505,2.8772 c -1.3761,-2.7104 -2.2101,-4.8788 -2.502,-6.505 -3.7529,4.2115 -7.0053,6.3173 -9.7575,6.3173"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10185"
+           d="m 2836.1199,-2070.2692 c 0,-2.8772 -0.2502,-4.7954 -0.7506,-5.7545 -0.5004,-0.959 -1.668,-1.4385 -3.5027,-1.4386 -1.8347,1e-4 -3.9197,0.7298 -6.2548,2.1892 l 0,4.566 0.688,14.6988 -8.3814,1.5637 0.7506,-15.1992 c -0.1668,-5.5876 -0.6255,-10.5914 -1.3761,-15.0115 l 7.5683,-1.6263 c 0.2919,2.4186 0.5004,4.7746 0.6255,7.068 0.6255,-0.834 1.3344,-1.6471 2.1266,-2.4394 0.7923,-0.8339 2.0433,-1.7513 3.7529,-2.7521 1.7097,-1.0424 3.5027,-1.5637 5.3792,-1.5637 1.9181,0 3.5235,0.7506 4.8162,2.2517 1.2926,1.4595 1.9389,3.461 1.939,6.0046 l -0.3753,7.0054 0.688,15.0116 -8.444,1.5637 0.7506,-16.1374"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10187"
+           d="m 2848.5318,-2048.2523 c 0,-3.6695 3.4193,-6.5467 10.2579,-8.6317 -1.6263,-0.8339 -2.4394,-1.9181 -2.4394,-3.2525 0,-0.542 0.2085,-1.0841 0.6255,-1.6262 0.4169,-0.5421 1.0216,-1.105 1.8139,-1.6888 l 0,-0.5004 c -3.0024,0 -5.4,-0.834 -7.1931,-2.5019 -1.7513,-1.6679 -2.627,-3.8988 -2.627,-6.6927 0,-3.6277 1.4803,-6.776 4.4409,-9.4447 2.9606,-2.7104 6.4633,-4.0656 10.5081,-4.0656 1.0425,0 2.5645,0.2085 4.566,0.6254 2.0015,0.4171 3.9614,0.6256 5.8795,0.6255 l 4.8162,0 -0.6254,5.4417 -4.6912,-0.5629 c 0.417,1.2093 0.6255,2.5853 0.6255,4.1281 0,1.5429 -0.3753,3.0858 -1.1258,4.6286 -0.7089,1.5012 -1.5846,2.7104 -2.6271,3.6278 -1.0007,0.9174 -2.0224,1.7305 -3.0648,2.4394 -1.0008,0.6672 -1.8765,1.2718 -2.627,1.8139 -0.7089,0.5004 -1.0634,0.959 -1.0633,1.376 -1e-4,0.7923 0.8339,1.4803 2.5019,2.0641 4.42,1.7931 7.4015,3.3151 8.9443,4.566 1.5846,1.251 2.3768,2.7938 2.3769,4.6286 -10e-5,3.0857 -1.5638,5.6084 -4.6911,7.5683 -3.0858,2.0015 -6.7761,3.0023 -11.071,3.0023 -4.295,0 -7.6309,-0.688 -10.0077,-2.0641 -2.3352,-1.3761 -3.5027,-3.2108 -3.5027,-5.5042 m 18.8895,-26.2702 c 0,-2.0432 -0.4587,-3.5444 -1.3761,-4.5035 -0.9174,-0.959 -2.356,-1.4386 -4.3158,-1.4386 -3.7946,0 -5.6919,1.8765 -5.6919,5.6293 0,3.7112 2.0224,5.5668 6.0672,5.5668 3.5444,0 5.3166,-1.7513 5.3166,-5.254 m 2.2517,24.2061 c 0,-0.7506 -0.3961,-1.4595 -1.1884,-2.1266 -0.7923,-0.6255 -2.21,-1.3761 -4.2533,-2.2518 -3.044,0.834 -5.1498,1.6054 -6.3173,2.3143 -1.1259,0.7089 -1.6888,1.5846 -1.6888,2.627 0,1.0425 0.5629,1.8765 1.6888,2.5019 1.1675,0.6672 2.7729,1.0008 4.8162,1.0008 2.0432,0 3.7112,-0.3753 5.0038,-1.1258 1.2927,-0.7506 1.939,-1.7305 1.939,-2.9398"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10189"
+           d="m 2915.4651,-2090.9101 -13.3228,0 -0.3753,11.5088 11.9467,0 -0.688,6.6301 -11.4463,0 -0.063,2.0641 0.7506,16.2625 -8.7567,0 0.7506,-15.1992 -0.7506,-27.959 22.6424,0 -0.688,6.6927"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10191"
+           d="m 2927.2495,-2054.4446 c -2.2517,0 -4.0865,-0.7297 -5.5042,-2.1892 -1.3761,-1.5011 -2.0641,-3.461 -2.0641,-5.8795 l 0.3753,-7.193 -0.6881,-15.0115 8.3815,-1.5638 -0.8131,19.0147 c -10e-5,1.793 0.3127,3.1065 0.9382,3.9405 0.6671,0.834 1.7096,1.251 3.1274,1.251 1.4177,0 3.3776,-0.7089 5.8795,-2.1267 l 0,-3.7529 -0.688,-16.4501 8.444,-1.5637 -0.7506,15.637 0.063,5.129 c 0,2.6687 0.688,5.3582 2.0641,8.0687 l -6.505,2.8772 c -1.3761,-2.7104 -2.21,-4.8788 -2.5019,-6.505 -3.7529,4.2115 -7.0054,6.3173 -9.7575,6.3173"
+           inkscape:connector-curvature="0" />
+      </g>
+      <g
+         id="text3016"
+         style="font-size:55.5345993px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Acme;-inkscape-font-specification:Acme"
+         transform="scale(1.0640965,0.93976441)">
+        <path
+           id="path10194"
+           d="m 2910.1662,-1832.5692 -3.1655,0 0,0.3332 0.6664,14.439 -7.4416,0 0.6664,-13.4949 -0.6664,-24.8239 12.051,0 c 3.5542,0 6.3864,0.9626 8.4968,2.8878 2.1103,1.9252 3.1654,4.4798 3.1655,7.6637 -10e-5,2.4066 -0.6295,4.6094 -1.8882,6.6087 -1.2218,1.9622 -2.8878,3.5172 -4.9981,4.6649 l 9.663,13.6615 -6.7197,3.4431 -8.6634,-15.4386 c -0.2592,0.037 -0.6479,0.056 -1.1662,0.056 m 0.111,-17.9376 -2.7212,0 -0.3887,12.5508 3.7764,0 c 1.8511,0 3.2765,-0.6109 4.2761,-1.8327 1.0366,-1.2217 1.555,-2.9618 1.555,-5.2202 0,-3.6653 -2.1659,-5.4979 -6.4976,-5.4979"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10196"
+           d="m 2941.1059,-1823.4615 c 1.0736,0 2.2028,-0.2777 3.3876,-0.833 1.1847,-0.5553 2.1103,-1.1107 2.7767,-1.666 l 0.9997,-0.8331 2.5545,3.11 c -0.3702,0.6294 -0.9441,1.3513 -1.7215,2.1658 -0.7775,0.8145 -1.5735,1.518 -2.388,2.1103 -0.7775,0.5554 -1.8327,1.0737 -3.1655,1.555 -1.2958,0.4443 -2.6471,0.6664 -4.054,0.6664 -2.9989,0 -5.4424,-1.1292 -7.3306,-3.3876 -1.8882,-2.2954 -2.8322,-5.2387 -2.8322,-8.83 0,-4.5168 1.3143,-8.4783 3.9429,-11.8844 2.6286,-3.4061 5.7016,-5.1092 9.2188,-5.1092 2.7026,0 4.7944,0.759 6.2754,2.2769 1.5179,1.518 2.2769,3.6468 2.2769,6.3865 0,1.6291 -0.2777,3.4802 -0.833,5.5535 l -1.1107,1.1662 -13.8837,1.2773 c 0.6294,4.1836 2.5916,6.2754 5.8867,6.2754 m 0,-17.2157 c -1.629,0 -2.9989,0.6664 -4.1096,1.9992 -1.1107,1.2959 -1.7586,2.9619 -1.9437,4.9981 l 9.4409,-1.1662 c 0.1111,-0.8515 0.1666,-1.5549 0.1666,-2.1103 0,-2.4805 -1.1847,-3.7208 -3.5542,-3.7208"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10198"
+           d="m 2965.2808,-1824.7388 0.7219,0 1.8327,-7.6082 2.9989,-13.7171 6.8862,0.833 -4.165,13.7726 -3.4987,12.9951 -9.4964,0.833 -3.2766,-13.4949 -3.9985,-13.4949 7.5527,-1.3883 2.388,12.2176 2.0548,9.0521"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10200"
+           d="m 2988.8795,-1846.0641 -0.6664,13.8281 0.6109,13.0507 -7.4416,1.3883 0.6664,-13.4949 -0.6109,-13.3838 7.4416,-1.3884 m -7.8303,-8.108 c 0,-1.2588 0.4812,-2.3695 1.4439,-3.3321 0.9625,-0.9996 2.0732,-1.4994 3.332,-1.4994 1.2588,0 2.2214,0.3517 2.8878,1.0551 0.6664,0.6665 0.9996,1.6846 0.9997,3.0544 -10e-5,1.3699 -0.4814,2.5547 -1.4439,3.5542 -0.9256,0.9627 -2.0178,1.444 -3.2766,1.4439 -1.2218,10e-5 -2.1844,-0.4072 -2.8878,-1.2217 -0.7034,-0.8145 -1.0551,-1.8326 -1.0551,-3.0544"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10202"
+           d="m 3004.9924,-1823.4615 c 1.0736,0 2.2028,-0.2777 3.3876,-0.833 1.1847,-0.5553 2.1103,-1.1107 2.7767,-1.666 l 0.9996,-0.8331 2.5546,3.11 c -0.3702,0.6294 -0.9441,1.3513 -1.7215,2.1658 -0.7775,0.8145 -1.5735,1.518 -2.388,2.1103 -0.7775,0.5554 -1.8327,1.0737 -3.1655,1.555 -1.2958,0.4443 -2.6472,0.6664 -4.054,0.6664 -2.9989,0 -5.4424,-1.1292 -7.3306,-3.3876 -1.8882,-2.2954 -2.8323,-5.2387 -2.8323,-8.83 0,-4.5168 1.3144,-8.4783 3.943,-11.8844 2.6286,-3.4061 5.7015,-5.1092 9.2187,-5.1092 2.7027,0 4.7945,0.759 6.2754,2.2769 1.518,1.518 2.2769,3.6468 2.277,6.3865 -1e-4,1.6291 -0.2777,3.4802 -0.8331,5.5535 l -1.1106,1.1662 -13.8837,1.2773 c 0.6294,4.1836 2.5916,6.2754 5.8867,6.2754 m 0,-17.2157 c -1.6291,0 -2.9989,0.6664 -4.1096,1.9992 -1.1107,1.2959 -1.7586,2.9619 -1.9437,4.9981 l 9.4409,-1.1662 c 0.111,-0.8515 0.1666,-1.5549 0.1666,-2.1103 0,-2.4805 -1.1848,-3.7208 -3.5542,-3.7208"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10204"
+           d="m 3044.4948,-1824.7388 0.722,0 1.7771,-7.6082 2.8878,-13.7171 6.7752,0.833 -4.054,13.7726 -3.3876,12.9951 -9.1077,0.833 -3.0544,-13.4949 -0.2777,-0.8885 -3.7208,13.5504 -8.83,0.833 -3.221,-13.4949 -3.8319,-13.4949 7.275,-1.3883 2.277,12.2176 1.9992,9.0521 0.7219,0 4.776,-15.8273 -1.1662,-4.0541 7.164,-1.3883 2.3324,12.2176 1.9437,9.0521"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10206"
+           d="m 3081.9121,-1817.1861 c -4.1095,0 -7.5712,-1.7216 -10.3849,-5.1647 -2.8138,-3.4431 -4.2207,-7.6638 -4.2207,-12.6619 0,-6.1088 1.6846,-11.255 5.0537,-15.4386 3.4061,-4.1836 7.5712,-6.2754 12.4953,-6.2754 1.9251,0 3.7022,0.2037 5.3313,0.6109 1.666,0.3702 2.8507,0.759 3.5542,1.1662 l 0.9996,0.6109 -3.7763,7.7193 c -0.8516,-0.7404 -2.0548,-1.4254 -3.6098,-2.0548 -1.555,-0.6664 -2.9618,-0.9996 -4.2206,-0.9996 -2.5546,0 -4.6094,1.0552 -6.1644,3.1655 -1.5179,2.1103 -2.2769,5.1277 -2.2769,9.0521 0,3.8874 0.796,7.0899 2.388,9.6075 1.592,2.4805 3.7578,3.7208 6.4976,3.7208 1.7771,0 3.3876,-0.5553 4.8315,-1.666 1.4438,-1.1107 2.462,-2.5916 3.0544,-4.4428 l 4.6093,2.6656 c -1.2217,3.2581 -3.0914,5.8127 -5.6089,7.6638 -2.5176,1.8142 -5.3684,2.7212 -8.5524,2.7212"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10208"
+           d="m 3106.1131,-1817.797 c -1.9993,0 -3.6283,-0.6479 -4.8871,-1.9437 -1.2217,-1.3328 -1.8326,-3.0729 -1.8326,-5.2202 l 0.3332,-6.3865 -0.6109,-13.3283 7.4416,-1.3884 -0.7219,16.8825 c 0,1.592 0.2777,2.7583 0.833,3.4987 0.5924,0.7405 1.5179,1.1107 2.7767,1.1107 1.2588,0 2.9989,-0.6294 5.2203,-1.8882 l 0,-3.332 -0.6109,-14.6056 7.4972,-1.3884 -0.6664,13.8837 0.055,4.5538 c 0,2.3695 0.6109,4.7575 1.8326,7.164 l -5.7756,2.5545 c -1.2217,-2.4065 -1.9622,-4.3317 -2.2213,-5.7756 -3.3321,3.7394 -6.2199,5.609 -8.6634,5.609"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10210"
+           d="m 3137.3105,-1839.1778 c -1.629,0 -2.8878,0.5924 -3.7764,1.7771 -0.8885,1.1478 -1.3328,2.9249 -1.3328,5.3313 0,2.4066 0.4443,4.3503 1.3328,5.8312 0.9256,1.4439 2.2584,2.1658 3.9985,2.1658 0.8886,0 1.7586,-0.2776 2.6102,-0.833 0.8515,-0.5553 1.4994,-1.1107 1.9437,-1.666 l 0.6109,-0.8886 2.7767,2.8878 c -0.1111,0.1851 -0.2592,0.4443 -0.4443,0.7775 -0.1481,0.3332 -0.5554,0.9256 -1.2218,1.7771 -0.6294,0.8515 -1.3143,1.6105 -2.0547,2.2769 -0.7035,0.6294 -1.6291,1.2218 -2.7768,1.7771 -1.1477,0.5184 -2.3509,0.7775 -3.6097,0.7775 -2.7768,0 -5.0352,-1.1292 -6.7752,-3.3876 -1.7401,-2.2584 -2.6102,-5.2017 -2.6102,-8.83 0,-4.5168 1.3144,-8.4783 3.943,-11.8844 2.6286,-3.4061 5.7015,-5.1092 9.2187,-5.1092 1.0737,0 2.1474,0.1667 3.221,0.4998 1.0737,0.2962 2.1659,0.7775 3.2766,1.4439 l -3.8874,6.8308 c -1.3329,-1.0366 -2.8138,-1.555 -4.4428,-1.555"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10212"
+           d="m 3166.4532,-1817.2416 -10.4406,-15.3831 0,0.3887 0.6109,13.0507 -7.4971,1.3883 0.6664,-13.4949 -0.6664,-28.1005 7.6082,-1.3883 -0.722,26.8232 11.107,-12.1621 5.2202,3.5542 -9.7185,9.1077 10.1628,11.9955 -6.3309,4.2206"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10214"
+           d="m 3174.2514,-1829.4037 c 0,-4.7389 1.2773,-8.7559 3.8319,-12.051 2.5546,-3.295 5.5349,-4.9426 8.9411,-4.9426 3.4431,0 6.1088,1.1107 7.997,3.3321 1.9251,2.2214 2.8877,5.1647 2.8877,8.83 0,4.8871 -1.2402,8.9596 -3.7208,12.2176 -2.4435,3.221 -5.5349,4.8315 -9.2742,4.8315 -3.073,0 -5.6276,-1.1477 -7.6638,-3.4431 -1.9993,-2.3325 -2.9989,-5.2573 -2.9989,-8.7745 m 17.4379,-1.9437 c 0,-5.3683 -1.8327,-8.0525 -5.498,-8.0525 -3.8133,0 -5.72,2.5176 -5.72,7.5527 0,2.6286 0.5368,4.6834 1.6105,6.1643 1.0736,1.481 2.4805,2.2214 4.2206,2.2214 1.7771,0 3.1099,-0.6664 3.9985,-1.9992 0.9256,-1.3699 1.3884,-3.3321 1.3884,-5.8867"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path10216"
+           d="m 3201.0425,-1829.4037 c 0,-4.7389 1.2773,-8.7559 3.8319,-12.051 2.5546,-3.295 5.5349,-4.9426 8.9411,-4.9426 3.4431,0 6.1088,1.1107 7.997,3.3321 1.9251,2.2214 2.8877,5.1647 2.8878,8.83 -10e-5,4.8871 -1.2403,8.9596 -3.7209,12.2176 -2.4435,3.221 -5.5349,4.8315 -9.2742,4.8315 -3.073,0 -5.6276,-1.1477 -7.6638,-3.4431 -1.9993,-2.3325 -2.9989,-5.2573 -2.9989,-8.7745 m 17.4379,-1.9437 c 0,-5.3683 -1.8327,-8.0525 -5.4979,-8.0525 -3.8134,0 -5.7201,2.5176 -5.7201,7.5527 0,2.6286 0.5368,4.6834 1.6105,6.1643 1.0737,1.481 2.4805,2.2214 4.2206,2.2214 1.7771,0 3.1099,-0.6664 3.9985,-1.9992 0.9256,-1.3699 1.3884,-3.3321 1.3884,-5.8867"
+           inkscape:connector-curvature="0" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/contrib/themes/diffy/static/diffymute.svg b/contrib/themes/diffy/static/diffymute.svg
new file mode 100644
index 0000000..6833ba6
--- /dev/null
+++ b/contrib/themes/diffy/static/diffymute.svg
@@ -0,0 +1,198 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="600.06732"
+   height="558.20709"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.48.3.1 r9886"
+   sodipodi:docname="diffy.svg"
+   inkscape:export-filename="/home/sarah/art/diffy.png"
+   inkscape:export-xdpi="500"
+   inkscape:export-ydpi="500">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="0.66618777"
+     inkscape:cx="300.03365"
+     inkscape:cy="258.35521"
+     inkscape:document-units="px"
+     inkscape:current-layer="g10119"
+     showgrid="false"
+     inkscape:window-width="1366"
+     inkscape:window-height="744"
+     inkscape:window-x="0"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-3069.8852,2251.5084)">
+    <text
+       xml:space="preserve"
+       style="font-size:51.65934372px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:end;line-height:125%;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeSans;-inkscape-font-specification:FreeSans Bold"
+       x="475.83173"
+       y="900.47076"
+       id="text4257"
+       sodipodi:linespacing="125%"
+       transform="scale(1.1411753,0.87628955)"><tspan
+         sodipodi:role="line"
+         x="475.83173"
+         y="900.47076"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:end;text-anchor:end;font-family:Acme;-inkscape-font-specification:Acme Bold"
+         id="tspan3008" /></text>
+    <flowRoot
+       xml:space="preserve"
+       id="flowRoot4263"
+       style="font-size:12px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><flowRegion
+         id="flowRegion4265"><rect
+           id="rect4267"
+           width="313.14728"
+           height="343.45187"
+           x="-335.37064"
+           y="205.85435" /></flowRegion><flowPara
+         id="flowPara4269" /></flowRoot>    <g
+       id="g10119"
+       transform="matrix(0.99201906,-0.12608805,0.12608805,0.99201906,273.85438,408.58042)">
+      <path
+         sodipodi:nodetypes="csccscac"
+         inkscape:connector-curvature="0"
+         id="path4130"
+         d="m 3378.9965,-2206.2972 c 0,0 13.4039,-8.8398 27.0022,-17.1718 13.5984,-8.3319 22.2322,-21.276 22.2322,-21.276 l 34.2799,28.1467 c 0,0 -16.0641,15.553 -24.7742,21.6893 -3.709,2.6129 -10.9463,9.1577 -10.9463,9.1577 0,0 -14.3617,-10.0769 -22.3623,-13.5225 -8.0773,-3.4786 -25.4315,-7.0234 -25.4315,-7.0234 z"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cac"
+         inkscape:connector-curvature="0"
+         d="m 3414.7387,-2204.9828 c 0,0 8.2233,-5.8571 11.9326,-9.2622 3.8224,-3.5089 10.5596,-11.437 10.5596,-11.437"
+         style="fill:#ffffff;stroke:#ff0000;stroke-width:8.97858429;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         id="path4185" />
+      <path
+         sodipodi:nodetypes="cascacsc"
+         inkscape:connector-curvature="0"
+         id="path3307"
+         d="m 3247.6516,-2171.4037 c 0,0 4.3348,-19.2835 10.7071,-26.2701 7.4284,-8.1446 19.072,-14.1542 29.3495,-15.24 27.8177,-2.9386 63.2371,6.1908 63.2371,6.1908 0,0 -30.8042,0.7262 -45.6022,4.5134 -5.1922,1.3289 -14.7941,6.2977 -14.7941,6.2977 0,0 -16.1166,0.8374 -29.8971,12.0927 -13.7804,11.2552 -3.0344,9.7831 -3.0344,9.7831"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="ccsccscscccccscc"
+         inkscape:connector-curvature="0"
+         id="path3011"
+         d="m 3332.9985,-2012.4352 -20.0742,24.9886 c 0,0 -16.3479,-5.082 -40.3255,4.9725 -23.9776,10.0546 -39.2193,26.4042 -39.2193,26.4042 l 7.8897,-0.7975 c 0,0 20.4818,-14.4371 31.9614,-17.1294 11.4796,-2.6923 23.6197,-2.8388 23.6197,-2.8388 0,0 -25.492,10.0264 -30.7497,12.3759 -6.2584,2.7968 -15.0874,9.199 -15.0874,9.199 l 10.4761,-0.2965 c 17.6867,-7.2528 41.3221,-16.0187 59.9685,-15.047 15.5919,1.1274 33.9405,16.7543 33.9405,16.7543 l -3.8165,-9.9895 c 0,0 -10.8307,-8.1932 -16.3866,-13.7491 -5.5558,-5.5558 17.5848,-25.8193 17.5848,-25.8193 -14.3098,-2.8351 -2.1172,-7.9424 -19.7815,-9.0274 z"
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         d="m 3397.9929,-1984.0461 -15.5774,33.0846 c 0,0 -14.4499,-2.2165 -39.5097,4.7139 -25.0597,6.9303 -44.9575,20.0476 -44.9575,20.0476 l 10.5706,1.822 c 0,0 19.7276,-11.1182 30.516,-14.1955 7.747,-2.2097 23.9934,-2.9008 23.9934,-2.9008 0,0 -20.2687,6.6869 -30.057,10.9033 -5.4067,2.329 -15.7817,7.9273 -15.7817,7.9273 l 10.8391,1.5269 c 0,0 31.056,-11.8625 47.4047,-13.3504 11.1103,-1.0112 11.8331,-2.962 33.324,3.1079 10.0509,2.8388 16.3928,6.252 16.3928,6.252 l -3.3122,-5.8696 c 0,0 -5.3909,-4.5711 -8.2712,-6.6111 -3.5982,-2.5484 -11.2202,-7.0059 -11.2202,-7.0059 l 15.4484,-30.858 c -13.3255,-5.9362 -2.8177,-3.6211 -19.8021,-8.5942 z"
+         id="path3013"
+         inkscape:connector-curvature="0"
+         sodipodi:nodetypes="ccsccacaccasccaccc" />
+      <path
+         sodipodi:nodetypes="aaczsccsssca"
+         inkscape:connector-curvature="0"
+         id="path3014"
+         d="m 3138.8926,-2124.2277 c 9.8375,-20.5358 27.6337,-38.9533 48.4705,-48.136 22.7321,-10.018 51.1582,-10.0879 74.4382,-3.5941 7.6944,6.5 10.3115,-17.1804 46.9059,-27.4208 36.5944,-10.2403 91.6429,-6.509 142.1505,22.7854 54.0494,31.3487 -23.7386,41.9213 -8.0812,94.4492 32.3844,113.578 -63.1345,101.5204 -63.1345,101.5204 0,0 -25.2539,-16.1625 -35.3554,-17.1726 -10.1015,-1.0102 -46.6429,-25.1251 -68.8662,-28.6607 -22.2234,-3.5355 -36.9003,18.6222 -107.991,-9.9282 -24.3178,-9.7661 -33.8401,-33.335 -33.8401,-33.335 0,0 -2.0102,-35.2405 5.3033,-50.5076 z"
+         style="fill:#0000ff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cssscscsac"
+         inkscape:connector-curvature="0"
+         id="path3028"
+         d="m 3179.7636,-2035.4822 c 0,0 33.988,-7.9036 57.2215,-14.9746 23.2335,-7.0711 36.1637,-43.7474 71.519,-48.7981 35.3554,-5.0508 68.528,22.1624 80.6498,47.4162 12.1218,25.2539 50.6701,55.6193 50.6701,55.6193 0,0 1.0101,11.1117 -14.1422,18.1828 -15.1522,7.0711 -10.4989,7.6628 -10.4989,7.6628 0,0 -43.1103,-11.4139 -62.396,-21.4139 -19.2857,-10 -61.236,-23.044 -92.9377,-30.7963 -26.2653,-6.4228 -35.0334,2.5255 -80.0856,-12.8982 z"
+         style="fill:#ff6600;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="cscccccscc"
+         inkscape:connector-curvature="0"
+         id="path3009"
+         d="m 3470.1209,-2002.8128 c 0,0 13.8937,99.0002 33.6829,157.8517 19.7893,58.8514 78.4464,180.4742 78.4464,180.4742 l 28.1031,-31.8521 -52.5625,-109.7203 60.5152,95.9016 26.0124,-35.7271 c 0,0 -70.663,-88.4878 -93.8954,-140.4597 -23.2324,-51.9718 -37.4945,-152.9082 -37.4945,-152.9082 z"
+         style="fill:#0000ff;fill-opacity:1;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="csscsccccccccccsc"
+         inkscape:connector-curvature="0"
+         id="path3016"
+         d="m 3359.2154,-2191.502 c 0,0 -40.7142,5 -57.1428,42.1428 -16.4286,37.1429 50.7143,115.7143 60.7143,132.1429 10,16.4286 24.2857,38.5714 24.2857,38.5714 0,0 47.6766,32.0335 82.9062,53.8444 32.6567,20.2179 65.2266,33.1109 65.2266,33.1109 0,0 1.1687,-0.4937 7.0265,-19.1963 -12.1218,-21.7183 -38.3216,-53.2543 -54.5036,-69.192 24.2792,14.1167 43.346,28.1254 60.1041,56.5482 0,0 4.9588,-21.3916 6.5659,-30.7894 -3.0357,-9.5763 -47.4771,-56.5685 -47.4771,-56.5685 20.5517,8.7613 33.7508,23.0255 50.0892,44.7732 0,0 3.3263,-21.5598 3.8366,-32.9472 -2.5253,-6.566 -33.7228,-35.0595 -33.7228,-35.0595 13.1654,6.6374 19.714,11.0661 34.4127,24.0476 0,0 -3.0357,-60.7142 -66.6072,-130.7142 -63.5714,-70 -135.7143,-50.7143 -135.7143,-50.7143 z"
+         style="fill:#000080;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <g
+         transform="translate(2935.644,-2513.1499)"
+         id="g3138">
+        <path
+           style="fill:#ff0000;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+           d="m 337.66259,394.0585 c 0,0 -28.86328,-30.61257 -60.78781,-25.80202 -31.92454,4.81054 -41.10831,33.67382 -39.79634,39.79633 1.31197,6.12252 13.557,11.80771 22.30345,15.74361 8.74644,3.9359 32.79917,-0.43732 45.48152,-6.55984 12.68235,-6.12251 32.79918,-23.17808 32.79918,-23.17808 z"
+           id="path3022"
+           inkscape:connector-curvature="0" />
+        <path
+           style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:3;stroke-miterlimit:4"
+           d="m 286.65383,372.49887 c 12.50235,2.18615 20.86528,14.09354 18.67912,26.59589 -2.18615,12.50234 -14.09354,20.86527 -26.59589,18.67911 -12.50235,-2.18616 -20.86528,-14.09354 -18.67912,-26.59588 z"
+           id="path3024"
+           inkscape:connector-curvature="0"
+           sodipodi:nodetypes="csscc" />
+        <path
+           sodipodi:type="arc"
+           style="fill:#000000;fill-opacity:1;stroke:none"
+           id="path3026"
+           sodipodi:cx="229.64285"
+           sodipodi:cy="403.79074"
+           sodipodi:rx="8.2142859"
+           sodipodi:ry="8.2142859"
+           d="m 237.85714,403.79074 c 0,4.53663 -3.67766,8.21429 -8.21429,8.21429 -4.53662,0 -8.21428,-3.67766 -8.21428,-8.21429 0,-4.53662 3.67766,-8.21428 8.21428,-8.21428 4.53663,0 8.21429,3.67766 8.21429,8.21428 z"
+           transform="matrix(1.5694327,0,0,1.5694327,-85.566867,-233.30737)" />
+      </g>
+      <path
+         sodipodi:nodetypes="cssssssc"
+         style="fill:#ffff00;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         d="m 3110.2692,-2103.8575 c -32.3249,20.7081 -31.4522,56.0845 -27.4116,60.6301 4.0406,4.5457 8.0812,1.0102 15.6574,-1.5152 7.5761,-2.5254 26.2639,-10.1015 42.4264,-11.1117 16.1624,-1.0101 21.2132,3.0305 23.7386,-0.505 2.5254,-3.5356 4.0406,-15.1523 3.0304,-25.7589 -1.0101,-10.6066 -1.0101,-23.2335 -7.071,-25.2538 -6.061,-2.0203 -38.2484,-5.0718 -50.3702,3.5145 z"
+         id="path3296"
+         inkscape:connector-curvature="0" />
+      <path
+         inkscape:connector-curvature="0"
+         id="path3305"
+         d="m 3251.1404,-2178.2729 c 0,0 12.1428,21.0714 67.1428,32.5 55,11.4286 125.7143,-38.2143 125.7143,-38.2143 l 38.7857,38.6429 c 0,0 -113.0714,25.6428 -150.2143,21 -37.1428,-4.6429 -81.4285,-50.3572 -81.4285,-50.3572"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         sodipodi:nodetypes="ccccc"
+         inkscape:connector-curvature="0"
+         id="path4099"
+         d="m 3453.1169,-2166.1191 -30.2186,14.3667 6.8378,5.5184 29.9689,-14.0733 z"
+         style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans" />
+      <path
+         sodipodi:nodetypes="cacscacsc"
+         inkscape:connector-curvature="0"
+         id="path3303"
+         d="m 3154.8351,-2149.0637 c 0,0 -6.1891,7.2925 -8.7928,11.2888 -2.8625,4.3936 -7.4472,13.8569 -7.4472,13.8569 0,0 49.8667,-24.0831 67.6065,-30.2972 18.2787,-6.4028 54.803,-21.7873 54.803,-21.7873 0,0 -3.8451,-1.8876 -5.9103,-2.3872 -3.1018,-0.7503 -9.5343,-0.868 -9.5343,-0.868 0,0 -25.7837,-0.1235 -42.8032,6.1343 -17.0196,6.2579 -47.9217,24.0597 -47.9217,24.0597 z"
+         style="fill:#ffffff;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+      <path
+         style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans"
+         d="m 3433.0379,-2169.2212 24.4739,22.8165 -8.1844,3.1976 -24.3243,-22.4616 z"
+         id="path27241"
+         inkscape:connector-curvature="0"
+         sodipodi:nodetypes="ccccc" />
+    </g>
+  </g>
+</svg>
diff --git a/gerrit-acceptance-framework/BUCK b/gerrit-acceptance-framework/BUCK
index d8f0276..ba68fa3 100644
--- a/gerrit-acceptance-framework/BUCK
+++ b/gerrit-acceptance-framework/BUCK
@@ -2,14 +2,19 @@
 
 DEPS = [
   '//gerrit-gpg:gpg',
+  '//gerrit-launcher:launcher',
+  '//gerrit-openid:openid',
   '//gerrit-pgm:daemon',
+  '//gerrit-pgm:http-jetty',
   '//gerrit-pgm:util-nodep',
+  '//gerrit-server/src/main/prolog:common',
   '//gerrit-server:testutil',
   '//lib/auto:auto-value',
   '//lib/httpcomponents:fluent-hc',
   '//lib/httpcomponents:httpclient',
   '//lib/httpcomponents:httpcore',
-  '//lib/jgit:junit',
+  '//lib/jetty:servlet',
+  '//lib/jgit/org.eclipse.jgit.junit:junit',
   '//lib/log:impl_log4j',
   '//lib/log:log4j',
 ]
@@ -24,8 +29,8 @@
   '//gerrit-reviewdb:server',
   '//gerrit-server:server',
   '//lib:gson',
-  '//lib/jgit:jgit',
   '//lib:jsch',
+  '//lib/jgit/org.eclipse.jgit:jgit',
   '//lib/mina:sshd',
   '//lib:servlet-api-3_1',
 ]
@@ -80,8 +85,8 @@
     '//lib/guice:guice-servlet',
     '//lib/guice:javax-inject',
     '//lib:gwtorm_client',
-    '//lib:junit__jar',
-    '//lib:truth__jar',
+    '//lib:junit',
+    '//lib:truth',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
new file mode 100644
index 0000000..1439ba9
--- /dev/null
+++ b/gerrit-acceptance-framework/BUILD
@@ -0,0 +1,60 @@
+load('//tools/bzl:java.bzl', 'java_library2')
+
+SRCS = glob(['src/test/java/com/google/gerrit/acceptance/*.java'])
+
+DEPS = [
+  '//gerrit-gpg:gpg',
+  '//gerrit-launcher:launcher',
+  '//gerrit-openid:openid',
+  '//gerrit-pgm:daemon',
+  '//gerrit-pgm:http-jetty',
+  '//gerrit-pgm:util-nodep',
+  '//gerrit-server/src/main/prolog:common',
+  '//gerrit-server:testutil',
+  '//lib/auto:auto-value',
+  '//lib/httpcomponents:fluent-hc',
+  '//lib/httpcomponents:httpclient',
+  '//lib/httpcomponents:httpcore',
+  '//lib/jetty:servlet',
+  '//lib/jgit/org.eclipse.jgit.junit:junit',
+  '//lib/log:impl_log4j',
+  '//lib/log:log4j',
+]
+
+PROVIDED = [
+  '//gerrit-common:annotations',
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-httpd:httpd',
+  '//gerrit-lucene:lucene',
+  '//gerrit-pgm:init',
+  '//gerrit-reviewdb:server',
+  '//gerrit-server:server',
+  '//lib:gson',
+  '//lib:jsch',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/mina:sshd',
+  '//lib:servlet-api-3_1',
+]
+
+java_binary(
+  name = 'acceptance-framework',
+  main_class = 'Dummy',
+  runtime_deps = [':lib'],
+  visibility = ['//visibility:public'],
+)
+
+java_library2(
+  name = 'lib',
+  srcs = SRCS,
+  exported_deps = DEPS + [
+    '//lib:truth',
+  ],
+  deps = PROVIDED + [ # We want these deps to be exported_deps
+    '//lib:gwtorm',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index f8a6856..68f98b1 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,10 +2,10 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
-  <description>API for Gerrit Plugins</description>
+  <description>Framework for Gerrit's acceptance tests</description>
   <url>https://www.gerritcodereview.com/</url>
 
   <licenses>
@@ -23,15 +23,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 84af20e..a21b7d0 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -16,12 +16,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
+import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.base.Function;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Chars;
@@ -33,6 +36,8 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -42,35 +47,49 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.EmailHeader;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TempFileUtil;
+import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gson.Gson;
+import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -78,11 +97,16 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.Transport;
+import org.junit.After;
 import org.junit.AfterClass;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
@@ -95,6 +119,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
@@ -128,7 +153,7 @@
   protected AccountCache accountCache;
 
   @Inject
-  private IdentifiedUser.GenericFactory identifiedUserFactory;
+  protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
   protected PushOneCommit.Factory pushFactory;
@@ -169,18 +194,47 @@
   @GerritPersonIdent
   protected Provider<PersonIdent> serverIdent;
 
+  @Inject
+  protected ChangeData.Factory changeDataFactory;
+
+  @Inject
+  protected PatchSetUtil psUtil;
+
+  @Inject
+  protected ChangeFinder changeFinder;
+
+  @Inject
+  protected Revisions revisions;
+
+  @Inject
+  protected FakeEmailSender sender;
+
+  @Inject
+  protected ChangeNoteUtil changeNoteUtil;
+
+  @Inject
+  protected ChangeResource.Factory changeResourceFactory;
+
+  @Inject
+  private EventRecorder.Factory eventRecorderFactory;
+
   protected TestRepository<InMemoryRepository> testRepo;
   protected GerritServer server;
   protected TestAccount admin;
   protected TestAccount user;
-  protected RestSession adminSession;
-  protected RestSession userSession;
-  protected SshSession sshSession;
+  protected RestSession adminRestSession;
+  protected RestSession userRestSession;
+  protected SshSession adminSshSession;
+  protected SshSession userSshSession;
   protected ReviewDb db;
   protected Project.NameKey project;
+  protected EventRecorder eventRecorder;
 
   @Inject
-  protected NotesMigration notesMigration;
+  protected TestNotesMigration notesMigration;
+
+  @Inject
+  protected ChangeNotes.Factory notesFactory;
 
   @Rule
   public ExpectedException exception = ExpectedException.none();
@@ -209,11 +263,29 @@
   @Rule
   public TemporaryFolder tempSiteDir = new TemporaryFolder();
 
+  @Before
+  public void clearSender() {
+    sender.clear();
+  }
+
+  @Before
+  public void startEventRecorder() {
+    eventRecorder = eventRecorderFactory.create(admin);
+  }
+
+  @After
+  public void closeEventRecorder() {
+    eventRecorder.close();
+  }
+
   @AfterClass
   public static void stopCommonServer() throws Exception {
     if (commonServer != null) {
-      commonServer.stop();
-      commonServer = null;
+      try {
+        commonServer.stop();
+      } finally {
+        commonServer = null;
+      }
     }
     TempFileUtil.cleanup();
   }
@@ -238,11 +310,8 @@
     return cfg.getBoolean("change", null, "submitWholeTopic", false);
   }
 
-  private static boolean isNoteDbTestEnabled() {
-    final String[] RUN_FLAGS = {"yes", "y", "true"};
-    String value = System.getenv("GERRIT_ENABLE_NOTEDB");
-    return value != null &&
-        Arrays.asList(RUN_FLAGS).contains(value.toLowerCase());
+  protected boolean isContributorAgreementsEnabled() {
+    return cfg.getBoolean("auth", null, "contributorAgreements", false);
   }
 
   protected void beforeTest(Description description) throws Exception {
@@ -251,11 +320,9 @@
     GerritServer.Description methodDesc =
       GerritServer.Description.forTestMethod(description, configName);
 
-    if (isNoteDbTestEnabled()) {
-      NotesMigration.setAllEnabledConfig(baseConfig);
-    }
     baseConfig.setString("gerrit", null, "tempSiteDir",
         tempSiteDir.getRoot().getPath());
+    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() &&
         !methodDesc.sandboxed()) {
       if (commonServer == null) {
@@ -267,6 +334,7 @@
     }
 
     server.getTestInjector().injectMembers(this);
+    notesMigration.setFromEnv();
     Transport.register(inProcessProtocol);
     toClose = Collections.synchronizedList(new ArrayList<Repository>());
     admin = accounts.admin();
@@ -276,14 +344,18 @@
     accountCache.evict(admin.getId());
     accountCache.evict(user.getId());
 
-    adminSession = new RestSession(server, admin);
-    userSession = new RestSession(server, user);
+    adminRestSession = new RestSession(server, admin);
+    userRestSession = new RestSession(server, user);
     initSsh(admin);
     db = reviewDbProvider.open();
-    Context ctx = newRequestContext(admin);
+    Context ctx = newRequestContext(user);
     atrScope.set(ctx);
-    sshSession = ctx.getSession();
-    sshSession.open();
+    userSshSession = ctx.getSession();
+    userSshSession.open();
+    ctx = newRequestContext(admin);
+    atrScope.set(ctx);
+    adminSshSession = ctx.getSession();
+    adminSshSession.open();
     resourcePrefix = UNSAFE_PROJECT_NAME.matcher(
         description.getClassName() + "_"
         + description.getMethodName() + "_").replaceAll("");
@@ -350,14 +422,19 @@
   protected Project.NameKey createProject(String nameSuffix,
       Project.NameKey parent) throws RestApiException {
     // Default for createEmptyCommit should match TestProjectConfig.
-    return createProject(nameSuffix, parent, true);
+    return createProject(nameSuffix, parent, true, null);
   }
 
   protected Project.NameKey createProject(String nameSuffix,
-      Project.NameKey parent, boolean createEmptyCommit)
-      throws RestApiException {
-    return createProject(
-        nameSuffix, parent, createEmptyCommit, SubmitType.MERGE_IF_NECESSARY);
+      Project.NameKey parent, boolean createEmptyCommit) throws RestApiException {
+    // Default for createEmptyCommit should match TestProjectConfig.
+    return createProject(nameSuffix, parent, createEmptyCommit, null);
+  }
+
+  protected Project.NameKey createProject(String nameSuffix,
+      Project.NameKey parent, SubmitType submitType) throws RestApiException {
+    // Default for createEmptyCommit should match TestProjectConfig.
+    return createProject(nameSuffix, parent, true, submitType);
   }
 
   protected Project.NameKey createProject(String nameSuffix,
@@ -366,8 +443,8 @@
     ProjectInput in = new ProjectInput();
     in.name = name(nameSuffix);
     in.parent = parent != null ? parent.get() : null;
-    in.createEmptyCommit = createEmptyCommit;
     in.submitType = submitType;
+    in.createEmptyCommit = createEmptyCommit;
     return createProject(in);
   }
 
@@ -407,7 +484,8 @@
       repo.close();
     }
     db.close();
-    sshSession.close();
+    adminSshSession.close();
+    userSshSession.close();
     if (server != commonServer) {
       server.stop();
     }
@@ -418,7 +496,7 @@
   }
 
   protected TestRepository<?>.CommitBuilder amendBuilder() throws Exception {
-    ObjectId head = repo().getRef("HEAD").getObjectId();
+    ObjectId head = repo().exactRef("HEAD").getObjectId();
     TestRepository<?>.CommitBuilder b = testRepo.amendRef("HEAD");
     Optional<String> id = GitUtil.getChangeId(testRepo, head);
     // TestRepository behaves like "git commit --amend -m foo", which does not
@@ -432,12 +510,73 @@
   }
 
   protected PushOneCommit.Result createChange() throws Exception {
+    return createChange("refs/for/master");
+  }
+
+  protected PushOneCommit.Result createChange(String ref) throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result result = push.to("refs/for/master");
+    PushOneCommit.Result result = push.to(ref);
     result.assertOkStatus();
     return result;
   }
 
+  protected PushOneCommit.Result createMergeCommitChange(String ref)
+      throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result p1 = pushFactory.create(db, admin.getIdent(),
+        testRepo, "parent 1", ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
+        .to(ref);
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result p2 = pushFactory.create(db, admin.getIdent(),
+        testRepo, "parent 2", ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
+        .to(ref);
+
+    PushOneCommit m = pushFactory.create(db, admin.getIdent(), testRepo, "merge",
+        ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  protected PushOneCommit.Result createDraftChange() throws Exception {
+    return pushTo("refs/drafts/master");
+  }
+
+  protected PushOneCommit.Result createChange(String subject,
+      String fileName, String content) throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  protected PushOneCommit.Result createChange(String subject,
+      String fileName, String content, String topic)
+          throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master/" + name(topic));
+  }
+
+  protected PushOneCommit.Result createChange(TestRepository<?> repo,
+      String branch, String subject, String fileName, String content,
+      String topic) throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), repo, subject, fileName, content);
+    return push.to("refs/for/" + branch + "/" + name(topic));
+  }
+
+  protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
+    return gApi.projects()
+        .name(branch.getParentKey().get())
+        .branch(branch.get())
+        .create(new BranchInput());
+  }
+
   protected BranchApi createBranchWithRevision(Branch.NameKey branch,
       String revision) throws Exception {
     BranchInput in = new BranchInput();
@@ -457,13 +596,24 @@
 
   protected PushOneCommit.Result amendChange(String changeId, String ref)
       throws Exception {
+    return amendChange(changeId, ref, admin, testRepo);
+  }
+
+  protected PushOneCommit.Result amendChange(String changeId, String ref,
+      TestAccount testAccount, TestRepository<?> repo) throws Exception {
     Collections.shuffle(RANDOM);
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME, new String(Chars.toArray(RANDOM)), changeId);
+        pushFactory.create(db, testAccount.getIdent(), repo,
+            PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
+            new String(Chars.toArray(RANDOM)), changeId);
     return push.to(ref);
   }
 
+  protected void merge(PushOneCommit.Result r) throws Exception {
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+  }
+
   protected PushOneCommit.Result amendChangeAsDraft(String changeId)
       throws Exception {
     return amendChange(changeId, "refs/drafts/master");
@@ -495,8 +645,8 @@
   }
 
   private Context newRequestContext(TestAccount account) {
-    return atrScope.newContext(reviewDbProvider, new SshSession(server, admin),
-        identifiedUserFactory.create(Providers.of(db), account.getId()));
+    return atrScope.newContext(reviewDbProvider, new SshSession(server, account),
+        identifiedUserFactory.create(account.getId()));
   }
 
   protected Context setApiUser(TestAccount account) {
@@ -504,7 +654,18 @@
   }
 
   protected Context setApiUserAnonymous() {
-    return atrScope.newContext(reviewDbProvider, null, anonymousUser.get());
+    return atrScope.set(
+        atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
+  }
+
+  protected Context disableDb() {
+    notesMigration.setFailOnLoad(true);
+    return atrScope.disableDb();
+  }
+
+  protected void enableDb(Context preDisableContext) {
+    notesMigration.setFailOnLoad(false);
+    atrScope.set(preDisableContext);
   }
 
   protected static Gson newGson() {
@@ -554,40 +715,72 @@
 
   protected void setUseContributorAgreements(InheritableBoolean value)
       throws Exception {
-    MetaDataUpdate md = metaDataUpdateFactory.create(project);
-    ProjectConfig config = ProjectConfig.read(md);
-    config.getProject().setUseContributorAgreements(value);
-    config.commit(md);
-    projectCache.evict(config.getProject());
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      config.getProject().setUseContributorAgreements(value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
   }
 
   protected void setUseSignedOffBy(InheritableBoolean value)
       throws Exception {
-    MetaDataUpdate md = metaDataUpdateFactory.create(project);
-    ProjectConfig config = ProjectConfig.read(md);
-    config.getProject().setUseSignedOffBy(value);
-    config.commit(md);
-    projectCache.evict(config.getProject());
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      config.getProject().setUseSignedOffBy(value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void setRequireChangeId(InheritableBoolean value) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      config.getProject().setRequireChangeID(value);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
   }
 
   protected void deny(String permission, AccountGroup.UUID id, String ref)
       throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    deny(project, permission, id, ref);
+  }
+
+  protected void deny(Project.NameKey p, String permission,
+      AccountGroup.UUID id, String ref) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
     Util.deny(cfg, permission, id, ref);
+    saveProjectConfig(p, cfg);
+  }
+
+  protected PermissionRule block(String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    return block(permission, id, ref, project);
+  }
+
+  protected PermissionRule block(String permission,
+      AccountGroup.UUID id, String ref, Project.NameKey project)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    PermissionRule rule = Util.block(cfg, permission, id, ref);
     saveProjectConfig(project, cfg);
+    return rule;
   }
 
   protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg)
       throws Exception {
-    MetaDataUpdate md = metaDataUpdateFactory.create(p);
-    try {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
+      md.setAuthor(identifiedUserFactory.create(admin.getId()));
       cfg.commit(md);
-    } finally {
-      md.close();
     }
     projectCache.evict(cfg.getProject());
   }
 
+  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    saveProjectConfig(project, cfg);
+  }
+
   protected void grant(String permission, Project.NameKey project, String ref)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     grant(permission, project, ref, false);
@@ -595,30 +788,37 @@
 
   protected void grant(String permission, Project.NameKey project, String ref,
       boolean force) throws RepositoryNotFoundException, IOException,
-      ConfigInvalidException {
-    MetaDataUpdate md = metaDataUpdateFactory.create(project);
-    md.setMessage(String.format("Grant %s on %s", permission, ref));
-    ProjectConfig config = ProjectConfig.read(md);
-    AccessSection s = config.getAccessSection(ref, true);
-    Permission p = s.getPermission(permission, true);
-    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
-    PermissionRule rule = new PermissionRule(config.resolve(adminGroup));
-    rule.setForce(force);
-    p.add(rule);
-    config.commit(md);
-    projectCache.evict(config.getProject());
+          ConfigInvalidException {
+    AccountGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators"));
+    grant(permission, project, ref, force, adminGroup.getGroupUUID());
   }
 
-  protected void blockRead(Project.NameKey project, String ref) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.READ, REGISTERED_USERS, ref);
-    saveProjectConfig(project, cfg);
+  protected void grant(String permission, Project.NameKey project, String ref,
+      boolean force, AccountGroup.UUID groupUUID)
+          throws RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Grant %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      PermissionRule rule = Util.newRule(config, groupUUID);
+      rule.setForce(force);
+      p.add(rule);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void blockRead(String ref) throws Exception {
+    block(Permission.READ, REGISTERED_USERS, ref);
   }
 
   protected void blockForgeCommitter(Project.NameKey project, String ref)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
+    Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
     saveProjectConfig(project, cfg);
   }
 
@@ -641,17 +841,115 @@
       .actions();
   }
 
+  private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
+    return Iterables.transform(changes,
+        new Function<ChangeInfo, String>() {
+          @Override
+          public String apply(ChangeInfo input) {
+            return input.changeId;
+          }
+        });
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected)
       throws Exception {
     List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(chId)
+            .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+
+    assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(actual).hasSize(expected.length);
-    assertThat(Iterables.transform(actual,
-        new Function<ChangeInfo, String>() {
-      @Override
-      public String apply(ChangeInfo input) {
-        return input.changeId;
-      }
-    })).containsExactly((Object[])expected).inOrder();
+    assertThat(changeIds(actual))
+        .containsExactly((Object[])expected).inOrder();
+    assertThat(changeIds(info.changes))
+        .containsExactly((Object[])expected).inOrder();
+  }
+
+  protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
+    return changeDataFactory.create(db, project, psId.getParentKey())
+        .patchSet(psId);
+  }
+
+  protected IdentifiedUser user(TestAccount testAccount) {
+    return identifiedUserFactory.create(testAccount.getId());
+  }
+
+  protected RevisionResource parseCurrentRevisionResource(String changeId)
+      throws Exception {
+    ChangeResource cr = parseChangeResource(changeId);
+    int psId = cr.getChange().currentPatchSetId().get();
+    return revisions.parse(cr,
+        IdString.fromDecoded(Integer.toString(psId)));
+  }
+
+  protected RevisionResource parseRevisionResource(String changeId, int n)
+      throws Exception {
+    return revisions.parse(parseChangeResource(changeId),
+        IdString.fromDecoded(Integer.toString(n)));
+  }
+
+  protected RevisionResource parseRevisionResource(PushOneCommit.Result r)
+      throws Exception {
+    PatchSet.Id psId = r.getPatchSetId();
+    return parseRevisionResource(psId.getParentKey().toString(), psId.get());
+  }
+
+  protected ChangeResource parseChangeResource(String changeId)
+      throws Exception {
+    List<ChangeControl> ctls = changeFinder.find(
+        changeId, atrScope.get().getUser());
+    assertThat(ctls).hasSize(1);
+    return changeResourceFactory.create(ctls.get(0));
+  }
+
+  protected String createGroup(String name) throws Exception {
+    return createGroup(name, "Administrators");
+  }
+
+  protected String createGroup(String name, String owner) throws Exception {
+    name = name(name);
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = owner;
+    gApi.groups().create(in);
+    return name;
+  }
+
+  protected RevCommit getHead(Repository repo, String name) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      return rw.parseCommit(repo.exactRef(name).getObjectId());
+    }
+  }
+
+  protected RevCommit getHead(Repository repo) throws Exception {
+    return getHead(repo, "HEAD");
+  }
+
+  protected RevCommit getRemoteHead(Project.NameKey project, String branch)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return getHead(repo,
+          branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
+    }
+  }
+
+  protected RevCommit getRemoteHead(String project, String branch)
+      throws Exception {
+    return getRemoteHead(new Project.NameKey(project), branch);
+  }
+
+  protected RevCommit getRemoteHead() throws Exception {
+    return getRemoteHead(project, "master");
+  }
+
+  protected void assertMailFrom(Message message, String email)
+      throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    EmailHeader.String replyTo =
+        (EmailHeader.String)message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).isEqualTo(email);
   }
 
   protected TestRepository<?> createProjectWithPush(String name,
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 34379a1..71d738c 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -32,6 +31,7 @@
 import com.google.inject.Scope;
 import com.google.inject.util.Providers;
 
+import java.util.HashMap;
 import java.util.Map;
 
 /** Guice scopes for state during an Acceptance Test connection. */
@@ -44,7 +44,7 @@
 
   public static class Context implements RequestContext {
     private final RequestCleanup cleanup = new RequestCleanup();
-    private final Map<Key<?>, Object> map = Maps.newHashMap();
+    private final Map<Key<?>, Object> map = new HashMap<>();
     private final SchemaFactory<ReviewDb> schemaFactory;
     private final SshSession session;
     private final CurrentUser user;
@@ -182,6 +182,14 @@
     return old;
   }
 
+  public Context reopenDb() {
+    // Setting a new context with the same fields is enough to get the ReviewDb
+    // provider to reopen the database.
+    Context old = current.get();
+    return set(
+        new Context(old.schemaFactory, old.session, old.user, old.created));
+  }
+
   /** Returns exactly one instance per command executed. */
   static final Scope REQUEST = new Scope() {
     @Override
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index 9eaf266..bce0b5a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -15,19 +15,20 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -46,36 +47,41 @@
 public class AccountCreator {
   private final Map<String, TestAccount> accounts;
 
-  private SchemaFactory<ReviewDb> reviewDbProvider;
-  private GroupCache groupCache;
-  private SshKeyCache sshKeyCache;
-  private AccountCache accountCache;
-  private AccountByEmailCache byEmailCache;
+  private final SchemaFactory<ReviewDb> reviewDbProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final GroupCache groupCache;
+  private final SshKeyCache sshKeyCache;
+  private final AccountCache accountCache;
+  private final AccountByEmailCache byEmailCache;
+  private final AccountIndexer indexer;
 
   @Inject
-  AccountCreator(SchemaFactory<ReviewDb> schema, GroupCache groupCache,
-      SshKeyCache sshKeyCache, AccountCache accountCache,
-      AccountByEmailCache byEmailCache) {
+  AccountCreator(SchemaFactory<ReviewDb> schema,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      GroupCache groupCache,
+      SshKeyCache sshKeyCache,
+      AccountCache accountCache,
+      AccountByEmailCache byEmailCache,
+      AccountIndexer indexer) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
+    this.authorizedKeys = authorizedKeys;
     this.groupCache = groupCache;
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
     this.byEmailCache = byEmailCache;
+    this.indexer = indexer;
   }
 
   public synchronized TestAccount create(String username, String email,
-      String fullName, String... groups)
-      throws OrmException, UnsupportedEncodingException, JSchException {
+      String fullName, String... groups) throws Exception {
     TestAccount account = accounts.get(username);
     if (account != null) {
       return account;
     }
     try (ReviewDb db = reviewDbProvider.open()) {
       Account.Id id = new Account.Id(db.nextAccountId());
-      KeyPair sshKey = genSshKey();
-      AccountSshKey key =
-          new AccountSshKey(new AccountSshKey.Id(id, 1), publicKey(sshKey, email));
+
       AccountExternalId extUser =
           new AccountExternalId(id, new AccountExternalId.Key(
               AccountExternalId.SCHEME_USERNAME, username));
@@ -94,8 +100,6 @@
       a.setPreferredEmail(email);
       db.accounts().insert(Collections.singleton(a));
 
-      db.accountSshKeys().insert(Collections.singleton(key));
-
       if (groups != null) {
         for (String n : groups) {
           AccountGroup.NameKey k = new AccountGroup.NameKey(n);
@@ -106,10 +110,15 @@
         }
       }
 
+      KeyPair sshKey = genSshKey();
+      authorizedKeys.addKey(id, publicKey(sshKey, email));
       sshKeyCache.evict(username);
+
       accountCache.evictByUsername(username);
       byEmailCache.evict(email);
 
+      indexer.index(id);
+
       account =
           new TestAccount(id, username, email, fullName, sshKey, httpPass);
       accounts.put(username, account);
@@ -117,35 +126,29 @@
     }
   }
 
-  public TestAccount create(String username, String group)
-      throws OrmException, UnsupportedEncodingException, JSchException {
+  public TestAccount create(String username, String group) throws Exception {
     return create(username, null, username, group);
   }
 
-  public TestAccount create(String username)
-      throws UnsupportedEncodingException, OrmException, JSchException {
+  public TestAccount create(String username) throws Exception {
     return create(username, null, username, (String[]) null);
   }
 
-  public TestAccount admin()
-      throws UnsupportedEncodingException, OrmException, JSchException {
+  public TestAccount admin() throws Exception {
     return create("admin", "admin@example.com", "Administrator",
       "Administrators");
   }
 
-  public TestAccount admin2()
-      throws UnsupportedEncodingException, OrmException, JSchException {
+  public TestAccount admin2() throws Exception {
     return create("admin2", "admin2@example.com", "Administrator2",
       "Administrators");
   }
 
-  public TestAccount user()
-      throws UnsupportedEncodingException, OrmException, JSchException {
+  public TestAccount user() throws Exception {
     return create("user", "user@example.com", "User");
   }
 
-  public TestAccount user2()
-      throws UnsupportedEncodingException, OrmException, JSchException {
+  public TestAccount user2() throws Exception {
     return create("user2", "user2@example.com", "User2");
   }
 
@@ -159,15 +162,15 @@
     return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
   }
 
-  private static KeyPair genSshKey() throws JSchException {
+  public static KeyPair genSshKey() throws JSchException {
     JSch jsch = new JSch();
     return KeyPair.genKeyPair(jsch, KeyPair.RSA);
   }
 
-  private static String publicKey(KeyPair sshKey, String comment)
+  public static String publicKey(KeyPair sshKey, String comment)
       throws UnsupportedEncodingException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     sshKey.writePublicKey(out, comment);
-    return out.toString("ASCII");
+    return out.toString(US_ASCII.name()).trim();
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
new file mode 100644
index 0000000..a325feb
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class AssertUtil {
+  public static <T> void assertPrefs(T actual, T expected,
+      String... fieldsToExclude)
+          throws IllegalArgumentException, IllegalAccessException {
+    Set<String> exludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
+    for (Field field : actual.getClass().getDeclaredFields()) {
+      if (exludedFields.contains(field.getName()) || skipField(field)) {
+        continue;
+      }
+      Object actualVal = field.get(actual);
+      Object expectedVal = field.get(expected);
+      if (field.getType().isAssignableFrom(Boolean.class)) {
+        if (actualVal == null) {
+          actualVal = false;
+        }
+        if (expectedVal == null) {
+          expectedVal = false;
+        }
+      }
+      assertWithMessage(field.getName()).that(actualVal).isEqualTo(expectedVal);
+    }
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
new file mode 100644
index 0000000..6cc8d3c
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -0,0 +1,220 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeMergedEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.ReviewerDeletedEvent;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class EventRecorder {
+  private final RegistrationHandle eventListenerRegistration;
+  private final Multimap<String, RefEvent> recordedEvents;
+
+  @Singleton
+  public static class Factory {
+    private final DynamicSet<UserScopedEventListener> eventListeners;
+    private final IdentifiedUser.GenericFactory userFactory;
+
+    @Inject
+    Factory(DynamicSet<UserScopedEventListener> eventListeners,
+        IdentifiedUser.GenericFactory userFactory) {
+      this.eventListeners = eventListeners;
+      this.userFactory = userFactory;
+    }
+
+    public EventRecorder create(TestAccount user) {
+      return new EventRecorder(eventListeners, userFactory.create(user.id));
+    }
+  }
+
+  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners,
+      final IdentifiedUser user) {
+    recordedEvents = LinkedListMultimap.create();
+
+    eventListenerRegistration = eventListeners.add(
+        new UserScopedEventListener() {
+          @Override
+          public void onEvent(Event e) {
+            if (e instanceof ReviewerDeletedEvent) {
+              recordedEvents.put(
+                  ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+            } else if (e instanceof RefEvent) {
+              RefEvent event = (RefEvent) e;
+              String key = refEventKey(event.getType(),
+                  event.getProjectNameKey().get(),
+                  event.getRefName());
+              recordedEvents.put(key, event);
+            }
+          }
+
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+        });
+  }
+
+  private static String refEventKey(String type, String project, String ref) {
+    return String.format("%s-%s-%s", type, project, ref);
+  }
+
+  private static class RefEventTransformer<T extends RefEvent>
+      implements Function<RefEvent, T> {
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public T apply(RefEvent e) {
+      return (T) e;
+    }
+  }
+
+  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(String project,
+      String refName, int expectedSize) {
+    String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<RefUpdatedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<RefUpdatedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(String project,
+      String branch, int expectedSize) {
+    String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeMergedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<ChangeMergedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(
+      int expectedSize) {
+    String key = ReviewerDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ReviewerDeletedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<ReviewerDeletedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch,
+      String... expected) throws Exception {
+    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null
+          ? ObjectId.zeroId().name()
+          : expected[i];
+      String newRev = expected[i+1] == null
+          ? ObjectId.zeroId().name()
+          : expected[i+1];
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch,
+      RevCommit... expected) throws Exception {
+    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null
+          ? ObjectId.zeroId().name()
+          : expected[i].name();
+      String newRev = expected[i+1] == null
+          ? ObjectId.zeroId().name()
+          : expected[i+1].name();
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertChangeMergedEvents(String project, String branch,
+      String... expected) throws Exception {
+    ImmutableList<ChangeMergedEvent> events = getChangeMergedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (ChangeMergedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      assertThat(event.newRev).isEqualTo(expected[i+1]);
+      i += 2;
+    }
+  }
+
+  public void assertReviewerDeletedEvents(String... expected) {
+    ImmutableList<ReviewerDeletedEvent> events =
+        getReviewerDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ReviewerDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.reviewer.get().email;
+      assertThat(reviewer).isEqualTo(expected[i+1]);
+      i += 2;
+    }
+  }
+
+  public void close() {
+    eventListenerRegistration.remove();
+  }
+}
\ No newline at end of file
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
index 58bb9f2..e0f9d4a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
@@ -23,5 +23,5 @@
 @Target({METHOD})
 @Retention(RUNTIME)
 public @interface GerritConfigs {
-  public GerritConfig[] value();
+  GerritConfig[] value();
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index 5b0d311..d1ec9e6 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -24,11 +24,12 @@
 import com.google.gerrit.pgm.Init;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
-import com.google.gerrit.server.index.ChangeSchemas;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.NoteDbChecker;
+import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -144,8 +145,7 @@
       cfg.setBoolean("index", "lucene", "testInmemory", true);
       cfg.setString("gitweb", null, "cgi", "");
       daemon.setEnableHttpd(desc.httpd());
-      daemon.setLuceneModule(new LuceneIndexModule(
-          ChangeSchemas.getLatest().getVersion(), 0, null));
+      daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
       daemon.setDatabaseForTesting(ImmutableList.<Module>of(
           new InMemoryTestingDatabaseModule(cfg)));
       daemon.start();
@@ -157,7 +157,7 @@
         public Void call() throws Exception {
           int rc = daemon.main(new String[] {
               "-d", site.getPath(),
-              "--headless", "--console-log", "--show-stack-trace"});
+              "--headless", "--console-log", "--show-stack-trace",});
           if (rc != 0) {
             System.err.println("Failed to start Gerrit daemon");
             serverStarted.reset();
@@ -178,7 +178,7 @@
     Init init = new Init();
     int rc = init.main(new String[] {
         "-d", tmp.getPath(), "--batch", "--no-auto-start",
-        "--skip-plugins"});
+        "--skip-plugins",});
     if (rc != 0) {
       throw new RuntimeException("Couldn't initialize site");
     }
@@ -289,13 +289,20 @@
   }
 
   void stop() throws Exception {
-    daemon.getLifecycleManager().stop();
-    if (daemonService != null) {
-      System.out.println("Gerrit Server Shutdown");
-      daemonService.shutdownNow();
-      daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+    try {
+      if (NoteDbMode.get().equals(NoteDbMode.CHECK)) {
+        testInjector.getInstance(NoteDbChecker.class)
+            .rebuildAndCheckAllChanges();
+      }
+    } finally {
+      daemon.getLifecycleManager().stop();
+      if (daemonService != null) {
+        System.out.println("Gerrit Server Shutdown");
+        daemonService.shutdownNow();
+        daemonService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+      }
+      RepositoryCache.clear();
     }
-    RepositoryCache.clear();
   }
 
   @Override
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
index e8f8925..f1700a7 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.common.base.Optional;
 import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
@@ -39,6 +41,7 @@
 import org.eclipse.jgit.transport.OpenSshConfig.Host;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.util.FS;
 
@@ -103,12 +106,18 @@
       Project.NameKey project, String uri) throws Exception {
     DfsRepositoryDescription desc =
         new DfsRepositoryDescription("clone of " + project.get());
+
+    FS fs = FS.detect();
+
+    // Avoid leaking user state into our tests.
+    fs.setUserHome(null);
+
     InMemoryRepository dest = new InMemoryRepository.Builder()
         .setRepositoryDescription(desc)
         // SshTransport depends on a real FS to read ~/.ssh/config, but
         // InMemoryRepository by default uses a null FS.
         // TODO(dborowitz): Remove when we no longer depend on SSH.
-        .setFS(FS.detect())
+        .setFS(fs)
         .build();
     Config cfg = dest.getConfig();
     cfg.setString("remote", "origin", "url", uri);
@@ -135,6 +144,11 @@
     fetch.call();
   }
 
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref)
+      throws GitAPIException {
+    return pushHead(testRepo, ref, false);
+  }
+
   public static PushResult pushHead(TestRepository<?> testRepo, String ref,
       boolean pushTags) throws GitAPIException {
     return pushHead(testRepo, ref, pushTags, false);
@@ -152,6 +166,20 @@
     return Iterables.getOnlyElement(r);
   }
 
+  public static void assertPushOk(PushResult result, String ref) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).named(rru.toString())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+  }
+
+  public static void assertPushRejected(PushResult result, String ref,
+      String expectedMessage) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).named(rru.toString())
+        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).isEqualTo(expectedMessage);
+  }
+
   public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id)
       throws IOException {
     RevCommit c = tr.getRevWalk().parseCommit(id);
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 634db7c..443c580 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
@@ -25,24 +27,32 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.schema.SchemaVersion;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryH2Type;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
+import com.google.inject.Key;
 import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 
-import org.apache.sshd.common.KeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 import org.eclipse.jgit.lib.Config;
 
+import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 
@@ -60,17 +70,26 @@
       .toInstance(cfg);
 
     // TODO(dborowitz): Use jimfs.
+    Path p = Paths.get(cfg.getString("gerrit", null, "tempSiteDir"));
     bind(Path.class)
       .annotatedWith(SitePath.class)
-      .toInstance(Paths.get(cfg.getString("gerrit", null, "tempSiteDir")));
+      .toInstance(p);
+    makeSiteDirs(p);
 
     bind(GitRepositoryManager.class)
-      .toInstance(new InMemoryRepositoryManager());
+      .to(InMemoryRepositoryManager.class);
+    bind(InMemoryRepositoryManager.class).in(SINGLETON);
 
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
     bind(DataSourceType.class).to(InMemoryH2Type.class);
+
+    bind(NotesMigration.class).to(TestNotesMigration.class);
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class))
+        .to(InMemoryDatabase.class);
     bind(InMemoryDatabase.class).in(SINGLETON);
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
-      .to(InMemoryDatabase.class);
 
     listener().to(CreateDatabase.class);
 
@@ -122,4 +141,14 @@
       mem.drop();
     }
   }
+
+  private static void makeSiteDirs(Path p) {
+    try {
+      Files.createDirectories(p.resolve("etc"));
+    } catch (IOException e) {
+      ProvisionException pe = new ProvisionException(e.getMessage());
+      pe.initCause(e);
+      throw pe;
+    }
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
index c16eed7..1d03acd 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.InProcessProtocol.Context;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -29,13 +30,14 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
-import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.ReceiveCommits;
 import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.util.RequestContext;
@@ -211,8 +213,9 @@
     private final Provider<ReviewDb> dbProvider;
     private final Provider<CurrentUser> userProvider;
     private final TagCache tagCache;
-    private final ChangeCache changeCache;
+    @Nullable private final SearchingChangeCacheImpl changeCache;
     private final ProjectControl.GenericFactory projectControlFactory;
+    private final ChangeNotes.Factory changeNotesFactory;
     private final TransferConfig transferConfig;
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final UploadValidators.Factory uploadValidatorsFactory;
@@ -223,8 +226,9 @@
         Provider<ReviewDb> dbProvider,
         Provider<CurrentUser> userProvider,
         TagCache tagCache,
-        ChangeCache changeCache,
+        @Nullable SearchingChangeCacheImpl changeCache,
         ProjectControl.GenericFactory projectControlFactory,
+        ChangeNotes.Factory changeNotesFactory,
         TransferConfig transferConfig,
         DynamicSet<PreUploadHook> preUploadHooks,
         UploadValidators.Factory uploadValidatorsFactory,
@@ -234,6 +238,7 @@
       this.tagCache = tagCache;
       this.changeCache = changeCache;
       this.projectControlFactory = projectControlFactory;
+      this.changeNotesFactory = changeNotesFactory;
       this.transferConfig = transferConfig;
       this.preUploadHooks = preUploadHooks;
       this.uploadValidatorsFactory = uploadValidatorsFactory;
@@ -259,11 +264,9 @@
         UploadPack up = new UploadPack(repo);
         up.setPackConfig(transferConfig.getPackConfig());
         up.setTimeout(transferConfig.getTimeout());
-
-        if (!ctl.allRefsAreVisible()) {
-          up.setAdvertiseRefsHook(new VisibleRefFilter(
-              tagCache, changeCache, repo, ctl, dbProvider.get(), true));
-        }
+        up.setAdvertiseRefsHook(new VisibleRefFilter(
+            tagCache, changeNotesFactory, changeCache, repo, ctl,
+            dbProvider.get(), true));
         List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
         hooks.add(uploadValidatorsFactory.create(
             ctl.getProject(), repo, "localhost-test"));
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
index f0b9f46..dde1875 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
@@ -41,16 +41,18 @@
 
   private static final String BUCKLC = "buck";
   private static final String BUCKOUT = "buck-out";
+  private static final String ECLIPSE = "eclipse-out";
 
   private Path gen;
-  private Path testSite;
   private Path pluginRoot;
   private Path pluginsSitePath;
   private Path pluginSubPath;
   private Path pluginSource;
-  private String pluginName;
   private boolean standalone;
 
+  protected String pluginName;
+  protected Path testSite;
+
   @Override
   protected void beforeTest(Description description) throws Exception {
     locatePaths();
@@ -58,9 +60,13 @@
     buildPluginJar();
     createTestSiteDirs();
     copyJarToTestSite();
+    beforeTestServerStarts();
     super.beforeTest(description);
   }
 
+  protected void beforeTestServerStarts() throws Exception {
+  }
+
   protected void setPluginConfigString(String name, String value)
       throws IOException, ConfigInvalidException {
     SitePaths sitePath = new SitePaths(testSite);
@@ -93,7 +99,7 @@
       if (subPath.endsWith("plugins")) {
         pluginsIdx = idx;
       }
-      if (subPath.endsWith(BUCKOUT)) {
+      if (subPath.endsWith(BUCKOUT) || subPath.endsWith(ECLIPSE)) {
         buckOutIdx = idx;
       }
       idx++;
@@ -121,7 +127,8 @@
         String gerritDirCandidate =
             partialPath.subpath(count - 2, count - 1).toString();
         if (pattern.matcher(gerritDirCandidate).matches()) {
-          if (partialPath.endsWith(gerritDirCandidate + "/" + BUCKOUT)) {
+          if (partialPath.endsWith(gerritDirCandidate + "/" + BUCKOUT) ||
+              partialPath.endsWith(gerritDirCandidate + "/" + ECLIPSE)) {
             return false;
           }
         }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
index 6dd26e9..d79e573 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -27,6 +28,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -36,7 +38,6 @@
 
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -44,6 +45,7 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 
 import java.util.List;
+import java.util.Map;
 
 public class PushOneCommit {
   public static final String SUBJECT = "test commit";
@@ -77,6 +79,12 @@
         ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
+        @Assisted("changeId") String changeId);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
         @Assisted("subject") String subject,
         @Assisted("fileName") String fileName,
         @Assisted("content") String content);
@@ -85,6 +93,13 @@
         ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
+        @Assisted String subject,
+        @Assisted Map<String, String> files);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
         @Assisted("subject") String subject,
         @Assisted("fileName") String fileName,
         @Assisted("content") String content,
@@ -117,8 +132,7 @@
   private final TestRepository<?> testRepo;
 
   private final String subject;
-  private final String fileName;
-  private final String content;
+  private final Map<String, String> files;
   private String changeId;
   private Tag tag;
   private boolean force;
@@ -143,6 +157,18 @@
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
+      @Assisted("changeId") String changeId) throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider,
+        db, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT, changeId);
+  }
+
+  @AssistedInject
+  PushOneCommit(ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content) throws Exception {
@@ -157,18 +183,43 @@
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
+      @Assisted String subject,
+      @Assisted Map<String, String> files) throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
+        subject, files, null);
+  }
+
+  @AssistedInject
+  PushOneCommit(ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content,
       @Nullable @Assisted("changeId") String changeId) throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
+        subject, ImmutableMap.of(fileName, content), changeId);
+  }
+
+  private PushOneCommit(ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      ReviewDb db,
+      PersonIdent i,
+      TestRepository<?> testRepo,
+      String subject,
+      Map<String, String> files,
+      String changeId) throws Exception {
     this.db = db;
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.queryProvider = queryProvider;
     this.subject = subject;
-    this.fileName = fileName;
-    this.content = content;
+    this.files = files;
     this.changeId = changeId;
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD")
@@ -178,7 +229,7 @@
     }
     commitBuilder.message(subject)
       .author(i)
-      .committer(new PersonIdent(i, testRepo.getClock()));
+      .committer(new PersonIdent(i, testRepo.getDate()));
   }
 
   public void setParents(List<RevCommit> parents) throws Exception {
@@ -188,13 +239,22 @@
     }
   }
 
+  public void setParent(RevCommit parent) throws Exception {
+    commitBuilder.noParents();
+    commitBuilder.parent(parent);
+  }
+
   public Result to(String ref) throws Exception {
-    commitBuilder.add(fileName, content);
+    for (Map.Entry<String, String> e : files.entrySet()) {
+      commitBuilder.add(e.getKey(), e.getValue());
+    }
     return execute(ref);
   }
 
   public Result rm(String ref) throws Exception {
-    commitBuilder.rm(fileName);
+    for (String fileName : files.keySet()) {
+      commitBuilder.rm(fileName);
+    }
     return execute(ref);
   }
 
@@ -227,6 +287,10 @@
     this.force = force;
   }
 
+  public void noParents() {
+    commitBuilder.noParents();
+  }
+
   public class Result {
     private final String ref;
     private final PushResult result;
@@ -258,17 +322,13 @@
       return changeId;
     }
 
-    public ObjectId getCommitId() {
-      return commit;
-    }
-
     public RevCommit getCommit() {
       return commit;
     }
 
     public void assertChange(Change.Status expectedStatus,
         String expectedTopic, TestAccount... expectedReviewers)
-        throws OrmException {
+        throws OrmException, NoSuchChangeException {
       Change c = getChange().change();
       assertThat(c.getSubject()).isEqualTo(resSubj);
       assertThat(c.getStatus()).isEqualTo(expectedStatus);
@@ -277,9 +337,10 @@
     }
 
     private void assertReviewers(Change c, TestAccount... expectedReviewers)
-        throws OrmException {
-      Iterable<Account.Id> actualIds =
-          approvalsUtil.getReviewers(db, notesFactory.create(c)).values();
+        throws OrmException, NoSuchChangeException {
+      Iterable<Account.Id> actualIds = approvalsUtil
+          .getReviewers(db, notesFactory.createChecked(db, c))
+          .all();
       assertThat(actualIds).containsExactlyElementsIn(
           Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
@@ -310,10 +371,14 @@
     public void assertMessage(String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(message(refUpdate).toLowerCase())
-        .named(message(refUpdate))
         .contains(expectedMessage.toLowerCase());
     }
 
+    public String getMessage() {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      return message(refUpdate);
+    }
+
     private String message(RemoteRefUpdate refUpdate) {
       StringBuilder b = new StringBuilder();
       if (refUpdate.getMessage() != null) {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
index 261b894..d76cb81 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import org.apache.http.HttpStatus;
+
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -36,4 +39,51 @@
     }
     return reader;
   }
+
+  public void assertStatus(int status) throws Exception {
+    assert_()
+        .withFailureMessage(String.format("Expected status code %d", status))
+        .that(getStatusCode())
+        .isEqualTo(status);
+  }
+
+  public void assertOK() throws Exception {
+    assertStatus(HttpStatus.SC_OK);
+  }
+
+  public void assertNotFound() throws Exception {
+    assertStatus(HttpStatus.SC_NOT_FOUND);
+  }
+
+  public void assertConflict() throws Exception {
+    assertStatus(HttpStatus.SC_CONFLICT);
+  }
+
+  public void assertForbidden() throws Exception {
+    assertStatus(HttpStatus.SC_FORBIDDEN);
+  }
+
+  public void assertNoContent() throws Exception {
+    assertStatus(HttpStatus.SC_NO_CONTENT);
+  }
+
+  public void assertBadRequest() throws Exception {
+    assertStatus(HttpStatus.SC_BAD_REQUEST);
+  }
+
+  public void assertUnprocessableEntity() throws Exception {
+    assertStatus(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+  }
+
+  public void assertMethodNotAllowed() throws Exception {
+    assertStatus(HttpStatus.SC_METHOD_NOT_ALLOWED);
+  }
+
+  public void assertCreated() throws Exception {
+    assertStatus(HttpStatus.SC_CREATED);
+  }
+
+  public void assertPreconditionFailed() throws Exception {
+    assertStatus(HttpStatus.SC_PRECONDITION_FAILED);
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
index 4b22d0a..9c59e10 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
@@ -28,9 +28,7 @@
 import org.apache.http.entity.StringEntity;
 import org.apache.http.message.BasicHeader;
 
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStream;
 
 public class RestSession extends HttpSession {
 
@@ -56,6 +54,10 @@
     return execute(get);
   }
 
+  public RestResponse head(String endPoint) throws IOException {
+    return execute(Request.Head(url + "/a" + endPoint));
+  }
+
   public RestResponse put(String endPoint) throws IOException {
     return put(endPoint, null);
   }
@@ -113,33 +115,4 @@
   public RestResponse delete(String endPoint) throws IOException {
     return execute(Request.Delete(url + "/a" + endPoint));
   }
-
-  public RestResponse head(String endPoint) throws IOException {
-    return execute(Request.Head(url + "/a" + endPoint));
-  }
-
-  public static RawInput newRawInput(String content) {
-    return newRawInput(content.getBytes(UTF_8));
-  }
-
-  public static RawInput newRawInput(final byte[] bytes) {
-    Preconditions.checkNotNull(bytes);
-    Preconditions.checkArgument(bytes.length > 0);
-    return new RawInput() {
-      @Override
-      public InputStream getInputStream() throws IOException {
-        return new ByteArrayInputStream(bytes);
-      }
-
-      @Override
-      public String getContentType() {
-        return "application/octet-stream";
-      }
-
-      @Override
-      public long getContentLength() {
-        return bytes.length;
-      }
-    };
-  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
index 4a6d22d..7f08b6f 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 
 import com.jcraft.jsch.KeyPair;
 
@@ -58,6 +59,7 @@
   public final Account.Id id;
   public final String username;
   public final String email;
+  public final Address emailAddress;
   public final String fullName;
   public final KeyPair sshKey;
   public final String httpPassword;
@@ -67,6 +69,7 @@
     this.id = id;
     this.username = username;
     this.email = email;
+    this.emailAddress = new Address(fullName, email);
     this.fullName = fullName;
     this.sshKey = sshKey;
     this.httpPassword = httpPassword;
diff --git a/gerrit-acceptance-tests/BUCK b/gerrit-acceptance-tests/BUCK
index 0a39ea7..d5d0b0d 100644
--- a/gerrit-acceptance-tests/BUCK
+++ b/gerrit-acceptance-tests/BUCK
@@ -32,7 +32,8 @@
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
+    '//lib/log:api',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/mina:sshd',
   ],
   visibility = [
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
new file mode 100644
index 0000000..2ec7a05
--- /dev/null
+++ b/gerrit-acceptance-tests/BUILD
@@ -0,0 +1,42 @@
+load('//tools/bzl:java.bzl', 'java_library2')
+
+java_library2(
+  name = 'lib',
+  srcs = glob(['src/test/java/com/google/gerrit/acceptance/*.java']),
+  exported_deps = [
+    '//gerrit-acceptance-framework:lib',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-gpg:testutil',
+    '//gerrit-launcher:launcher',
+    '//gerrit-lucene:lucene',
+    '//gerrit-httpd:httpd',
+    '//gerrit-pgm:init',
+    '//gerrit-pgm:pgm',
+    '//gerrit-pgm:util',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-server:testutil',
+    '//gerrit-server/src/main/prolog:common',
+    '//gerrit-sshd:sshd',
+
+    '//lib:args4j',
+    '//lib:gson',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm',
+    '//lib:h2',
+    '//lib:jsch',
+    '//lib:servlet-api-3_1-without-neverlink',
+
+    '//lib/bouncycastle:bcpg',
+    '//lib/bouncycastle:bcprov',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+    '//lib/log:api',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/mina:sshd',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
index 6ead346..29aadc1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
@@ -16,9 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.account.PutUsername;
-
-import org.apache.http.HttpStatus;
 import org.junit.After;
 import org.junit.Test;
 
@@ -26,16 +23,11 @@
 public class SandboxTest extends AbstractDaemonTest {
   @After
   public void addUser() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
-    in.username = "sandboxuser";
-    RestResponse r =
-        adminSession.put("/accounts/sandboxuser", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CREATED);
+    gApi.accounts().create("sandboxuser");
   }
 
   private void testUserNotPresent() throws Exception {
-    RestResponse r = adminSession.get("/accounts/sandboxuser");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
index f4d156c..2f50480 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
@@ -29,15 +29,15 @@
   Config serverConfig;
 
   @Test
-  @GerritConfig(name="x.y", value="z")
+  @GerritConfig(name = "x.y", value = "z")
   public void testOne() {
     assertThat(serverConfig.getString("x", null, "y")).isEqualTo("z");
   }
 
   @Test
   @GerritConfigs({
-      @GerritConfig(name="x.y", value="z"),
-      @GerritConfig(name="a.b", value="c")
+      @GerritConfig(name = "x.y", value = "z"),
+      @GerritConfig(name = "a.b", value = "c")
   })
   public void testMultiple() {
     assertThat(serverConfig.getString("x", null, "y")).isEqualTo("z");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 39296d0..6ad20ff 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -17,24 +17,39 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.gpg.testutil.TestKeys.allValidKeys;
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithExpiration;
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
+import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountCreator;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -44,27 +59,40 @@
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushCertificateIdent;
 import org.junit.After;
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -97,12 +125,14 @@
     db.accountExternalIds().delete(getExternalIds(admin));
     db.accountExternalIds().delete(getExternalIds(user));
     db.accountExternalIds().insert(savedExternalIds);
+    accountCache.evict(admin.getId());
+    accountCache.evict(user.getId());
   }
 
   @After
   public void clearPublicKeyStore() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.getRef(REFS_GPG_KEYS);
+      Ref ref = repo.exactRef(REFS_GPG_KEYS);
       if (ref != null) {
         RefUpdate ru = repo.updateRef(REFS_GPG_KEYS);
         ru.setForceUpdate(true);
@@ -111,9 +141,9 @@
     }
   }
 
-  private List<AccountExternalId> getExternalIds(TestAccount account)
+  private Collection<AccountExternalId> getExternalIds(TestAccount account)
       throws Exception {
-    return db.accountExternalIds().byAccount(account.getId()).toList();
+    return accountCache.get(account.getId()).getExternalIds();
   }
 
   @After
@@ -141,14 +171,31 @@
   }
 
   @Test
+  public void getByIntId() throws Exception {
+    AccountInfo info = gApi
+        .accounts()
+        .id("admin")
+        .get();
+    AccountInfo infoByIntId = gApi
+        .accounts()
+        .id(info._accountId)
+        .get();
+    assertThat(info.name).isEqualTo(infoByIntId.name);
+  }
+
+  @Test
   public void self() throws Exception {
     AccountInfo info = gApi
         .accounts()
         .self()
         .get();
-    assertThat(info.name).isEqualTo("Administrator");
-    assertThat(info.email).isEqualTo("admin@example.com");
-    assertThat(info.username).isEqualTo("admin");
+    assertUser(info, admin);
+
+    info = gApi
+        .accounts()
+        .id("self")
+        .get();
+    assertUser(info, admin);
   }
 
   @Test
@@ -158,11 +205,139 @@
     gApi.accounts()
         .self()
         .starChange(triplet);
-    assertThat(info(triplet).starred).isTrue();
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).contains(DEFAULT_LABEL);
+
     gApi.accounts()
         .self()
         .unstarChange(triplet);
-    assertThat(info(triplet).starred).isNull();
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).isNull();
+  }
+
+  @Test
+  public void starUnstarChangeWithLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+    assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
+
+    gApi.accounts().self().setStars(triplet,
+        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars)
+        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    assertThat(gApi.accounts().self().getStars(triplet))
+        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    List<ChangeInfo> starredChanges =
+        gApi.accounts().self().getStarredChanges();
+    assertThat(starredChanges).hasSize(1);
+    ChangeInfo starredChange = starredChanges.get(0);
+    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+    assertThat(starredChange.starred).isTrue();
+    assertThat(starredChange.stars)
+        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+
+    gApi.accounts().self().setStars(triplet,
+        new StarsInput(ImmutableSet.of("yellow"),
+            ImmutableSet.of(DEFAULT_LABEL, "blue")));
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).containsExactly("red", "yellow").inOrder();
+    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly(
+        "red", "yellow").inOrder();
+    starredChanges = gApi.accounts().self().getStarredChanges();
+    assertThat(starredChanges).hasSize(1);
+    starredChange = starredChanges.get(0);
+    assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
+    assertThat(starredChange.starred).isNull();
+    assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to get stars of another account");
+    gApi.accounts().id(Integer.toString((admin.id.get()))).getStars(triplet);
+  }
+
+  @Test
+  public void starWithInvalidLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "invalid labels: another invalid label, invalid label");
+    gApi.accounts().self().setStars(triplet,
+        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue",
+            "another invalid label")));
+  }
+
+  @Test
+  public void starWithDefaultAndIgnoreLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("The labels " + DEFAULT_LABEL
+        + " and " + IGNORE_LABEL + " are mutually exclusive."
+        + " Only one of them can be set.");
+    gApi.accounts().self().setStars(triplet,
+        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+  }
+
+  @Test
+  public void ignoreChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+
+    TestAccount user2 = accounts.user2();
+    in = new AddReviewerInput();
+    in.reviewer = user2.email;
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(),
+        new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .abandon();
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+  }
+
+  @Test
+  public void addReviewerToIgnoredChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(),
+        new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+    setApiUser(admin);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message message = messages.get(0);
+    assertThat(message.rcpt()).containsExactly(user.emailAddress);
+    assertMailFrom(message, admin.email);
   }
 
   @Test
@@ -206,6 +381,145 @@
   }
 
   @Test
+  public void fetchUserBranch() throws Exception {
+    // change something in the user preferences to ensure that the user branch
+    // is created
+    setApiUser(user);
+    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
+    input.changesPerPage =
+        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    gApi.accounts().self().setPreferences(input);
+
+    TestRepository<InMemoryRepository> allUsersRepo =
+        cloneProject(allUsers, user);
+    String userRefName = RefNames.refsUsers(user.id);
+
+    // remove default READ permissions
+    ProjectConfig cfg = projectCache.checkedGet(allUsers).getConfig();
+    cfg.getAccessSection(
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
+        .remove(new Permission(Permission.READ));
+    saveProjectConfig(allUsers, cfg);
+
+    // deny READ permission that is inherited from All-Projects
+    deny(allUsers, Permission.READ, ANONYMOUS_USERS, RefNames.REFS + "*");
+
+    // fetching user branch without READ permission fails
+    try {
+      fetch(allUsersRepo, userRefName + ":userRef");
+      Assert.fail(
+          "user branch is visible although no READ permission is granted");
+    } catch (TransportException e) {
+      // expected because no READ granted on user branch
+    }
+
+    // allow each user to read its own user branch
+    grant(Permission.READ, allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", false,
+        REGISTERED_USERS);
+
+    // fetch user branch using refs/users/YY/XXXXXXX
+    fetch(allUsersRepo, userRefName + ":userRef");
+    Ref userRef = allUsersRepo.getRepository().exactRef("userRef");
+    assertThat(userRef).isNotNull();
+
+    // fetch user branch using refs/users/self
+    fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
+    Ref userSelfRef =
+        allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
+    assertThat(userSelfRef).isNotNull();
+    assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
+
+    // fetching user branch of another user fails
+    String otherUserRefName = RefNames.refsUsers(admin.id);
+    exception.expect(TransportException.class);
+    exception.expectMessage(
+        "Remote does not have " + otherUserRefName + " available for fetch.");
+    fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
+  }
+
+  @Test
+  public void pushToUserBranch() throws Exception {
+    // change something in the user preferences to ensure that the user branch
+    // is created
+    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
+    input.changesPerPage =
+        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    gApi.accounts().self().setPreferences(input);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
+
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+  }
+
+  @Test
+  public void pushToUserBranchForReview() throws Exception {
+    // change something in the user preferences to ensure that the user branch
+    // is created
+    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
+    input.changesPerPage =
+        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    gApi.accounts().self().setPreferences(input);
+
+    String userRefName = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRefName + ":userRef");
+    allUsersRepo.reset("userRef");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
+    r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
+    // change something in the user preferences to ensure that the user branch
+    // is created
+    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
+    input.changesPerPage =
+        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    gApi.accounts().self().setPreferences(input);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config wc = new Config();
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
+        WatchConfig.NotifyValue
+            .create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
+        "Add project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+
+    String invalidNotifyValue = "]invalid[";
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
+        invalidNotifyValue);
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
+        "Add invalid project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid watch configuration");
+    r.assertMessage(String.format(
+        "%s: Invalid project watch of account %d for project %s: %s",
+        WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(),
+        invalidNotifyValue));
+  }
+
+  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
@@ -245,6 +559,7 @@
         user.getId(), new AccountExternalId.Key("foo:myId"));
 
     db.accountExternalIds().insert(Collections.singleton(extId));
+    accountCache.evict(user.getId());
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
@@ -320,6 +635,70 @@
         ImmutableList.of(key2.getKeyIdString()));
   }
 
+  @Test
+  public void sshKeys() throws Exception {
+    // The test account should initially have exactly one ssh key
+    List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(1);
+    assertSequenceNumbers(info);
+    SshKeyInfo key = info.get(0);
+    String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
+    assertThat(key.sshPublicKey).isEqualTo(inital);
+
+    // Add a new key
+    String newKey = AccountCreator.publicKey(
+        AccountCreator.genSshKey(), admin.email);
+    gApi.accounts().self().addSshKey(newKey);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+
+    // Add an existing key (the request succeeds, but the key isn't added again)
+    gApi.accounts().self().addSshKey(inital);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+
+    // Add another new key
+    String newKey2 = AccountCreator.publicKey(
+        AccountCreator.genSshKey(), admin.email);
+    gApi.accounts().self().addSshKey(newKey2);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(3);
+    assertSequenceNumbers(info);
+
+    // Delete second key
+    gApi.accounts().self().deleteSshKey(2);
+    info = gApi.accounts().self().listSshKeys();
+    assertThat(info).hasSize(2);
+    assertThat(info.get(0).seq).isEqualTo(1);
+    assertThat(info.get(1).seq).isEqualTo(3);
+  }
+
+  // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
+  @Test
+  public void reindexPermissions() throws Exception {
+    // admin can reindex any account
+    setApiUser(admin);
+    gApi.accounts().id(user.username).index();
+
+    // user can reindex own account
+    setApiUser(user);
+    gApi.accounts().self().index();
+
+    // user cannot reindex any account
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to index account");
+    gApi.accounts().id(admin.username).index();
+  }
+
+  private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
+    int seq = 1;
+    for (SshKeyInfo key : sshKeys) {
+      assertThat(key.seq).isEqualTo(seq++);
+    }
+  }
+
   private PGPPublicKey getOnlyKeyFromStore(TestKey key) throws Exception {
     try (PublicKeyStore store = publicKeyStoreProvider.get()) {
       Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
@@ -434,4 +813,11 @@
         ImmutableList.of(armored),
         ImmutableList.<String> of());
   }
+
+  private void assertUser(AccountInfo info, TestAccount account)
+      throws Exception {
+    assertThat(info.name).isEqualTo(account.fullName);
+    assertThat(info.email).isEqualTo(account.email);
+    assertThat(info.username).isEqualTo(account.username);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
new file mode 100644
index 0000000..8cd696c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.TestTimeUtil;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.List;
+
+public class AgreementsIT extends AbstractDaemonTest {
+  private ContributorAgreement ca;
+  private ContributorAgreement ca2;
+
+  @ConfigSuite.Config
+  public static Config enableAgreementsConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("auth", null, "contributorAgreements", true);
+    return cfg;
+  }
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    String g = createGroup("cla-test-group");
+    GroupApi groupApi = gApi.groups().id(g);
+    groupApi.description("CLA test group");
+    AccountGroup caGroup = groupCache.get(
+        new AccountGroup.UUID(groupApi.detail().id));
+    GroupReference groupRef = GroupReference.forGroup(caGroup);
+    PermissionRule rule = new PermissionRule(groupRef);
+    rule.setAction(PermissionRule.Action.ALLOW);
+    ca = new ContributorAgreement("cla-test");
+    ca.setDescription("description");
+    ca.setAgreementUrl("agreement-url");
+    ca.setAutoVerify(groupRef);
+    ca.setAccepted(ImmutableList.of(rule));
+
+    ca2 = new ContributorAgreement("cla-test-no-auto-verify");
+    ca2.setDescription("description");
+    ca2.setAgreementUrl("agreement-url");
+
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    cfg.replace(ca);
+    cfg.replace(ca2);
+    saveProjectConfig(allProjects, cfg);
+    setApiUser(user);
+  }
+
+  @Test
+  public void signNonExistingAgreement() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("contributor agreement not found");
+    gApi.accounts().self().signAgreement("does-not-exist");
+  }
+
+  @Test
+  public void signAgreementNoAutoVerify() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot enter a non-autoVerify agreement");
+    gApi.accounts().self().signAgreement(ca2.getName());
+  }
+
+  @Test
+  public void signAgreement() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // List of agreements is initially empty
+    List<AgreementInfo> result = gApi.accounts().self().listAgreements();
+    assertThat(result).isEmpty();
+
+    // Sign the agreement
+    gApi.accounts().self().signAgreement(ca.getName());
+
+    // Explicitly reset the user to force a new request context
+    setApiUser(user);
+
+    // Verify that the agreement was signed
+    result = gApi.accounts().self().listAgreements();
+    assertThat(result).hasSize(1);
+    AgreementInfo info = result.get(0);
+    assertThat(info.name).isEqualTo(ca.getName());
+    assertThat(info.description).isEqualTo(ca.getDescription());
+    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+
+    // Signing the same agreement again has no effect
+    gApi.accounts().self().signAgreement(ca.getName());
+    result = gApi.accounts().self().listAgreements();
+    assertThat(result).hasSize(1);
+  }
+
+  @Test
+  public void agreementsDisabledSign() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isFalse();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("contributor agreements disabled");
+    gApi.accounts().self().signAgreement(ca.getName());
+  }
+
+  @Test
+  public void agreementsDisabledList() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isFalse();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("contributor agreements disabled");
+    gApi.accounts().self().listAgreements();
+  }
+
+  @Test
+  public void revertChangeWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    setApiUser(admin);
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert is not allowed when CLA is required but not signed
+    setApiUser(user);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    exception.expect(AuthException.class);
+    exception.expectMessage("A Contributor Agreement must be completed");
+    gApi.changes().id(change.changeId).revert();
+  }
+
+  @Test
+  public void cherrypickChangeWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a new branch
+    setApiUser(admin);
+    BranchInfo dest = gApi.projects().name(project.get())
+        .branch("cherry-pick-to").create(new BranchInput()).get();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Cherry-pick is not allowed when CLA is required but not signed
+    setApiUser(user);
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    CherryPickInput in = new CherryPickInput();
+    in.destination = dest.ref;
+    in.message = change.subject;
+    exception.expect(AuthException.class);
+    exception.expectMessage("A Contributor Agreement must be completed");
+    gApi.changes().id(change.changeId).current().cherryPick(in);
+  }
+
+  @Test
+  public void createChangeRespectsCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    gApi.changes().create(newChangeInput());
+
+    // Create a change is not allowed when CLA is required but not signed
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    try {
+      gApi.changes().create(newChangeInput());
+      fail("Expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e.getMessage()).contains(
+          "A Contributor Agreement must be completed");
+    }
+
+    // Sign the agreement
+    gApi.accounts().self().signAgreement(ca.getName());
+
+    // Explicitly reset the user to force a new request context
+    setApiUser(user);
+
+    // Create a change succeeds after signing the agreement
+    gApi.changes().create(newChangeInput());
+  }
+
+  private ChangeInput newChangeInput() {
+    ChangeInput in = new ChangeInput();
+    in.branch = "master";
+    in.subject = "test";
+    in.project = project.get();
+    return in;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK
index 814dcf4..4e3c880 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-account',
+  group = 'api_account',
   srcs = glob(['*IT.java']),
   labels = ['api'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
new file mode 100644
index 0000000..9935eeb
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_account',
+  srcs = glob(['*IT.java']),
+  labels = ['api'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
new file mode 100644
index 0000000..9236176
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.client.Theme;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.After;
+import org.junit.Test;
+
+@NoHttpd
+public class DiffPreferencesIT extends AbstractDaemonTest {
+  @Inject
+  private AllUsersName allUsers;
+
+  @After
+  public void cleanUp() throws Exception {
+    gApi.accounts().id(admin.getId().toString())
+        .setDiffPreferences(DiffPreferencesInfo.defaults());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    try {
+      fetch(allUsersRepo, RefNames.REFS_USERS_DEFAULT + ":defaults");
+    } catch (TransportException e) {
+      if (e.getMessage().equals("Remote does not have "
+          + RefNames.REFS_USERS_DEFAULT + " available for fetch.")) {
+        return;
+      }
+      throw e;
+    }
+    allUsersRepo.reset("defaults");
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
+        "Delete default preferences", VersionedAccountPreferences.PREFERENCES,
+        "");
+    push.rm(RefNames.REFS_USERS_DEFAULT).assertOkStatus();
+  }
+
+  @Test
+  public void getDiffPreferences() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    DiffPreferencesInfo o = gApi.accounts()
+        .id(admin.getId().toString())
+        .getDiffPreferences();
+    assertPrefs(o, d);
+  }
+
+  @Test
+  public void setDiffPreferences() throws Exception {
+    DiffPreferencesInfo i = DiffPreferencesInfo.defaults();
+
+    // change all default values
+    i.context *= -1;
+    i.tabSize *= -1;
+    i.lineLength *= -1;
+    i.cursorBlinkRate = 500;
+    i.theme = Theme.MIDNIGHT;
+    i.ignoreWhitespace = Whitespace.IGNORE_ALL;
+    i.expandAllComments ^= true;
+    i.intralineDifference ^= true;
+    i.manualReview ^= true;
+    i.retainHeader ^= true;
+    i.showLineEndings ^= true;
+    i.showTabs ^= true;
+    i.showWhitespaceErrors ^= true;
+    i.skipDeleted ^= true;
+    i.skipUnchanged ^= true;
+    i.skipUncommented ^= true;
+    i.syntaxHighlighting ^= true;
+    i.hideTopMenu ^= true;
+    i.autoHideDiffTableHeader ^= true;
+    i.hideLineNumbers ^= true;
+    i.renderEntireFile ^= true;
+    i.hideEmptyPane ^= true;
+    i.matchBrackets ^= true;
+    i.lineWrapping ^= true;
+
+    DiffPreferencesInfo o = gApi.accounts()
+        .id(admin.getId().toString())
+        .setDiffPreferences(i);
+    assertPrefs(o, i);
+
+    // Partially fill input record
+    i = new DiffPreferencesInfo();
+    i.tabSize = 42;
+    DiffPreferencesInfo a = gApi.accounts()
+        .id(admin.getId().toString())
+        .setDiffPreferences(i);
+    assertPrefs(a, o, "tabSize");
+    assertThat(a.tabSize).isEqualTo(42);
+  }
+
+  @Test
+  public void getDiffPreferencesWithConfiguredDefaults() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    int newLineLength = d.lineLength + 10;
+    int newTabSize = d.tabSize * 2;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = newLineLength;
+    update.tabSize = newTabSize;
+    gApi.config().server().setDefaultDiffPreferences(update);
+
+    DiffPreferencesInfo o = gApi.accounts()
+        .id(admin.getId().toString())
+        .getDiffPreferences();
+
+    // assert configured defaults
+    assertThat(o.lineLength).isEqualTo(newLineLength);
+    assertThat(o.tabSize).isEqualTo(newTabSize);
+
+    // assert hard-coded defaults
+    assertPrefs(o, d, "lineLength", "tabSize");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
new file mode 100644
index 0000000..9eb6918
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.KeyMapType;
+import com.google.gerrit.extensions.client.Theme;
+
+import org.junit.Test;
+
+@NoHttpd
+public class EditPreferencesIT extends AbstractDaemonTest {
+  @Test
+  public void getSetEditPreferences() throws Exception {
+    EditPreferencesInfo out = gApi.accounts()
+        .id(admin.getId().toString())
+        .getEditPreferences();
+
+    assertThat(out.lineLength).isEqualTo(100);
+    assertThat(out.indentUnit).isEqualTo(2);
+    assertThat(out.tabSize).isEqualTo(8);
+    assertThat(out.cursorBlinkRate).isEqualTo(0);
+    assertThat(out.hideTopMenu).isNull();
+    assertThat(out.showTabs).isTrue();
+    assertThat(out.showWhitespaceErrors).isNull();
+    assertThat(out.syntaxHighlighting).isTrue();
+    assertThat(out.hideLineNumbers).isNull();
+    assertThat(out.matchBrackets).isTrue();
+    assertThat(out.lineWrapping).isNull();
+    assertThat(out.autoCloseBrackets).isNull();
+    assertThat(out.showBase).isNull();
+    assertThat(out.theme).isEqualTo(Theme.DEFAULT);
+    assertThat(out.keyMapType).isEqualTo(KeyMapType.DEFAULT);
+
+    // change some default values
+    out.lineLength = 80;
+    out.indentUnit = 4;
+    out.tabSize = 4;
+    out.cursorBlinkRate = 500;
+    out.hideTopMenu = true;
+    out.showTabs = false;
+    out.showWhitespaceErrors = true;
+    out.syntaxHighlighting = false;
+    out.hideLineNumbers = true;
+    out.matchBrackets = false;
+    out.lineWrapping = true;
+    out.autoCloseBrackets = true;
+    out.showBase = true;
+    out.theme = Theme.TWILIGHT;
+    out.keyMapType = KeyMapType.EMACS;
+
+    EditPreferencesInfo info = gApi.accounts()
+        .id(admin.getId().toString())
+        .setEditPreferences(out);
+
+    assertEditPreferences(info, out);
+
+    // Partially filled input record
+    EditPreferencesInfo in = new EditPreferencesInfo();
+    in.tabSize = 42;
+
+    info = gApi.accounts()
+        .id(admin.getId().toString())
+        .setEditPreferences(in);
+
+    out.tabSize = in.tabSize;
+    assertEditPreferences(info, out);
+  }
+
+  private void assertEditPreferences(EditPreferencesInfo out,
+      EditPreferencesInfo in) throws Exception {
+    assertThat(out.lineLength).isEqualTo(in.lineLength);
+    assertThat(out.indentUnit).isEqualTo(in.indentUnit);
+    assertThat(out.tabSize).isEqualTo(in.tabSize);
+    assertThat(out.cursorBlinkRate).isEqualTo(in.cursorBlinkRate);
+    assertThat(out.hideTopMenu).isEqualTo(in.hideTopMenu);
+    assertThat(out.showTabs).isNull();
+    assertThat(out.showWhitespaceErrors).isEqualTo(in.showWhitespaceErrors);
+    assertThat(out.syntaxHighlighting).isNull();
+    assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
+    assertThat(out.matchBrackets).isNull();
+    assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
+    assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
+    assertThat(out.showBase).isEqualTo(in.showBase);
+    assertThat(out.theme).isEqualTo(in.theme);
+    assertThat(out.keyMapType).isEqualTo(in.keyMapType);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
new file mode 100644
index 0000000..f45bfbbe
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+@NoHttpd
+public class GeneralPreferencesIT extends AbstractDaemonTest {
+  @Inject
+  private AllUsersName allUsers;
+
+  private TestAccount user42;
+
+  @Before
+  public void setUp() throws Exception {
+    String name = name("user42");
+    user42 = accounts.create(name, name + "@example.com", "User 42");
+  }
+
+  @After
+  public void cleanUp() throws Exception {
+    gApi.accounts().id(user42.getId().toString())
+        .setPreferences(GeneralPreferencesInfo.defaults());
+
+    try (Repository git = repoManager.openRepository(allUsers)) {
+      if (git.exactRef(RefNames.REFS_USERS_DEFAULT) != null) {
+        RefUpdate u = git.updateRef(RefNames.REFS_USERS_DEFAULT);
+        u.setForceUpdate(true);
+        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      }
+    }
+    accountCache.evictAll();
+  }
+
+  @Test
+  public void getAndSetPreferences() throws Exception {
+    GeneralPreferencesInfo o = gApi.accounts()
+        .id(user42.id.toString())
+        .getPreferences();
+    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my");
+    assertThat(o.my).hasSize(7);
+
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+
+    // change all default values
+    i.changesPerPage *= -1;
+    i.showSiteHeader ^= true;
+    i.useFlashClipboard ^= true;
+    i.downloadCommand = DownloadCommand.REPO_DOWNLOAD;
+    i.dateFormat = DateFormat.US;
+    i.timeFormat = TimeFormat.HHMM_24;
+    i.emailStrategy = EmailStrategy.DISABLED;
+    i.relativeDateInChangeTable ^= true;
+    i.sizeBarInChangeTable ^= true;
+    i.legacycidInChangeTable ^= true;
+    i.muteCommonPathPrefixes ^= true;
+    i.signedOffBy ^= true;
+    i.reviewCategoryStrategy = ReviewCategoryStrategy.ABBREV;
+    i.diffView = DiffView.UNIFIED_DIFF;
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem("name", "url"));
+    i.urlAliases = new HashMap<>();
+    i.urlAliases.put("foo", "bar");
+
+    o = gApi.accounts()
+        .id(user42.getId().toString())
+        .setPreferences(i);
+    assertPrefs(o, i, "my");
+    assertThat(o.my).hasSize(1);
+  }
+
+  @Test
+  public void getPreferencesWithConfiguredDefaults() throws Exception {
+    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
+    int newChangesPerPage = d.changesPerPage * 2;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.changesPerPage = newChangesPerPage;
+    gApi.config().server().setDefaultPreferences(update);
+
+    GeneralPreferencesInfo o = gApi.accounts()
+        .id(user42.getId().toString())
+        .getPreferences();
+
+    // assert configured defaults
+    assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
+
+    // assert hard-coded defaults
+    assertPrefs(o, d, "my", "changesPerPage");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK
index 5db2054..e8963be 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-change',
+  group = 'api_change',
   srcs = glob(['*IT.java']),
   labels = ['api'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
new file mode 100644
index 0000000..2502cad
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_change',
+  srcs = glob(['*IT.java']),
+  labels = ['api'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d92a887..15ac366 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -15,56 +15,110 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.Util.blockLabel;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.fail;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gerrit.testutil.TestTimeUtil;
 
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.EnumSet;
+import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
+import java.util.Map;
 
 @NoHttpd
 public class ChangeIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
 
   @Test
   public void get() throws Exception {
@@ -76,6 +130,7 @@
     assertThat(c.branch).isEqualTo("master");
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     assertThat(c.subject).isEqualTo("test commit");
+    assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
     assertThat(c.mergeable).isTrue();
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
@@ -89,34 +144,85 @@
   }
 
   @Test
+  public void getAmbiguous() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    gApi.changes().id(changeId).get();
+
+    BranchInput b = new BranchInput();
+    b.revision = repo().exactRef("HEAD").getObjectId().name();
+    gApi.projects()
+        .name(project.get())
+        .branch("other")
+        .create(b);
+
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
+        PushOneCommit.FILE_CONTENT, changeId);
+    PushOneCommit.Result r2 = push2.to("refs/for/other");
+    assertThat(r2.getChangeId()).isEqualTo(changeId);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Multiple changes found for " + changeId);
+    gApi.changes().id(changeId).get();
+  }
+
+  @Test
   public void abandon() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.NEW);
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
     gApi.changes()
-        .id(r.getChangeId())
+        .id(changeId)
         .abandon();
-    ChangeInfo info = get(r.getChangeId());
+    ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase())
         .contains("abandoned");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is abandoned");
+    gApi.changes()
+        .id(changeId)
+        .abandon();
+  }
+
+  @Test
+  public void abandonDraft() throws Exception {
+    PushOneCommit.Result r = createDraftChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("draft changes cannot be abandoned");
+    gApi.changes()
+        .id(changeId)
+        .abandon();
   }
 
   @Test
   public void restore() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.NEW);
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
     gApi.changes()
-        .id(r.getChangeId())
+        .id(changeId)
         .abandon();
-    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
 
     gApi.changes()
-        .id(r.getChangeId())
+        .id(changeId)
         .restore();
-    ChangeInfo info = get(r.getChangeId());
+    ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
     assertThat(Iterables.getLast(info.messages).message.toLowerCase())
         .contains("restored");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is new");
+    gApi.changes()
+        .id(changeId)
+        .restore();
   }
 
   @Test
@@ -144,8 +250,7 @@
         gApi.changes().id(r.getChangeId()).get().messages);
     assertThat(sourceMessages).hasSize(4);
     String expectedMessage = String.format(
-        "Patch Set 1: Reverted\n\n" +
-        "This patchset was reverted in change: %s",
+        "Created a revert of this change as %s",
         revertChange.changeId);
     assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
 
@@ -188,29 +293,102 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
+    String changeId = r2.getChangeId();
     // Rebase the second change
     gApi.changes()
-        .id(r2.getChangeId())
+        .id(changeId)
         .current()
         .rebase();
 
     // Second change should have 2 patch sets
-    assertThat(r2.getPatchSetId().get()).isEqualTo(2);
+    ChangeInfo c2 = gApi.changes().id(changeId).get();
+    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
 
     // ...and the committer should be correct
     ChangeInfo info = gApi.changes()
-        .id(r2.getChangeId()).get(EnumSet.of(
+        .id(changeId).get(EnumSet.of(
             ListChangesOption.CURRENT_REVISION,
             ListChangesOption.CURRENT_COMMIT));
     GitPerson committer = info.revisions.get(
         info.currentRevision).commit.committer;
     assertThat(committer.name).isEqualTo(admin.fullName);
     assertThat(committer.email).isEqualTo(admin.email);
+
+    // Rebasing the second change again should fail
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already up to date");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .rebase();
   }
 
-  @Test(expected = ResourceConflictException.class)
+  @Test
+  public void publish() throws Exception {
+    PushOneCommit.Result r = createChange("refs/drafts/master");
+    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
+    gApi.changes()
+      .id(r.getChangeId())
+      .publish();
+    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void delete() throws Exception {
+    PushOneCommit.Result r = createChange("refs/drafts/master");
+    assertThat(query(r.getChangeId())).hasSize(1);
+    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
+    gApi.changes()
+      .id(r.getChangeId())
+      .delete();
+    assertThat(query(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void voteOnClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is closed");
+    revision(r).review(ReviewInput.reject());
+  }
+
+  @Test
+  public void voteOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReviewType = Util.codeReview();
+    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+    String heads = "refs/heads/*";
+    AccountGroup.UUID owner =
+        SystemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
+    Util.allow(cfg, forCodeReviewAs, -1, 1, owner, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = user.id.toString();
+    revision.review(in);
+
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.value).isEqualTo(1);
+  }
+
+  @Test
   public void rebaseUpToDateChange() throws Exception {
     PushOneCommit.Result r = createChange();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already up to date");
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
@@ -285,41 +463,246 @@
     assertThat(r1.getPatchSetId().get()).isEqualTo(3);
   }
 
-  @Test(expected = ResourceConflictException.class)
+  @Test
   public void rebaseChangeBaseRecursion() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = createChange();
 
     RebaseInput ri = new RebaseInput();
     ri.base = r2.getCommit().name();
+    String expectedMessage = "base change " + r2.getChangeId()
+        + " is a descendant of the current change - recursion not allowed";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(expectedMessage);
     gApi.changes()
         .id(r1.getChangeId())
         .revision(r1.getCommit().name())
         .rebase(ri);
   }
 
-  private Set<Account.Id> getReviewers(String changeId) throws Exception {
-    ChangeInfo ci = gApi.changes().id(changeId).get();
-    Set<Account.Id> result = Sets.newHashSet();
-    for (LabelInfo li : ci.labels.values()) {
-      for (ApprovalInfo ai : li.all) {
-        result.add(new Account.Id(ai._accountId));
-      }
+  @Test
+  public void rebaseAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes()
+        .id(changeId)
+        .abandon();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is abandoned");
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .rebase();
+  }
+
+  @Test
+  public void rebaseOntoAbandonedChange() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Abandon the first change
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes()
+        .id(changeId)
+        .abandon();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    RebaseInput ri = new RebaseInput();
+    ri.base = r.getCommit().name();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("base change is abandoned: " + changeId);
+    gApi.changes()
+        .id(r2.getChangeId())
+        .revision(r2.getCommit().name())
+        .rebase(ri);
+  }
+
+  @Test
+  public void rebaseOntoSelf() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String commit = r.getCommit().name();
+    RebaseInput ri = new RebaseInput();
+    ri.base = commit;
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot rebase change onto itself");
+    gApi.changes()
+        .id(changeId)
+        .revision(commit)
+        .rebase(ri);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void changeNoParentToOneParent() throws Exception {
+    // create initial commit with no parent and push it as change, so that patch
+    // set 1 has no parent
+    RevCommit c =
+        testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    PushResult pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    ChangeInfo change = gApi.changes().id(id).get();
+    assertThat(change.revisions.get(change.currentRevision).commit.parents)
+        .isEmpty();
+
+    // create another initial commit with no parent and push it directly into
+    // the remote repository
+    c = testRepo.amend(c.getId()).message("Initial Empty Commit").create();
+    testRepo.reset(c);
+    pr = pushHead(testRepo, "refs/heads/master", false);
+    assertPushOk(pr, "refs/heads/master");
+
+    // create a successor commit and push it as second patch set to the change,
+    // so that patch set 2 has 1 parent
+    RevCommit c2 = testRepo.commit().message("Initial commit").parent(c)
+        .insertChangeId(id.substring(1)).create();
+    testRepo.reset(c2);
+
+    pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    change = gApi.changes().id(id).get();
+    RevisionInfo rev = change.revisions.get(change.currentRevision);
+    assertThat(rev.commit.parents).hasSize(1);
+    assertThat(rev.commit.parents.get(0).commit).isEqualTo(c.name());
+
+    // check that change kind is correctly detected as REWORK
+    assertThat(rev.kind).isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
+  public void addReviewerThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey("Administrators"))
+            .getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // create change
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check the user cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
     }
-    return result;
+
+    // try to add user as reviewer
+    setApiUser(admin);
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Change not visible to " + user.email);
+    gApi.changes()
+        .id(result.getChangeId())
+        .addReviewer(in);
   }
 
   @Test
   public void addReviewer() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes()
         .id(r.getChangeId())
         .addReviewer(in);
 
-    assertThat(getReviewers(r.getChangeId()))
-        .containsExactlyElementsIn(ImmutableSet.of(user.id));
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailFrom(m, admin.email);
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    Collection<AccountInfo> reviewers = NoteDbMode.readWrite()
+        ? c.reviewers.get(REVIEWER)
+        : c.reviewers.get(CC);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+  }
+
+  @Test
+  public void addSelfAsReviewer() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+
+    // There should be no email notification when adding self
+    assertThat(sender.getMessages()).isEmpty();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    Collection<AccountInfo> reviewers = NoteDbMode.readWrite()
+        ? c.reviewers.get(REVIEWER)
+        : c.reviewers.get(CC);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
   @Test
@@ -334,16 +717,314 @@
         .revision(r.getCommit().name())
         .submit();
 
-    assertThat(getReviewers(r.getChangeId()))
-      .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(admin.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes()
         .id(r.getChangeId())
         .addReviewer(in);
-    assertThat(getReviewers(r.getChangeId()))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.id));
+
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    reviewers = c.reviewers.get(REVIEWER);
+    if (NoteDbMode.readWrite()) {
+      // When NoteDb is enabled adding a reviewer records that user as reviewer
+      // in NoteDb.
+      assertThat(reviewers).hasSize(2);
+      Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+      assertThat(reviewerIt.next()._accountId)
+          .isEqualTo(admin.getId().get());
+      assertThat(reviewerIt.next()._accountId)
+          .isEqualTo(user.getId().get());
+      assertThat(c.reviewers).doesNotContainKey(CC);
+    } else {
+      // When NoteDb is disabled adding a reviewer results in a dummy 0 approval
+      // on the change which is treated as CC when the ChangeInfo is created.
+      assertThat(reviewers).hasSize(1);
+      assertThat(reviewers.iterator().next()._accountId)
+          .isEqualTo(admin.getId().get());
+      Collection<AccountInfo> ccs = c.reviewers.get(CC);
+      assertThat(ccs).hasSize(1);
+      assertThat(ccs.iterator().next()._accountId)
+          .isEqualTo(user.getId().get());
+    }
+  }
+
+  @Test
+  public void listVotes() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    Map<String, Short> m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)2));
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.dislike());
+
+    m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)-1));
+  }
+
+  @Test
+  public void removeReviewerNoVotes() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    LabelType verified = category("Verified", value(1, "Passes"),
+        value(0, "No score"), value(-1, "Failed"));
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .addReviewer(user.getId().toString());
+
+    // ReviewerState will vary between ReviewDb and NoteDb; we just care that it
+    // shows up somewhere.
+    Iterable<AccountInfo> reviewers = Iterables.concat(
+        gApi.changes().id(changeId).get().reviewers.values());
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers.isEmpty());
+
+    // Make sure the reviewer can still be added again.
+    gApi.changes()
+        .id(changeId)
+        .addReviewer(user.getId().toString());
+    reviewers = Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    // Remove again, and then try to remove once more to verify 404 is
+    // returned.
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .remove();
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .remove();
+  }
+
+  @Test
+  public void removeReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    Collection<AccountInfo> reviewers = gApi.changes()
+        .id(changeId)
+        .get()
+        .reviewers.get(REVIEWER);
+
+    assertThat(reviewers).hasSize(2);
+    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId)
+        .isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .remove();
+
+    reviewers = gApi.changes()
+        .id(changeId)
+        .get()
+        .reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId)
+      .isEqualTo(admin.getId().get());
+
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+  }
+
+  @Test
+  public void removeReviewerNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete reviewer not permitted");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .remove();
+  }
+
+  @Test
+  public void deleteVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    sender.clear();
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .deleteVote("Code-Review");
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message msg = messages.get(0);
+    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
+    assertThat(msg.body()).contains(
+        admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body()).contains(
+        "Removed Code-Review+1 by "
+            + user.fullName + " <" + user.email + ">" + "\n");
+
+    Map<String, Short> m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .votes();
+
+    if (NoteDbMode.readWrite()) {
+      // When NoteDb is enabled each reviewer is explicitly recorded in the
+      // NoteDb and this record stays even when all votes of that user have been
+      // deleted, hence there is no dummy 0 approval left when a vote is
+      // deleted.
+      assertThat(m).isEmpty();
+    } else {
+      // When NoteDb is disabled there is a dummy 0 approval on the change so
+      // that the user is still returned as CC when all votes of that user have
+      // been deleted.
+      assertThat(m).containsEntry("Code-Review", Short.valueOf((short)0));
+    }
+
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo(
+        "Removed Code-Review+1 by User <user@example.com>\n");
+    if (NoteDbMode.readWrite()) {
+      // When NoteDb is enabled each reviewer is explicitly recorded in the
+      // NoteDb and this record stays even when all votes of that user have been
+      // deleted.
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(
+              ImmutableSet.of(admin.getId(), user.getId()));
+    } else {
+      // When NoteDb is disabled users that have only dummy 0 approvals on the
+      // change are returned as CC and not as REVIEWER.
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+      assertThat(getReviewers(c.reviewers.get(CC)))
+          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
+    }
+  }
+
+  @Test
+  public void deleteVoteNotifyNone() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = NotifyHandling.NONE;
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .deleteVote(in);
+    assertThat(sender.getMessages()).hasSize(0);
+  }
+
+  @Test
+  public void deleteVoteNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete vote not permitted");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .deleteVote("Code-Review");
   }
 
   @Test
@@ -375,8 +1056,10 @@
         .review(input);
 
     // Reviewers should only be "admin"
-    assertThat(getReviewers(changeId))
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+    assertThat(c.reviewers.get(CC)).isNull();
 
     // Add the user as reviewer
     AddReviewerInput in = new AddReviewerInput();
@@ -384,8 +1067,17 @@
     gApi.changes()
         .id(changeId)
         .addReviewer(in);
-    assertThat(getReviewers(changeId))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+    c = gApi.changes().id(changeId).get();
+    if (NoteDbMode.readWrite()) {
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(
+              admin.getId(), user.getId()));
+    } else {
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+      assertThat(getReviewers(c.reviewers.get(CC)))
+          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
+    }
 
     // Approve the change as user, then remove the approval
     // (only to confirm that the user does have Code-Review+2 permission)
@@ -407,13 +1099,22 @@
         .submit();
 
     // User should still be on the change
-    assertThat(getReviewers(changeId))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+    c = gApi.changes().id(changeId).get();
+    if (NoteDbMode.readWrite()) {
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(
+              admin.getId(), user.getId()));
+    } else {
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+      assertThat(getReviewers(c.reviewers.get(CC)))
+          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
+    }
   }
 
   @Test
   public void createEmptyChange() throws Exception {
-    ChangeInfo in = new ChangeInfo();
+    ChangeInput in = new ChangeInput();
     in.branch = Constants.MASTER;
     in.subject = "Create a change from the API";
     in.project = project.get();
@@ -496,9 +1197,24 @@
   @Test
   public void queryChangesOptions() throws Exception {
     PushOneCommit.Result r = createChange();
+
     ChangeInfo result = Iterables.getOnlyElement(gApi.changes()
         .query(r.getChangeId())
-        .withOptions(EnumSet.allOf(ListChangesOption.class))
+        .get());
+    assertThat(result.labels).isNull();
+    assertThat(result.messages).isNull();
+    assertThat(result.actions).isNull();
+    assertThat(result.revisions).isNull();
+
+    EnumSet<ListChangesOption> options = EnumSet.of(
+        ListChangesOption.ALL_REVISIONS,
+        ListChangesOption.CHANGE_ACTIONS,
+        ListChangesOption.CURRENT_ACTIONS,
+        ListChangesOption.DETAILED_LABELS,
+        ListChangesOption.MESSAGES);
+    result = Iterables.getOnlyElement(gApi.changes()
+        .query(r.getChangeId())
+        .withOptions(options)
         .get());
     assertThat(Iterables.getOnlyElement(result.labels.keySet()))
         .isEqualTo("Code-Review");
@@ -560,7 +1276,28 @@
   }
 
   @Test
+  public void submitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .info().submitted).isNull();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .submit();
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .info().submitted).isNotNull();
+  }
+
+  @Test
   public void check() throws Exception {
+    // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
+    assume().that(notesMigration.enabled()).isFalse();
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes()
         .id(r.getChangeId())
@@ -637,6 +1374,7 @@
 
   @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
+    setApiUser(admin);
     PushOneCommit.Result r1 = createChange();
     gApi.changes()
         .id(r1.getChangeId())
@@ -648,18 +1386,23 @@
         .submit();
 
     createChange();
+    createDraftChange();
 
-    setApiUserAnonymous(); // Identified user may async get stars from DB.
-    atrScope.disableDb();
-    assertThat(gApi.changes().query()
-          .withQuery(
-            "project:{" + project.get() + "} (status:open OR status:closed)")
-          // Options should match defaults in AccountDashboardScreen.
-          .withOption(ListChangesOption.LABELS)
-          .withOption(ListChangesOption.DETAILED_ACCOUNTS)
-          .withOption(ListChangesOption.REVIEWED)
-          .get())
-        .hasSize(2);
+    setApiUser(user);
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      assertThat(gApi.changes().query()
+            .withQuery(
+              "project:{" + project.get() + "} (status:open OR status:closed)")
+            // Options should match defaults in AccountDashboardScreen.
+            .withOption(ListChangesOption.LABELS)
+            .withOption(ListChangesOption.DETAILED_ACCOUNTS)
+            .withOption(ListChangesOption.REVIEWED)
+            .get())
+          .hasSize(2);
+    } finally {
+      enableDb(ctx);
+    }
   }
 
   @Test
@@ -688,6 +1431,10 @@
   }
 
   @Test
+  @GerritConfigs({
+    @GerritConfig(name = "gerrit.editGpgKeys", value = "true"),
+    @GerritConfig(name = "receive.enableSignedPush", value = "true"),
+  })
   public void pushCertificates() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = amendChange(r1.getChangeId());
@@ -710,4 +1457,325 @@
     assertThat(rev2.pushCertificate.certificate).isNull();
     assertThat(rev2.pushCertificate.key).isNull();
   }
+
+  @Test
+  public void anonymousRestApi() throws Exception {
+    setApiUserAnonymous();
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    info = gApi.changes().id(triplet).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    info = gApi.changes().id(info._number).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    exception.expect(AuthException.class);
+    gApi.changes()
+        .id(triplet)
+        .current()
+        .review(ReviewInput.approve());
+  }
+
+  @Test
+  public void noteDbCommitsOnPatchSetCreation() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+        "b.txt", "4711", r.getChangeId()).to("refs/for/master").assertOkStatus();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commitPatchSetCreation = rw.parseCommit(
+          repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+
+      assertThat(commitPatchSetCreation.getShortMessage())
+          .isEqualTo("Create patch set 2");
+      PersonIdent expectedAuthor = changeNoteUtil.newIdent(
+          accountCache.get(admin.id).getAccount(), c.updated,
+          serverIdent.get(), AnonymousCowardNameProvider.DEFAULT);
+      assertThat(commitPatchSetCreation.getAuthorIdent())
+          .isEqualTo(expectedAuthor);
+      assertThat(commitPatchSetCreation.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
+      assertThat(commitPatchSetCreation.getParentCount()).isEqualTo(1);
+
+      RevCommit commitChangeCreation =
+          rw.parseCommit(commitPatchSetCreation.getParent(0));
+      assertThat(commitChangeCreation.getShortMessage())
+          .isEqualTo("Create change");
+      expectedAuthor = changeNoteUtil.newIdent(
+          accountCache.get(admin.id).getAccount(), c.created, serverIdent.get(),
+          AnonymousCowardNameProvider.DEFAULT);
+      assertThat(commitChangeCreation.getAuthorIdent())
+          .isEqualTo(expectedAuthor);
+      assertThat(commitChangeCreation.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
+      assertThat(commitChangeCreation.getParentCount()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void createEmptyChangeOnNonExistingBranch() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.branch = "foo";
+    in.subject = "Create a change on new branch from the API";
+    in.project = project.get();
+    in.newBranch = true;
+    ChangeInfo info = gApi
+        .changes()
+        .create(in)
+        .get();
+    assertThat(info.project).isEqualTo(in.project);
+    assertThat(info.branch).isEqualTo(in.branch);
+    assertThat(info.subject).isEqualTo(in.subject);
+    assertThat(Iterables.getOnlyElement(info.messages).message)
+        .isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void createEmptyChangeOnExistingBranchWithNewBranch() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "Create a change on new branch from the API";
+    in.project = project.get();
+    in.newBranch = true;
+
+    exception.expect(ResourceConflictException.class);
+    gApi.changes()
+        .create(in)
+        .get();
+  }
+
+  @Test
+  public void createNewPatchSetOnVisibleDraftPatchSet() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo =
+        cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Amend draft as admin
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+
+    // Add user as reviewer to make this patch set visible
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r1.getChangeId())
+        .addReviewer(in);
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r2.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r3 = amendChange(
+        r2.getChangeId(), "refs/drafts/master", user, userTestRepo);
+    r3.assertOkStatus();
+  }
+
+  @Test
+  public void createNewPatchSetOnInvisibleDraftPatchSet() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo =
+        cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Amend draft as admin
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r3 = amendChange(
+        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r3.assertErrorStatus("cannot add patch set to "
+        + r3.getChange().change().getChangeId() + ".");
+  }
+
+  @Test
+  public void createNewPatchSetWithoutPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSet1");
+
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo =
+        cloneProject(p, admin);
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(p, user);
+
+    // Block default permission
+    block(Permission.ADD_PATCH_SET,
+        REGISTERED_USERS, "refs/for/*", p);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertErrorStatus("cannot add patch set to "
+        + r1.getChange().getId().id + ".");
+  }
+
+  @Test
+  public void createNewSetPatchWithPermission() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    TestRepository<?> userTestRepo = cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertOkStatus();
+  }
+
+  @Test
+  public void createNewPatchSetAsOwnerWithoutPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSet2");
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+
+    // Block default permission
+    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+
+    // Create change as admin
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(adminTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    adminTestRepo.reset("ps");
+
+    // Amend change as admin
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/for/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+  }
+
+  @Test
+  public void createNewPatchSetAsReviewerOnDraftChange() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    TestRepository<?> userTestRepo = cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/drafts/master");
+    r1.assertOkStatus();
+
+    // Add user as reviewer
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r1.getChangeId())
+        .addReviewer(in);
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertOkStatus();
+  }
+
+  @Test
+  public void createNewDraftPatchSetOnDraftChange() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSet4");
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<?> adminTestRepo = cloneProject(p, admin);
+    TestRepository<?> userTestRepo = cloneProject(p, user);
+
+    // Block default permission
+    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/drafts/master");
+    r1.assertOkStatus();
+
+    // Add user as reviewer
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r1.getChangeId())
+        .addReviewer(in);
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/drafts/master", user, userTestRepo);
+    r2.assertErrorStatus("cannot add patch set to "
+        + r1.getChange().getId().id + ".");
+  }
+
+  private static Iterable<Account.Id> getReviewers(
+      Collection<AccountInfo> r) {
+    return Iterables.transform(r, new Function<AccountInfo, Account.Id>() {
+      @Override
+      public Account.Id apply(AccountInfo account) {
+        return new Account.Id(account._accountId);
+      }
+    });
+  }
+
+  private ChangeResource parseResource(PushOneCommit.Result r)
+      throws Exception {
+    List<ChangeControl> ctls = changeFinder.find(
+        r.getChangeId(), atrScope.get().getUser());
+    assertThat(ctls).hasSize(1);
+    return changeResourceFactory.create(ctls.get(0));
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java
deleted file mode 100644
index 475803a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.FixInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.reviewdb.client.Change;
-
-import org.junit.Test;
-
-import java.util.Collections;
-import java.util.List;
-
-@NoHttpd
-public class CheckIT extends AbstractDaemonTest {
-  // Most types of tests belong in ConsistencyCheckerTest; these mostly just
-  // test paths outside of ConsistencyChecker, like API wiring.
-  @Test
-  public void currentPatchSetMissing() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change c = getChange(r);
-    db.patchSets().deleteKeys(Collections.singleton(c.currentPatchSetId()));
-    indexer.index(db, c);
-
-    List<ProblemInfo> problems = gApi.changes()
-        .id(r.getChangeId())
-        .check()
-        .problems;
-    assertThat(problems).hasSize(1);
-    assertThat(problems.get(0).message)
-        .isEqualTo("Current patch set 1 not found");
-  }
-
-  @Test
-  public void fixReturnsUpdatedValue() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
-
-    Change c = getChange(r);
-    c.setStatus(Change.Status.NEW);
-    db.changes().update(Collections.singleton(c));
-    indexer.index(db, c);
-
-    ChangeInfo info = gApi.changes()
-        .id(r.getChangeId())
-        .check();
-    assertThat(info.problems).hasSize(1);
-    assertThat(info.problems.get(0).status).isNull();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-
-    info = gApi.changes()
-        .id(r.getChangeId())
-        .check(new FixInput());
-    assertThat(info.problems).hasSize(1);
-    assertThat(info.problems.get(0).status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-  }
-
-  private Change getChange(PushOneCommit.Result r) throws Exception {
-    return db.changes().get(new Change.Id(
-        gApi.changes().id(r.getChangeId()).get()._number));
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
new file mode 100644
index 0000000..54fe28f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -0,0 +1,538 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
+import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
+import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+@NoHttpd
+public class StickyApprovalsIT extends AbstractDaemonTest {
+  @Before
+  public void setup() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    // Overwrite "Code-Review" label that is inherited from All-Projects.
+    // This way changes to the "Code Review" label don't affect other tests.
+    LabelType codeReview =
+        category("Code-Review", value(2, "Looks good to me, approved"),
+            value(1, "Looks good to me, but someone else must approve"),
+            value(0, "No score"),
+            value(-1, "I would prefer that you didn't submit this"),
+            value(-2, "Do not submit"));
+    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+
+    LabelType verified = category("Verified", value(1, "Passes"),
+        value(0, "No score"), value(-1, "Failed"));
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
+        registeredUsers, heads);
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  @Test
+  public void notSticky() throws Exception {
+    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE,
+        MERGE_FIRST_PARENT_UPDATE));
+  }
+
+  @Test
+  public void stickyOnMinScore() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, -1, 1);
+      vote(user, changeId, -2, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyOnMaxScore() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyOnTrivialRebase() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyAllScoresOnTrivialRebase(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(TRIVIAL_REBASE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, TRIVIAL_REBASE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
+    assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
+
+    assertNotSticky(
+        EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
+
+    // check that votes are sticky when trivial rebase is done by cherry-pick
+    testRepo.reset(getRemoteHead());
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    // check that votes are not sticky when rework is done by cherry-pick
+    testRepo.reset(getRemoteHead());
+    changeId = createChange().getChangeId();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    cherryPickChangeId = cherryPick(changeId, REWORK);
+    c = detailedChange(cherryPickChangeId);
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyOnNoCodeChange() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Verified")
+        .setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(NO_CODE_CHANGE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, NO_CODE_CHANGE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
+
+    assertNotSticky(
+        EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
+  }
+
+  @Test
+  public void stickyOnMergeFirstParentUpdate() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyAllScoresOnMergeFirstParentUpdate(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
+    assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
+
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, TRIVIAL_REBASE));
+  }
+
+  @Test
+  public void removedVotesNotSticky() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyAllScoresOnTrivialRebase(true);
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      // Remove votes by re-voting with 0
+      vote(admin, changeId, 0, 0);
+      vote(user, changeId, 0, 0);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, null);
+      assertVotes(c, user, 0, 0, null);
+
+      updateChange(changeId, changeKind);
+      c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyMaxScore(true);
+    cfg.getLabelSections().get("Verified")
+        .setCopyAllScoresIfNoCodeChange(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, 2, 1);
+
+    for (int i = 0; i < 5; i++) {
+      updateChange(changeId, NO_CODE_CHANGE);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
+    }
+
+    updateChange(changeId, REWORK);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyMaxScore(true);
+    cfg.getLabelSections().get("Code-Review")
+        .setCopyMinScore(true);
+    saveProjectConfig(project, cfg);
+
+    // Vote max score on PS1
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, 2, 1);
+
+    // Have someone else vote min score on PS2
+    updateChange(changeId, REWORK);
+    vote(user, changeId, -2, 0);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // No vote changes on PS3
+    updateChange(changeId, REWORK);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // Both users revote on PS4
+    updateChange(changeId, REWORK);
+    vote(admin, changeId, 1, 1);
+    vote(user, changeId, 1, 1);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 1, 1, REWORK);
+    assertVotes(c, user, 1, 1, REWORK);
+
+    // New approvals shouldn't carry through to PS5
+    updateChange(changeId, REWORK);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, REWORK);
+    assertVotes(c, user, 0, 0, REWORK);
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes().id(changeId)
+        .get(EnumSet.of(ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.CURRENT_COMMIT));
+  }
+
+  private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
+    for (ChangeKind changeKind : changeKinds) {
+      testRepo.reset(getRemoteHead());
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, +2, 1);
+      vote(user, changeId, -2, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  private String createChange(ChangeKind kind) throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange().getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit();
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  private void updateChange(String changeId, ChangeKind changeKind)
+      throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId);
+        return;
+      case REWORK:
+        rework(changeId);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId);
+        return;
+      case NO_CHANGE:
+      default:
+        fail("unexpected change kind: " + changeKind);
+    }
+  }
+
+  private void noCodeChange(String changeId) throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder.message("New subject " + System.nanoTime())
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
+  }
+
+  private void rework(String changeId) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
+        "new content " + System.nanoTime(), changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
+  }
+
+  private void trivialRebase(String changeId) throws Exception {
+    setApiUser(admin);
+    testRepo.reset(getRemoteHead());
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Other Change",
+            "a" + System.nanoTime() + ".txt", PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+    ReviewInput in = new ReviewInput()
+        .label("Code-Review", 2)
+        .label("Verified", 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
+  }
+
+  private String createChangeForMergeCommit() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 =
+        createChange("parent 1", "p1.txt", "content 1");
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 =
+        createChange("parent 2", "p2.txt", "content 2");
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo);
+    merge.setParents(
+        ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  private void updateFirstParent(String changeId) throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 =
+        createChange("new parent 1", "p1-1.txt", "content 1-1");
+
+    PushOneCommit merge =
+        pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    merge.setParents(
+        ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        fail("unexpected change kind: " + changeKind);
+    }
+
+    testRepo.reset(getRemoteHead());
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+            "other.txt", "new content " + System.nanoTime())
+        .to("refs/for/master");
+    r.assertOkStatus();
+    vote(admin, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject = TRIVIAL_REBASE.equals(changeKind)
+        ? PushOneCommit.SUBJECT
+        : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message =
+        String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes()
+        .id(changeId)
+        .revision("current")
+        .cherryPick(in)
+        .get();
+    return c.changeId;
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId)
+        .get(EnumSet.of(ListChangesOption.CURRENT_REVISION));
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote,
+      int verifiedVote) throws Exception {
+    setApiUser(user);
+    ReviewInput in = new ReviewInput()
+        .label("Code-Review", codeReviewVote)
+        .label("Verified", verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote,
+      int verifiedVote) {
+    assertVotes(c, user, codeReviewVote, verifiedVote, null);
+  }
+
+  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote,
+      int verifiedVote, ChangeKind changeKind) {
+    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
+    assertVotes(c, user, "Verified", verifiedVote, changeKind);
+  }
+
+  private void assertVotes(ChangeInfo c, TestAccount user, String label,
+      int expectedVote, ChangeKind changeKind) {
+    Integer vote = 0;
+    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
+      for (ApprovalInfo approval : c.labels.get(label).all) {
+        if (approval._accountId == user.id.get()) {
+          vote = approval.value;
+          break;
+        }
+      }
+    }
+
+    String name = "label = " + label;
+    if (changeKind != null) {
+      name += "; changeKind = " + changeKind.name();
+    }
+    assertThat(vote)
+        .named(name)
+        .isEqualTo(expectedVote);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
new file mode 100644
index 0000000..1033164
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.SubmitType.CHERRY_PICK;
+import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
+import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
+import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
+import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.testutil.ConfigSuite;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@NoHttpd
+public class SubmitTypeRuleIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  private class RulesPl extends VersionedMetaData {
+    private static final String FILENAME = "rules.pl";
+
+    private String rule;
+
+    @Override
+    protected String getRefName() {
+      return RefNames.REFS_CONFIG;
+    }
+
+    @Override
+    protected void onLoad() throws IOException, ConfigInvalidException {
+      rule = readUTF8(FILENAME);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit)
+        throws IOException, ConfigInvalidException {
+      TestSubmitRuleInput in = new TestSubmitRuleInput();
+      in.rule = rule;
+      try {
+        gApi.changes().id(testChangeId.get()).current().testSubmitType(in);
+      } catch (RestApiException e) {
+        throw new ConfigInvalidException("Invalid submit type rule", e);
+      }
+
+      saveUTF8(FILENAME, rule);
+      return true;
+    }
+  }
+
+  private AtomicInteger fileCounter;
+  private Change.Id testChangeId;
+
+  @Before
+  public void setUp() throws Exception {
+    fileCounter = new AtomicInteger();
+    gApi.projects().name(project.get()).branch("test")
+        .create(new BranchInput());
+    testChangeId = createChange("test", "test change").getChange().getId();
+  }
+
+  private void setRulesPl(String rule) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      RulesPl r = new RulesPl();
+      r.load(md);
+      r.rule = rule;
+      r.commit(md);
+    }
+  }
+
+  private static final String SUBMIT_TYPE_FROM_SUBJECT =
+      "submit_type(fast_forward_only) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*FAST_FORWARD_ONLY.*', M),"
+      + "!.\n"
+      + "submit_type(merge_if_necessary) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*MERGE_IF_NECESSARY.*', M),"
+      + "!.\n"
+      + "submit_type(rebase_if_necessary) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
+      + "!.\n"
+      + "submit_type(merge_always) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*MERGE_ALWAYS.*', M),"
+      + "!.\n"
+      + "submit_type(cherry_pick) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*CHERRY_PICK.*', M),"
+      + "!.\n"
+      + "submit_type(T) :- gerrit:project_default_submit_type(T).";
+
+  private PushOneCommit.Result createChange(String dest, String subject)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        subject, "file" + fileCounter.incrementAndGet(),
+        PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/" + dest);
+    r.assertOkStatus();
+    return r;
+  }
+
+  @Test
+  public void unconditionalCherryPick() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertSubmitType(MERGE_IF_NECESSARY, r.getChangeId());
+    setRulesPl("submit_type(cherry_pick).");
+    assertSubmitType(CHERRY_PICK, r.getChangeId());
+  }
+
+  @Test
+  public void submitTypeFromSubject() throws Exception {
+    PushOneCommit.Result r1 = createChange("master", "Default 1");
+    PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
+    PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
+    PushOneCommit.Result r4 = createChange("master", "REBASE_IF_NECESSARY 4");
+    PushOneCommit.Result r5 = createChange("master", "MERGE_ALWAYS 5");
+    PushOneCommit.Result r6 = createChange("master", "CHERRY_PICK 6");
+
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r4.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r5.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r6.getChangeId());
+
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(FAST_FORWARD_ONLY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+    assertSubmitType(REBASE_IF_NECESSARY, r4.getChangeId());
+    assertSubmitType(MERGE_ALWAYS, r5.getChangeId());
+    assertSubmitType(CHERRY_PICK, r6.getChangeId());
+  }
+
+  @Test
+  public void submitTypeIsUsedForSubmit() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r = createChange("master", "CHERRY_PICK 1");
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    List<RevCommit> log = log("master", 1);
+    assertThat(log.get(0).getShortMessage()).isEqualTo("CHERRY_PICK 1");
+    assertThat(log.get(0).name()).isNotEqualTo(r.getCommit().name());
+    assertThat(log.get(0).getFullMessage())
+        .contains("Change-Id: " + r.getChangeId());
+    assertThat(log.get(0).getFullMessage()).contains("Reviewed-on: ");
+  }
+
+  @Test
+  public void mixingSubmitTypesAcrossBranchesSucceeds() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r1 = createChange("master", "MERGE_IF_NECESSARY 1");
+
+    RevCommit initialCommit = r1.getCommit().getParent(0);
+    BranchInput bin = new BranchInput();
+    bin.revision = initialCommit.name();
+    gApi.projects().name(project.get()).branch("branch").create(bin);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result r2 = createChange("branch", "MERGE_ALWAYS 1");
+
+    gApi.changes().id(r1.getChangeId()).topic(name("topic"));
+    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).topic(name("topic"));
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).current().submit();
+
+    assertThat(log("master", 1).get(0).name()).isEqualTo(r1.getCommit().name());
+
+    List<RevCommit> branchLog = log("branch", 1);
+    assertThat(branchLog.get(0).getParents()).hasLength(2);
+    assertThat(branchLog.get(0).getParent(1).name())
+        .isEqualTo(r2.getCommit().name());
+  }
+
+  @Test
+  public void mixingSubmitTypesOnOneBranchFails() throws Exception {
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    PushOneCommit.Result r1 = createChange("master", "CHERRY_PICK 1");
+    PushOneCommit.Result r2 = createChange("master", "MERGE_IF_NECESSARY 2");
+
+    gApi.changes().id(r1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.approve());
+
+    try {
+      gApi.changes().id(r2.getChangeId()).current().submit();
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(
+          "Failed to submit 2 changes due to the following problems:\n"
+          + "Change " + r1.getChange().getId() + ": Change has submit type "
+          + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
+          + "from change " + r2.getChange().getId() + " in the same batch");
+    }
+  }
+
+  private List<RevCommit> log(String commitish, int n) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        Git git = new Git(repo)) {
+      ObjectId id = repo.resolve(commitish);
+      assertThat(id).isNotNull();
+      return ImmutableList.copyOf(git.log().add(id).setMaxCount(n).call());
+    }
+  }
+
+  private void assertSubmitType(SubmitType expected, String id)
+      throws Exception {
+    assertThat(gApi.changes().id(id).current().submitType())
+        .isEqualTo(expected);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK
index 4918a95..3b3d362 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-config',
+  group = 'api_config',
   srcs = glob(['*IT.java']),
   labels = ['api'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
new file mode 100644
index 0000000..da8274d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_config',
+  srcs = glob(['*IT.java']),
+  labels = ['api'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
new file mode 100644
index 0000000..047305c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+
+import org.junit.Test;
+
+@NoHttpd
+public class DiffPreferencesIT extends AbstractDaemonTest {
+
+  @Test
+  public void getDiffPreferences() throws Exception {
+    DiffPreferencesInfo result =
+        gApi.config().server().getDefaultDiffPreferences();
+    assertPrefs(result, DiffPreferencesInfo.defaults());
+  }
+
+  @Test
+  public void setDiffPreferences() throws Exception {
+    int newLineLength = DiffPreferencesInfo.defaults().lineLength + 10;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = newLineLength;
+    DiffPreferencesInfo result =
+        gApi.config().server().setDefaultDiffPreferences(update);
+    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+
+    result = gApi.config().server().getDefaultDiffPreferences();
+    DiffPreferencesInfo expected = DiffPreferencesInfo.defaults();
+    expected.lineLength = newLineLength;
+    assertPrefs(result, expected);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
new file mode 100644
index 0000000..1dcdaed
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Test;
+
+@NoHttpd
+public class GeneralPreferencesIT extends AbstractDaemonTest {
+  @Inject
+  private AllUsersName allUsers;
+
+  @After
+  public void cleanUp() throws Exception {
+    try (Repository git = repoManager.openRepository(allUsers)) {
+      if (git.exactRef(RefNames.REFS_USERS_DEFAULT) != null) {
+        RefUpdate u = git.updateRef(RefNames.REFS_USERS_DEFAULT);
+        u.setForceUpdate(true);
+        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      }
+    }
+    accountCache.evictAll();
+  }
+
+  @Test
+  public void getGeneralPreferences() throws Exception {
+    GeneralPreferencesInfo result =
+        gApi.config().server().getDefaultPreferences();
+    assertPrefs(result, GeneralPreferencesInfo.defaults(), "my");
+  }
+
+  @Test
+  public void setGeneralPreferences() throws Exception {
+    boolean newSignedOffBy = !GeneralPreferencesInfo.defaults().signedOffBy;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.signedOffBy = newSignedOffBy;
+    GeneralPreferencesInfo result =
+        gApi.config().server().setDefaultPreferences(update);
+    assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
+
+    result = gApi.config().server().getDefaultPreferences();
+    GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults();
+    expected.signedOffBy = newSignedOffBy;
+    assertPrefs(result, expected, "my");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK
index 06be8ee..cea23dd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK
@@ -1,13 +1,13 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-group',
+  group = 'api_group',
   srcs = glob(['*IT.java']),
   deps = [
     ':util',
     '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util',
   ],
-  labels = ['api']
+  labels = ['api'],
 )
 
 java_library(
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
new file mode 100644
index 0000000..1a374f0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
@@ -0,0 +1,23 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_group',
+  srcs = glob(['*IT.java']),
+  deps = [
+    ':util',
+    '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util',
+  ],
+  labels = ['api'],
+)
+
+java_library(
+  name = 'util',
+  srcs = ['GroupAssert.java'],
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:gwtorm',
+    '//lib:truth',
+  ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8d1d89f..f699f61 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -21,7 +21,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -49,6 +48,7 @@
 
 import java.sql.Timestamp;
 import java.util.Arrays;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -100,6 +100,18 @@
   }
 
   @Test
+  public void addMembersWithAtSign() throws Exception {
+    String g = createGroup("users");
+    TestAccount u10 = accounts.create("u10", "u10@example.com", "Full Name 10");
+    TestAccount u11_at = accounts.create("u11@something", "u11@example.com",
+                                         "Full Name 11 With At");
+    accounts.create("u11", "u11.another@example.com",
+                    "Full Name 11 Without At");
+    gApi.groups().id(g).addMembers(u10.username, u11_at.username);
+    assertMembers(g, u10, u11_at);
+  }
+
+  @Test
   public void includeRemoveGroup() throws Exception {
     String p = createGroup("parent");
     String g = createGroup("newGroup");
@@ -125,7 +137,7 @@
     String p = createGroup("parent");
     String g1 = createGroup("newGroup1");
     String g2 = createGroup("newGroup2");
-    List<String> groups = Lists.newLinkedList();
+    List<String> groups = new LinkedList<>();
     groups.add(g1);
     groups.add(g2);
     gApi.groups().id(p).addGroups(g1, g2);
@@ -140,6 +152,45 @@
   }
 
   @Test
+  public void testCreateDuplicateInternalGroupCaseSensitiveName_Conflict()
+      throws Exception {
+    String dupGroupName = name("dupGroup");
+    gApi.groups().create(dupGroupName);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group '" + dupGroupName + "' already exists");
+    gApi.groups().create(dupGroupName);
+  }
+
+  @Test
+  public void testCreateDuplicateInternalGroupCaseInsensitiveName()
+      throws Exception {
+    String dupGroupName = name("dupGroupA");
+    String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
+    gApi.groups().create(dupGroupName);
+    gApi.groups().create(dupGroupNameLowerCase);
+    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupName);
+    assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupNameLowerCase);
+  }
+
+  @Test
+  public void testCreateDuplicateSystemGroupCaseSensitiveName_Conflict()
+      throws Exception {
+    String newGroupName = "Registered Users";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'Registered Users' already exists");
+    gApi.groups().create(newGroupName);
+  }
+
+  @Test
+  public void testCreateDuplicateSystemGroupCaseInsensitiveName_Conflict()
+      throws Exception {
+    String newGroupName = "registered users";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'Registered Users' already exists");
+    gApi.groups().create(newGroupName);
+  }
+
+  @Test
   public void testCreateGroupWithProperties() throws Exception {
     GroupInput in = new GroupInput();
     in.name = name("newGroup");
@@ -160,13 +211,6 @@
   }
 
   @Test
-  public void testCreateGroupWhenGroupAlreadyExists_Conflict()
-      throws Exception {
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().create("Administrators");
-  }
-
-  @Test
   public void testGetGroup() throws Exception {
     AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
     testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
@@ -528,19 +572,6 @@
     return groupCache.get(new AccountGroup.NameKey(name));
   }
 
-  private String createGroup(String name) throws Exception {
-    return createGroup(name, "Administrators");
-  }
-
-  private String createGroup(String name, String owner) throws Exception {
-    name = name(name);
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
   private String createAccount(String name, String group) throws Exception {
     name = name(name);
     accounts.create(name, group);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK
index 9dab9f8..0b293f3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-project',
+  group = 'api_project',
   srcs = glob(['*IT.java']),
   labels = ['api'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
new file mode 100644
index 0000000..4fb65ff
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_project',
+  srcs = glob(['*IT.java']),
+  labels = ['api'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
index d837565..6892893 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -21,34 +21,74 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.RefNames;
 
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 @NoHttpd
 public class ProjectIT extends AbstractDaemonTest  {
 
   @Test
-  public void createProjectFoo() throws Exception {
+  public void createProject() throws Exception {
     String name = name("foo");
     assertThat(name).isEqualTo(
         gApi.projects()
             .create(name)
             .get()
             .name);
+
+    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
+        null, head);
+
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
+        new String[]{});
   }
 
   @Test
-  public void createProjectFooWithGitSuffix() throws Exception {
+  public void createProjectWithGitSuffix() throws Exception {
     String name = name("foo");
     assertThat(name).isEqualTo(
         gApi.projects()
             .create(name + ".git")
             .get()
             .name);
+
+    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
+        null, head);
+
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
+        new String[]{});
+  }
+
+  @Test
+  public void createProjectWithInitialCommit() throws Exception {
+    String name = name("foo");
+    ProjectInput input = new ProjectInput();
+    input.name = name;
+    input.createEmptyCommit = true;
+    assertThat(name).isEqualTo(
+        gApi.projects()
+            .create(input)
+            .get()
+            .name);
+
+    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
+        null, head);
+
+    head = getRemoteHead(name, "refs/heads/master");
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
+        null, head);
   }
 
   @Test
@@ -63,6 +103,15 @@
   }
 
   @Test
+  public void createProjectNoNameInInput() throws Exception {
+    ProjectInput in = new ProjectInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("input.name is required");
+    gApi.projects()
+        .create(in);
+  }
+
+  @Test
   public void createProjectDuplicate() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("baz");
@@ -85,11 +134,12 @@
 
   @Test
   public void description() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
     assertThat(gApi.projects()
             .name(project.get())
             .description())
         .isEmpty();
-    PutDescriptionInput in = new PutDescriptionInput();
+    DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
     gApi.projects()
         .name(project.get())
@@ -98,5 +148,27 @@
             .name(project.get())
             .description())
         .isEqualTo(in.description);
+
+    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(project.get(), RefNames.REFS_CONFIG,
+        initialHead, updatedHead);
+  }
+
+  @Test
+  public void config() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+
+    ConfigInfo info = gApi.projects().name(project.get()).config();
+    assertThat(info.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    ConfigInput input = new ConfigInput();
+    input.submitType = SubmitType.CHERRY_PICK;
+    info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+    info = gApi.projects().name(project.get()).config();
+    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+
+    RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(project.get(), RefNames.REFS_CONFIG,
+        initialHead, updatedHead);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK
index c916755..76ae637 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'api-revision',
+  group = 'api_revision',
   srcs = glob(['*IT.java']),
   labels = ['api'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
new file mode 100644
index 0000000..e527b9d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'api_revision',
+  srcs = glob(['*IT.java']),
+  labels = ['api'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 23a2844..ee2dbfe 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -18,9 +18,11 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.http.HttpStatus.SC_OK;
 import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
@@ -28,6 +30,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -37,6 +40,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -46,8 +50,16 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.change.GetRevisionActions;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -57,6 +69,7 @@
 import java.io.ByteArrayOutputStream;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -66,6 +79,9 @@
 
 public class RevisionIT extends AbstractDaemonTest {
 
+  @Inject
+  private GetRevisionActions getRevisionActions;
+
   private TestAccount admin2;
 
   @Before
@@ -109,18 +125,68 @@
   @Test
   public void submit() throws Exception {
     PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
+        .id(changeId)
         .current()
         .review(ReviewInput.approve());
     gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
+        .id(changeId)
         .current()
         .submit();
+    assertThat(gApi.changes().id(changeId).get().status)
+        .isEqualTo(ChangeStatus.MERGED);
   }
 
-  @Test(expected = AuthException.class)
+  private void allowSubmitOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg,
+        Permission.SUBMIT_AS,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
+        "refs/heads/*");
+    saveProjectConfig(project, cfg);
+  }
+
+  @Test
   public void submitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+    assertThat(gApi.changes().id(changeId).get().status)
+        .isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void submitOnBehalfOfInvalidUser() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = "doesnotexist";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes()
         .id(project.get() + "~master~" + r.getChangeId())
@@ -128,6 +194,8 @@
         .review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email;
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit on behalf of not permitted");
     gApi.changes()
         .id(project.get() + "~master~" + r.getChangeId())
         .current()
@@ -159,7 +227,11 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name())
         .cherryPick(in);
-    assertThat(orig.get().messages).hasSize(2);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .get().messages;
+    assertThat(messages).hasSize(2);
 
     String cherryPickedRevision = cherry.get().currentRevision;
     String expectedMessage = String.format(
@@ -167,7 +239,7 @@
         "This patchset was cherry picked to branch %s as commit %s",
         in.destination, cherryPickedRevision);
 
-    Iterator<ChangeMessageInfo> origIt = orig.get().messages.iterator();
+    Iterator<ChangeMessageInfo> origIt = messages.iterator();
     origIt.next();
     assertThat(origIt.next().message).isEqualTo(expectedMessage);
 
@@ -279,7 +351,11 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name())
         .cherryPick(in);
-    assertThat(orig.get().messages).hasSize(2);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .get().messages;
+    assertThat(messages).hasSize(2);
 
     assertThat(cherry.get().subject).contains(in.message);
     cherry.current().review(ReviewInput.approve());
@@ -316,6 +392,47 @@
   }
 
   @Test
+  public void cherryPickToExistingChange() throws Exception {
+    PushOneCommit.Result r1 = pushFactory.create(
+          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+        .to("refs/for/master");
+    String t1 = project.get() + "~master~" + r1.getChangeId();
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects()
+        .name(project.get())
+        .branch("foo")
+        .create(bin);
+
+    PushOneCommit.Result r2 = pushFactory.create(
+          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b",
+          r1.getChangeId())
+        .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+    gApi.changes().id(t2).abandon();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    try {
+      gApi.changes().id(t1).current().cherryPick(in);
+      fail();
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage()).isEqualTo(
+          "Cannot create new patch set of change " + info(t2)._number
+          + " because it is abandoned");
+    }
+
+    gApi.changes().id(t2).restore();
+    gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(get(t2).revisions).hasSize(2);
+    assertThat(
+          gApi.changes().id(t2).current().file(FILE_NAME).content().asString())
+        .isEqualTo("a");
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -368,7 +485,7 @@
 
   @Test
   public void mergeable() throws Exception {
-    ObjectId initial = repo().getRef(HEAD).getLeaf().getObjectId();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
@@ -408,6 +525,35 @@
   }
 
   @Test
+  public void filesOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    // list files against auto-merge
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files()
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "foo", "bar");
+
+    // list files against parent 1
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files(1)
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "bar");
+
+    // list files against parent 2
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files(2)
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "foo");
+  }
+
+  @Test
   public void diff() throws Exception {
     PushOneCommit.Result r = createChange();
     DiffInfo diff = gApi.changes()
@@ -420,6 +566,48 @@
   }
 
   @Test
+  public void diffOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    DiffInfo diff;
+
+    // automerge
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("foo")
+        .diff();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("bar")
+        .diff();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 1
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("bar")
+        .diff(1);
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 2
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("foo")
+        .diff(2);
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+  }
+
+  @Test
   public void content() throws Exception {
     PushOneCommit.Result r = createChange();
     BinaryResult bin = gApi.changes()
@@ -441,8 +629,8 @@
       + "/revisions/" + r.getCommit().name()
       + "/files/" + FILE_NAME
       + "/content";
-    RestResponse response = adminSession.head(endPoint);
-    assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+    RestResponse response = adminRestSession.head(endPoint);
+    response.assertOK();
     assertThat(response.getContentType()).startsWith("text/plain");
     assertThat(response.hasContent()).isFalse();
   }
@@ -577,12 +765,39 @@
         Locale.US);
     String date = df.format(rev.commit.author.date);
     assertThat(res).isEqualTo(
-        String.format(PATCH, r.getCommitId().name(), date, r.getChangeId()));
+        String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
   }
 
-  private void merge(PushOneCommit.Result r) throws Exception {
-    revision(r).review(ReviewInput.approve());
-    revision(r).submit();
+  @Test
+  public void actions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(current(r).actions().keySet())
+        .containsExactly("cherrypick", "rebase");
+
+    current(r).review(ReviewInput.approve());
+    assertThat(current(r).actions().keySet())
+        .containsExactly("submit", "cherrypick", "rebase");
+
+    current(r).submit();
+    assertThat(current(r).actions().keySet())
+        .containsExactly("cherrypick");
+  }
+
+  @Test
+  public void actionsETag() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    String oldETag = checkETag(getRevisionActions, r2, null);
+    current(r2).review(ReviewInput.approve());
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
+
+    // Dependent change is included in ETag.
+    current(r1).review(ReviewInput.approve());
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
+
+    current(r2).submit();
+    oldETag = checkETag(getRevisionActions, r2, oldETag);
   }
 
   private PushOneCommit.Result updateChange(PushOneCommit.Result r,
@@ -596,4 +811,15 @@
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     return push.to("refs/drafts/master");
   }
+
+  private RevisionApi current(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChangeId()).current();
+  }
+
+  private String checkETag(ETagView<RevisionResource> view,
+      PushOneCommit.Result r, String oldETag) throws Exception {
+    String eTag = view.getETag(parseRevisionResource(r));
+    assertThat(eTag).isNotEqualTo(oldETag);
+    return eTag;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
new file mode 100644
index 0000000..3fcf2d8
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
@@ -0,0 +1,11 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'edit',
+  srcs = ['ChangeEditIT.java'],
+  deps = [
+    '//lib/commons:codec',
+    '//lib/joda:joda-time',
+  ],
+  labels = ['edit'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 815ea3f..e47d570 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -16,13 +16,9 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.apache.http.HttpStatus.SC_CONFLICT;
-import static org.apache.http.HttpStatus.SC_FORBIDDEN;
-import static org.apache.http.HttpStatus.SC_NOT_FOUND;
-import static org.apache.http.HttpStatus.SC_NO_CONTENT;
-import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
@@ -31,20 +27,24 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.change.ChangeEdits.Post;
@@ -58,14 +58,20 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 
 import org.apache.commons.codec.binary.StringUtils;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
@@ -73,11 +79,11 @@
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 
 public class ChangeEditIT extends AbstractDaemonTest {
 
@@ -93,7 +99,7 @@
   private SchemaFactory<ReviewDb> reviewDbProvider;
 
   @Inject
-  ChangeEditUtil editUtil;
+  private ChangeEditUtil editUtil;
 
   @Inject
   private ChangeEditModifier modifier;
@@ -139,11 +145,22 @@
   }
 
   @Test
+  public void parseEditRevision() throws Exception {
+    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+
+    // check that '0' is parsed as edit revision
+    gApi.changes().id(change.getChangeId()).revision(0).comments();
+
+    // check that 'edit' is parsed as edit revision
+    gApi.changes().id(change.getChangeId()).revision("edit").comments();
+  }
+
+  @Test
   public void deleteEdit() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     editUtil.delete(editUtil.byChange(change).get());
     assertThat(editUtil.byChange(change).isPresent()).isFalse();
   }
@@ -154,7 +171,7 @@
         .isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW2))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW2))).isEqualTo(RefUpdate.Result.FORCED);
     editUtil.publish(editUtil.byChange(change).get());
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
@@ -171,10 +188,9 @@
         RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.post(urlPublish()).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
@@ -190,10 +206,9 @@
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.delete(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.delete(urlEdit()).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
   }
@@ -206,12 +221,10 @@
         RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    RestResponse r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_FORBIDDEN);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+    adminRestSession.post(urlPublish()).assertForbidden();
     setUseContributorAgreements(InheritableBoolean.FALSE);
-    r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.post(urlPublish()).assertNoContent();
   }
 
   @Test
@@ -219,7 +232,7 @@
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     ChangeEdit edit = editUtil.byChange(change).get();
     PatchSet current = getCurrentPatchSet(changeId);
     assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
@@ -242,14 +255,13 @@
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     ChangeEdit edit = editUtil.byChange(change).get();
     PatchSet current = getCurrentPatchSet(changeId);
     assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
         current.getPatchSetId() - 1);
     Date beforeRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    RestResponse r = adminSession.post(urlRebase());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.post(urlRebase()).assertNoContent();
     edit = editUtil.byChange(change).get();
     assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
         ObjectId.fromString(edit.getRevision().get()), FILE_NAME), CONTENT_NEW);
@@ -267,7 +279,7 @@
     assertThat(modifier.createEdit(change2, current)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     ChangeEdit edit = editUtil.byChange(change2).get();
     assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
         current.getPatchSetId());
@@ -275,15 +287,14 @@
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, FILE_NAME,
             new String(CONTENT_NEW2), changeId2);
     push.to("refs/for/master").assertOkStatus();
-    RestResponse r = adminSession.post(urlRebase());
-    assertThat(r.getStatusCode()).isEqualTo(SC_CONFLICT);
+    adminRestSession.post(urlRebase()).assertConflict();
   }
 
   @Test
   public void updateExistingFile() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -329,12 +340,24 @@
   }
 
   @Test
+  public void updateMessageOnlyAddTrailingNewLines() throws Exception {
+    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
+        .isEqualTo(RefUpdate.Result.NEW);
+    Optional<ChangeEdit> edit = editUtil.byChange(change);
+
+    exception.expect(UnchangedCommitMessageException.class);
+    exception.expectMessage(
+        "New commit message cannot be same as existing commit message");
+    modifier.modifyMessage(
+        edit.get(),
+        edit.get().getEditCommit().getFullMessage() + "\n\n");
+  }
+
+  @Test
   public void updateMessage() throws Exception {
     assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
         .isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertUnchangedMessage(edit, edit.get().getEditCommit().getFullMessage());
-    assertUnchangedMessage(edit, edit.get().getEditCommit().getFullMessage() + "\n\n");
     String msg = String.format("New commit message\n\nChange-Id: %s\n",
         change.getKey());
     assertThat(modifier.modifyMessage(edit.get(), msg)).isEqualTo(
@@ -358,27 +381,33 @@
 
   @Test
   public void updateMessageRest() throws Exception {
-    assertThat(adminSession.get(urlEditMessage()).getStatusCode())
-        .isEqualTo(SC_NOT_FOUND);
+    adminRestSession.get(urlEditMessage(false)).assertNotFound();
     EditMessage.Input in = new EditMessage.Input();
     in.message = String.format("New commit message\n\n" +
         CONTENT_NEW2_STR + "\n\nChange-Id: %s\n",
         change.getKey());
-    assertThat(adminSession.put(urlEditMessage(), in).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
-    RestResponse r = adminSession.getJsonAccept(urlEditMessage());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
+    adminRestSession.put(urlEditMessage(false), in).assertNoContent();
+    RestResponse r = adminRestSession.getJsonAccept(urlEditMessage(false));
+    r.assertOK();
     assertThat(readContentFromJson(r)).isEqualTo(in.message);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(edit.get().getEditCommit().getFullMessage())
         .isEqualTo(in.message);
     in.message = String.format("New commit message2\n\nChange-Id: %s\n",
         change.getKey());
-    assertThat(adminSession.put(urlEditMessage(), in).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminRestSession.put(urlEditMessage(false), in).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.get().getEditCommit().getFullMessage())
         .isEqualTo(in.message);
+
+    r = adminRestSession.getJsonAccept(urlEditMessage(true));
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(
+          ObjectId.fromString(ps.getRevision().get()));
+      assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
+    }
+
     editUtil.publish(edit.get());
     assertChangeMessages(change,
         ImmutableList.of("Uploaded patch set 1.",
@@ -388,11 +417,10 @@
 
   @Test
   public void retrieveEdit() throws Exception {
-    RestResponse r = adminSession.get(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.get(urlEdit()).assertNoContent();
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     EditInfo info = toEditInfo(false);
@@ -402,15 +430,14 @@
     edit = editUtil.byChange(change);
     editUtil.delete(edit.get());
 
-    r = adminSession.get(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.get(urlEdit()).assertNoContent();
   }
 
   @Test
   public void retrieveFilesInEdit() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
 
     EditInfo info = toEditInfo(true);
@@ -448,8 +475,7 @@
 
   @Test
   public void createEditByDeletingExistingFileRest() throws Exception {
-    RestResponse r = adminSession.delete(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -458,15 +484,13 @@
 
   @Test
   public void deletingNonExistingEditRest() throws Exception {
-    RestResponse r = adminSession.delete(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    adminRestSession.delete(urlEdit()).assertNotFound();
   }
 
   @Test
   public void deleteExistingFileRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.delete(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -497,7 +521,7 @@
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change2);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
@@ -515,8 +539,7 @@
     Post.Input in = new Post.Input();
     in.oldPath = FILE_NAME;
     in.newPath = FILE_NAME3;
-    assertThat(adminSession.post(urlEdit(), in).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.post(urlEdit(), in).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME3), CONTENT_OLD);
@@ -529,8 +552,7 @@
   public void restoreDeletedFileInPatchSetRest() throws Exception {
     Post.Input in = new Post.Input();
     in.restorePath = FILE_NAME;
-    assertThat(adminSession.post(urlEdit2(), in).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.post(urlEdit2(), in).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change2);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
@@ -540,12 +562,12 @@
   public void amendExistingFile() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW2)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW2)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -555,15 +577,13 @@
   @Test
   public void createAndChangeEditInOneRequestRest() throws Exception {
     Put.Input in = new Put.Input();
-    in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
-    in.content = RestSession.newRawInput(CONTENT_NEW2);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    in.content = RawInputUtil.create(CONTENT_NEW2);
+    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW2);
@@ -573,9 +593,8 @@
   public void changeEditRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Put.Input in = new Put.Input();
-    in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
@@ -584,8 +603,7 @@
   @Test
   public void emptyPutRequest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.put(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.put(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), "".getBytes());
@@ -593,8 +611,7 @@
 
   @Test
   public void createEmptyEditRest() throws Exception {
-    assertThat(adminSession.post(urlEdit()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.post(urlEdit()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
@@ -603,27 +620,29 @@
   @Test
   public void getFileContentRest() throws Exception {
     Put.Input in = new Put.Input();
-    in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    in.content = RawInputUtil.create(CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW2)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW2)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
-    RestResponse r = adminSession.getJsonAccept(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
+    RestResponse r = adminRestSession.getJsonAccept(urlEditFile());
+    r.assertOK();
     assertThat(readContentFromJson(r)).isEqualTo(
         StringUtils.newStringUtf8(CONTENT_NEW2));
+
+    r = adminRestSession.getJsonAccept(urlEditFile(true));
+    r.assertOK();
+    assertThat(readContentFromJson(r)).isEqualTo(
+        StringUtils.newStringUtf8(CONTENT_OLD));
   }
 
   @Test
   public void getFileNotFoundRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.delete(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminRestSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.get(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminRestSession.get(urlEditFile()).assertNoContent();
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
@@ -633,7 +652,7 @@
   public void addNewFile() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -644,12 +663,12 @@
   public void addNewFileAndAmend() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RestSession.newRawInput(CONTENT_NEW)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME2), CONTENT_NEW);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RestSession.newRawInput(CONTENT_NEW2)))
+    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW2)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -664,7 +683,7 @@
     modifier.modifyFile(
         editUtil.byChange(change).get(),
         FILE_NAME,
-        RestSession.newRawInput(CONTENT_OLD));
+        RawInputUtil.create(CONTENT_OLD));
   }
 
   @Test
@@ -701,14 +720,6 @@
     assertThat(approvals.get(0).value).isEqualTo(1);
   }
 
-  private void assertUnchangedMessage(Optional<ChangeEdit> edit, String message)
-      throws Exception {
-    exception.expect(UnchangedCommitMessageException.class);
-    exception.expectMessage(
-        "New commit message cannot be same as existing commit message");
-    modifier.modifyMessage(edit.get(), message);
-  }
-
   @Test
   public void testHasEditPredicate() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
@@ -718,12 +729,12 @@
     assertThat(modifier.createEdit(change2, current)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
         modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     assertThat(queryEdits()).hasSize(2);
 
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     editUtil.delete(editUtil.byChange(change).get());
     assertThat(queryEdits()).hasSize(1);
 
@@ -738,6 +749,63 @@
     assertThat(queryEdits()).hasSize(0);
   }
 
+  @Test
+  public void files() throws Exception {
+    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    ChangeEdit edit = editUtil.byChange(change).get();
+    assertThat(modifier.modifyFile(edit, FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
+        .isEqualTo(RefUpdate.Result.FORCED);
+    edit = editUtil.byChange(change).get();
+
+    RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(edit));
+    Map<String, FileInfo> files = readContentFromJson(
+        r, new TypeToken<Map<String, FileInfo>>() {});
+    assertThat(files).containsKey(FILE_NAME);
+
+    r = adminRestSession.getJsonAccept(urlRevisionFiles());
+    files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
+    assertThat(files).containsKey(FILE_NAME);
+  }
+
+  @Test
+  public void diff() throws Exception {
+    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    ChangeEdit edit = editUtil.byChange(change).get();
+    assertThat(modifier.modifyFile(edit, FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
+        .isEqualTo(RefUpdate.Result.FORCED);
+    edit = editUtil.byChange(change).get();
+
+    RestResponse r = adminRestSession.getJsonAccept(urlDiff(edit));
+    DiffInfo diff = readContentFromJson(r, DiffInfo.class);
+    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
+
+    r = adminRestSession.getJsonAccept(urlDiff());
+    diff = readContentFromJson(r, DiffInfo.class);
+    assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
+  }
+
+  @Test
+  public void createEditWithoutPushPatchSetPermission() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSetEdit");
+    // Clone repository as user
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(p, user);
+
+    // Block default permission
+    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+
+    // Create change as user
+    PushOneCommit push = pushFactory.create(
+        db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Try to create edit as admin
+    assertThat(modifier.createEdit(r1.getChange().change(),
+        r1.getPatchSet())).isEqualTo(RefUpdate.Result.REJECTED);
+  }
+
   private List<ChangeInfo> queryEdits() throws Exception {
     return query("project:{" + project.get() + "} has:edit");
   }
@@ -768,8 +836,8 @@
   }
 
   private PatchSet getCurrentPatchSet(String changeId) throws Exception {
-    return db.patchSets()
-        .get(getChange(changeId).currentPatchSetId());
+    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId))
+        .currentPatchSet();
   }
 
   private static void assertByteArray(BinaryResult result, byte[] expected)
@@ -791,16 +859,22 @@
         + "/edit/";
   }
 
-  private String urlEditMessage() {
+  private String urlEditMessage(boolean base) {
     return "/changes/"
         + change.getChangeId()
-        + "/edit:message";
+        + "/edit:message"
+        + (base ? "?base" : "");
   }
 
   private String urlEditFile() {
+    return urlEditFile(false);
+  }
+
+  private String urlEditFile(boolean base) {
     return urlEdit()
         + "/"
-        + FILE_NAME;
+        + FILE_NAME
+        + (base ? "?base" : "");
   }
 
   private String urlGetFiles() {
@@ -808,6 +882,20 @@
         + "?list";
   }
 
+  private String urlRevisionFiles(ChangeEdit edit) {
+    return "/changes/"
+      + change.getChangeId()
+      + "/revisions/"
+      + edit.getRevision().get()
+      + "/files";
+  }
+
+  private String urlRevisionFiles() {
+    return "/changes/"
+      + change.getChangeId()
+      + "/revisions/0/files";
+  }
+
   private String urlPublish() {
     return "/changes/"
         + change.getChangeId()
@@ -820,16 +908,47 @@
         + "/edit:rebase";
   }
 
-  private EditInfo toEditInfo(boolean files) throws IOException {
-    RestResponse r = adminSession.get(files ? urlGetFiles() : urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
-    return newGson().fromJson(r.getReader(), EditInfo.class);
+  private String urlDiff() {
+    return "/changes/"
+        + change.getChangeId()
+        + "/revisions/0/files/"
+        + FILE_NAME
+        + "/diff?context=ALL&intraline";
   }
 
-  private String readContentFromJson(RestResponse r) throws IOException {
+  private String urlDiff(ChangeEdit edit) {
+    return "/changes/"
+        + change.getChangeId()
+        + "/revisions/"
+        + edit.getRevision().get()
+        + "/files/"
+        + FILE_NAME
+        + "/diff?context=ALL&intraline";
+  }
+
+  private EditInfo toEditInfo(boolean files) throws Exception {
+    RestResponse r = adminRestSession.get(files ? urlGetFiles() : urlEdit());
+    return readContentFromJson(r, EditInfo.class);
+  }
+
+  private <T> T readContentFromJson(RestResponse r, Class<T> clazz)
+      throws Exception {
+    r.assertOK();
     JsonReader jsonReader = new JsonReader(r.getReader());
     jsonReader.setLenient(true);
-    return newGson().fromJson(jsonReader, String.class);
+    return newGson().fromJson(jsonReader, clazz);
+  }
+
+  private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken)
+      throws Exception {
+    r.assertOK();
+    JsonReader jsonReader = new JsonReader(r.getReader());
+    jsonReader.setLenient(true);
+    return newGson().fromJson(jsonReader, typeToken.getType());
+  }
+
+  private String readContentFromJson(RestResponse r) throws Exception {
+    return readContentFromJson(r, String.class);
   }
 
   private void assertChangeMessages(Change c, List<String> expectedMessages)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 50fbec2..d71cfb4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -16,34 +16,63 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.TestTimeUtil;
 
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
@@ -53,6 +82,7 @@
   }
 
   private String sshUrl;
+  private LabelType patchSetLock;
 
   @BeforeClass
   public static void setTimeForTesting() {
@@ -66,7 +96,16 @@
 
   @Before
   public void setUp() throws Exception {
-    sshUrl = sshSession.getUrl();
+    sshUrl = adminSshSession.getUrl();
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    patchSetLock = Util.patchSetLock();
+    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
+    AccountGroup.UUID anonymousUsers =
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers,
+        "refs/heads/*");
+    saveProjectConfig(cfg);
+    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
   }
 
   protected void selectProtocol(Protocol p) throws Exception {
@@ -85,14 +124,14 @@
   }
 
   @Test
-  public void testPushForMaster() throws Exception {
+  public void pushForMaster() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, null);
   }
 
   @Test
-  public void testOutput() throws Exception {
+  public void output() throws Exception {
     String url = canonicalWebUrl.get();
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
     PushOneCommit.Result r1 = pushTo("refs/for/master");
@@ -125,7 +164,7 @@
   }
 
   @Test
-  public void testPushForMasterWithTopic() throws Exception {
+  public void pushForMasterWithTopic() throws Exception {
     // specify topic in ref
     String topic = "my/topic";
     PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
@@ -139,7 +178,41 @@
   }
 
   @Test
-  public void testPushForMasterWithCc() throws Exception {
+  public void pushForMasterWithNotify() throws Exception {
+    TestAccount user2 = accounts.user2();
+    String pushSpec = "refs/for/master"
+        + "%reviewer=" + user.email
+        + ",cc=" + user2.email;
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).hasSize(0);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER);
+    r.assertOkStatus();
+    // no email notification about own changes
+    assertThat(sender.getMessages()).hasSize(0);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER_REVIEWERS);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.ALL);
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).hasSize(1);
+    m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress);
+  }
+
+  @Test
+  public void pushForMasterWithCc() throws Exception {
     // cc one user
     String topic = "my/topic";
     PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email);
@@ -162,7 +235,7 @@
   }
 
   @Test
-  public void testPushForMasterWithReviewer() throws Exception {
+  public void pushForMasterWithReviewer() throws Exception {
     // add one reviewer
     String topic = "my/topic";
     PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email);
@@ -186,7 +259,7 @@
   }
 
   @Test
-  public void testPushForMasterAsDraft() throws Exception {
+  public void pushForMasterAsDraft() throws Exception {
     // create draft by pushing to 'refs/drafts/'
     PushOneCommit.Result r = pushTo("refs/drafts/master");
     r.assertOkStatus();
@@ -199,7 +272,20 @@
   }
 
   @Test
-  public void testPushForMasterAsEdit() throws Exception {
+  public void publishDraftChangeByPushingNonDraftPatchSet() throws Exception {
+    // create draft change
+    PushOneCommit.Result r = pushTo("refs/drafts/master");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.DRAFT, null);
+
+    // publish draft change by pushing non-draft patch set
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+  }
+
+  @Test
+  public void pushForMasterAsEdit() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     EditInfo edit = getEdit(r.getChangeId());
@@ -217,7 +303,21 @@
   }
 
   @Test
-  public void testPushForMasterWithApprovals() throws Exception {
+  public void pushForMasterWithMessage() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%m=my_test_message");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId());
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message).isEqualTo(
+          "Uploaded patch set 1.\nmy test message");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithApprovals() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review");
     r.assertOkStatus();
     ChangeInfo ci = get(r.getChangeId());
@@ -262,7 +362,7 @@
    * applied on behalf of the uploader a single label is sufficient.
    */
   @Test
-  public void testPushForMasterWithApprovalsForgeCommitterButNoForgeVote()
+  public void pushForMasterWithApprovalsForgeCommitterButNoForgeVote()
       throws Exception {
     // Create a commit with "User" as author and committer
     RevCommit c = commitBuilder()
@@ -296,7 +396,38 @@
   }
 
   @Test
-  public void testPushNewPatchsetToRefsChanges() throws Exception {
+  public void pushWithMultipleApprovals()
+      throws Exception {
+    LabelType Q = category("Custom-Label",
+        value(1, "Positive"),
+        value(0, "No score"),
+        value(-1, "Negative"));
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anon =
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    String heads = "refs/heads/*";
+    Util.allow(config, Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
+    config.getLabelSections().put(Q.getName(), Q);
+    saveProjectConfig(project, config);
+
+    RevCommit c = commitBuilder()
+        .author(admin.getIdent())
+        .committer(admin.getIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+        .message(PushOneCommit.SUBJECT)
+        .create();
+
+    pushHead(testRepo, "refs/for/master/%l=Code-Review+1,l=Custom-Label-1", false);
+
+    ChangeInfo ci = get(GitUtil.getChangeId(testRepo, c).get());
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(cr.all).hasSize(1);
+    cr = ci.labels.get("Custom-Label");
+    assertThat(cr.all).hasSize(1);
+  }
+
+  @Test
+  public void pushNewPatchsetToRefsChanges() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     PushOneCommit push =
@@ -307,29 +438,41 @@
   }
 
   @Test
-  public void testPushForMasterWithApprovals_MissingLabel() throws Exception {
+  public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, "b.txt", "anotherContent", r.getChangeId());
+    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
+    r = push.to("refs/for/master");
+    r.assertErrorStatus("cannot add patch set to "
+        + r.getChange().change().getChangeId()
+        + ". Change is patch set locked.");
+  }
+
+  @Test
+  public void pushForMasterWithApprovals_MissingLabel() throws Exception {
       PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
       r.assertErrorStatus("label \"Verify\" is not a configured label");
   }
 
   @Test
-  public void testPushForMasterWithApprovals_ValueOutOfRange() throws Exception {
+  public void pushForMasterWithApprovals_ValueOutOfRange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review-3");
     r.assertErrorStatus("label \"Code-Review\": -3 is not a valid value");
   }
 
   @Test
-  public void testPushForNonExistingBranch() throws Exception {
+  public void pushForNonExistingBranch() throws Exception {
     String branchName = "non-existing";
     PushOneCommit.Result r = pushTo("refs/for/" + branchName);
     r.assertErrorStatus("branch " + branchName + " not found");
   }
 
   @Test
-  public void testPushForMasterWithHashtags() throws Exception {
-
-    // Hashtags currently only work when noteDB is enabled
-    assume().that(notesMigration.enabled()).isTrue();
+  public void pushForMasterWithHashtags() throws Exception {
+    // Hashtags only work when reading from NoteDB is enabled
+    assume().that(notesMigration.readChanges()).isTrue();
 
     // specify a single hashtag as option
     String hashtag1 = "tag1";
@@ -354,10 +497,9 @@
   }
 
   @Test
-  public void testPushForMasterWithMultipleHashtags() throws Exception {
-
-    // Hashtags currently only work when noteDB is enabled
-    assume().that(notesMigration.enabled()).isTrue();
+  public void pushForMasterWithMultipleHashtags() throws Exception {
+    // Hashtags only work when reading from NoteDB is enabled
+    assume().that(notesMigration.readChanges()).isTrue();
 
     // specify multiple hashtags as options
     String hashtag1 = "tag1";
@@ -385,15 +527,15 @@
   }
 
   @Test
-  public void testPushForMasterWithHashtagsNoteDbDisabled() throws Exception {
-    // push with hashtags should fail when noteDb is disabled
-    assume().that(notesMigration.enabled()).isFalse();
+  public void pushForMasterWithHashtagsNoteDbDisabled() throws Exception {
+    // Push with hashtags should fail when reading from NoteDb is disabled.
+    assume().that(notesMigration.readChanges()).isFalse();
     PushOneCommit.Result r = pushTo("refs/for/master%hashtag=tag1");
     r.assertErrorStatus("cannot add hashtags; noteDb is disabled");
   }
 
   @Test
-  public void testPushCommitUsingSignedOffBy() throws Exception {
+  public void pushCommitUsingSignedOffBy() throws Exception {
     PushOneCommit push =
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
             "b.txt", "anotherContent");
@@ -418,10 +560,8 @@
   }
 
   @Test
-  public void testCreateNewChangeForAllNotInTarget() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
+  public void createNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
@@ -446,7 +586,7 @@
   }
 
   @Test
-  public void testPushSameCommitTwiceUsingMagicBranchBaseOption()
+  public void pushSameCommitTwiceUsingMagicBranchBaseOption()
       throws Exception {
     grant(Permission.PUSH, project, "refs/heads/master");
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
@@ -465,7 +605,7 @@
     r.assertOkStatus();
 
     PushResult pr = GitUtil.pushHead(
-        testRepo, "refs/for/foo%base=" + rBase.getCommitId().name(), false, false);
+        testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
     assertThat(pr.getMessages()).contains("changes: new: 1, refs: 1, done");
 
     assertTwoChangesWithSameRevision(r);
@@ -473,7 +613,7 @@
 
   private void assertTwoChangesWithSameRevision(PushOneCommit.Result result)
       throws Exception {
-    List<ChangeInfo> changes = query(result.getCommitId().name());
+    List<ChangeInfo> changes = query(result.getCommit().name());
     assertThat(changes).hasSize(2);
     ChangeInfo c1 = get(changes.get(0).id);
     ChangeInfo c2 = get(changes.get(1).id);
@@ -482,4 +622,416 @@
     assertThat(c1.changeId).isEqualTo(c2.changeId);
     assertThat(c1.currentRevision).isEqualTo(c2.currentRevision);
   }
+
+  @Test
+  public void pushAFewChanges() throws Exception {
+    int n = 10;
+    String r = "refs/for/master";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(n, r);
+
+    // Check that a change was created for each.
+    for (RevCommit c : commits) {
+      assertThat(byCommit(c).change().getSubject())
+          .named("change for " + c.name())
+          .isEqualTo(c.getShortMessage());
+    }
+
+    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
+
+    // Check that there are correct patch sets.
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits.get(i);
+      RevCommit c2 = commits2.get(i);
+      String name = "change for " + c2.name();
+      ChangeData cd = byCommit(c);
+      assertThat(cd.change().getSubject())
+          .named(name)
+          .isEqualTo(c2.getShortMessage());
+      assertThat(getPatchSetRevisions(cd)).named(name).containsExactlyEntriesIn(
+          ImmutableMap.of(1, c.name(), 2, c2.name()));
+    }
+
+    // Pushing again results in "no new changes".
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+  }
+
+  @Test
+  public void pushWithoutChangeId() throws Exception {
+    testPushWithoutChangeId();
+  }
+
+  @Test
+  public void pushWithoutChangeIdWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithoutChangeId();
+  }
+
+  private void testPushWithoutChangeId() throws Exception {
+    RevCommit c = createCommit(testRepo, "Message without Change-Id");
+    assertThat(GitUtil.getChangeId(testRepo, c).isPresent()).isFalse();
+    pushForReviewRejected(testRepo,
+        "missing Change-Id in commit message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewOk(testRepo);
+  }
+
+  @Test
+  public void pushWithMultipleChangeIds() throws Exception {
+    testPushWithMultipleChangeIds();
+  }
+
+  @Test
+  public void pushWithMultipleChangeIdsWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithMultipleChangeIds();
+  }
+
+  private void testPushWithMultipleChangeIds() throws Exception {
+    createCommit(testRepo,
+        "Message with multiple Change-Id\n"
+            + "\n"
+            + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
+            + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
+    pushForReviewRejected(testRepo,
+        "multiple Change-Id lines in commit message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewRejected(testRepo,
+        "multiple Change-Id lines in commit message footer");
+  }
+
+  @Test
+  public void pushWithInvalidChangeId() throws Exception {
+    testpushWithInvalidChangeId();
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testpushWithInvalidChangeId();
+  }
+
+  private void testpushWithInvalidChangeId() throws Exception {
+    createCommit(testRepo, "Message with invalid Change-Id\n"
+        + "\n"
+        + "Change-Id: X\n");
+    pushForReviewRejected(testRepo,
+        "invalid Change-Id line format in commit message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewRejected(testRepo,
+        "invalid Change-Id line format in commit message footer");
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdFromEgit() throws Exception {
+    testPushWithInvalidChangeIdFromEgit();
+  }
+
+  @Test
+  public void pushWithInvalidChangeIdFromEgitWithCreateNewChangeForAllNotInTarget()
+      throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushWithInvalidChangeIdFromEgit();
+  }
+
+  private void testPushWithInvalidChangeIdFromEgit() throws Exception {
+    createCommit(testRepo, "Message with invalid Change-Id\n"
+        + "\n"
+        + "Change-Id: I0000000000000000000000000000000000000000\n");
+    pushForReviewRejected(testRepo,
+        "invalid Change-Id line format in commit message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewRejected(testRepo,
+        "invalid Change-Id line format in commit message footer");
+  }
+
+  @Test
+  public void pushWithChangeIdInSubjectLine() throws Exception {
+    createCommit(testRepo, "Change-Id: I1234000000000000000000000000000000000000");
+    pushForReviewRejected(testRepo,
+        "missing subject; Change-Id must be in commit message footer");
+
+    setRequireChangeId(InheritableBoolean.FALSE);
+    pushForReviewRejected(testRepo,
+        "missing subject; Change-Id must be in commit message footer");
+  }
+
+  private static RevCommit createCommit(TestRepository<?> testRepo,
+      String message) throws Exception {
+    return testRepo.branch("HEAD").commit().message(message)
+        .add("a.txt", "content").create();
+  }
+
+  @Test
+  public void cantAutoCloseChangeAlreadyMergedToBranch() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id2 = r2.getChange().getId();
+
+    // Merge change 1 behind Gerrit's back.
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/master").update(r1.getCommit());
+    }
+
+    assertThat(gApi.changes().id(id1.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    r2 = amendChange(r2.getChangeId());
+    r2.assertOkStatus();
+
+    // Change 1 is still new despite being merged into the branch, because
+    // ReceiveCommits only considers commits between the branch tip (which is
+    // now the merged change 1) and the push tip (new patch set of change 2).
+    assertThat(gApi.changes().id(id1.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void accidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev =
+        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/changes/" + id;
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    // Added a new patch set and auto-closed the change.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(
+            1, ps1Rev,
+            2, testRepo.getRepository().resolve("HEAD").name()));
+  }
+
+  @Test
+  public void accidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev =
+        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/for/master";
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+
+    // Change not updated.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(1, ps1Rev));
+  }
+
+  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    RevCommit ps1Commit = r.getCommit();
+    Change c = r.getChange().change();
+
+    RevCommit ps2Commit;
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Create a new patch set of the change directly in Gerrit's repository,
+      // without pushing it. In reality it's more likely that the client would
+      // create and push this behind Gerrit's back (e.g. an admin accidentally
+      // using direct ssh access to the repo), but that's harder to do in tests.
+      TestRepository<?> tr = new TestRepository<>(repo);
+      ps2Commit = tr.branch("refs/heads/master").commit()
+          .message(ps1Commit.getShortMessage() + " v2")
+          .insertChangeId(r.getChangeId().substring(1))
+          .create();
+    }
+
+    testRepo.git().fetch()
+        .setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(ps2Commit);
+
+    ChangeData cd = byCommit(ps1Commit);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(1, ps1Commit.name()));
+    return c.getId();
+  }
+
+  @Test
+  public void pushWithEmailInFooter() throws Exception {
+    pushWithReviewerInFooter(user.emailAddress.toString(), user);
+  }
+
+  @Test
+  public void pushWithNameInFooter() throws Exception {
+    pushWithReviewerInFooter(user.fullName, user);
+  }
+
+  @Test
+  public void pushWithEmailInFooterNotFound() throws Exception {
+    pushWithReviewerInFooter(
+        new Address("No Body", "notarealuser@example.com").toString(),
+        null);
+  }
+
+  @Test
+  public void pushWithNameInFooterNotFound() throws Exception {
+    pushWithReviewerInFooter("Notauser", null);
+  }
+
+  @Test
+  // TODO(dborowitz): This is to exercise a specific case in the database search
+  // path. Once the account index becomes obligatory this method can be removed.
+  @GerritConfig(name = "index.testDisable", value = "accounts")
+  public void pushWithNameInFooterNotFoundWithDbSearch() throws Exception {
+    pushWithReviewerInFooter("Notauser", null);
+  }
+
+  private void pushWithReviewerInFooter(String nameEmail,
+      TestAccount expectedReviewer) throws Exception {
+    int n = 5;
+    String r = "refs/for/master";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits =
+        createChanges(n, r, ImmutableList.of("Acked-By: " + nameEmail));
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits.get(i);
+      ChangeData cd = byCommit(c);
+      String name = "reviewers for " + (i + 1);
+      if (expectedReviewer != null) {
+        assertThat(cd.reviewers().all()).named(name)
+            .containsExactly(expectedReviewer.getId());
+        gApi.changes()
+            .id(cd.getId().get())
+            .reviewer(expectedReviewer.getId().toString())
+            .remove();
+      }
+      assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+    }
+
+    List<RevCommit> commits2 = amendChanges(initialHead, commits, r);
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits2.get(i);
+      ChangeData cd = byCommit(c);
+      String name = "reviewers for " + (i + 1);
+      if (expectedReviewer != null) {
+        assertThat(cd.reviewers().all()).named(name)
+            .containsExactly(expectedReviewer.getId());
+      } else {
+        assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
+      }
+    }
+  }
+
+  private List<RevCommit> createChanges(int n, String refsFor)
+      throws Exception {
+    return createChanges(n, refsFor, ImmutableList.<String>of());
+  }
+
+  private List<RevCommit> createChanges(int n, String refsFor,
+      List<String> footerLines) throws Exception {
+    List<RevCommit> commits = new ArrayList<>(n);
+    for (int i = 1; i <= n; i++) {
+      String msg = "Change " + i;
+      if (!footerLines.isEmpty()) {
+        StringBuilder sb = new StringBuilder(msg).append("\n\n");
+        for (String line : footerLines) {
+          sb.append(line).append('\n');
+        }
+        msg = sb.toString();
+      }
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
+          .message(msg).insertChangeId();
+      if (!commits.isEmpty()) {
+        cb.parent(commits.get(commits.size() - 1));
+      }
+      RevCommit c = cb.create();
+      testRepo.getRevWalk().parseBody(c);
+      commits.add(c);
+    }
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return commits;
+  }
+
+  private List<RevCommit> amendChanges(ObjectId initialHead,
+      List<RevCommit> origCommits, String refsFor) throws Exception {
+    testRepo.reset(initialHead);
+    List<RevCommit> newCommits = new ArrayList<>(origCommits.size());
+    for (RevCommit c : origCommits) {
+      String msg = c.getShortMessage() + "v2";
+      if (!c.getShortMessage().equals(c.getFullMessage())) {
+        msg = msg + c.getFullMessage().substring(c.getShortMessage().length());
+      }
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
+          .message(msg);
+      if (!newCommits.isEmpty()) {
+        cb.parent(origCommits.get(newCommits.size() - 1));
+      }
+      RevCommit c2 = cb.create();
+      testRepo.getRevWalk().parseBody(c2);
+      newCommits.add(c2);
+    }
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return newCommits;
+  }
+
+  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd)
+      throws Exception {
+    Map<Integer, String> revisions = new HashMap<>();
+    for (PatchSet ps : cd.patchSets()) {
+      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
+    }
+    return revisions;
+  }
+
+  private ChangeData byCommit(ObjectId id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byCommit(id);
+    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    return cds.get(0);
+  }
+
+  private ChangeData byChangeId(Change.Id id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
+    assertThat(cds).named("change " + id).hasSize(1);
+    return cds.get(0);
+  }
+
+  private static void pushForReviewOk(TestRepository<?> testRepo)
+      throws GitAPIException {
+    pushForReview(testRepo, RemoteRefUpdate.Status.OK, null);
+  }
+
+  private static void pushForReviewRejected(TestRepository<?> testRepo,
+      String expectedMessage) throws GitAPIException {
+    pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON,
+        expectedMessage);
+  }
+
+  private static void pushForReview(TestRepository<?> testRepo,
+      RemoteRefUpdate.Status expectedStatus, String expectedMessage)
+          throws GitAPIException {
+    String ref = "refs/for/master";
+    PushResult r = pushHead(testRepo, ref);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
+    assertThat(refUpdate.getStatus()).isEqualTo(expectedStatus);
+    if (expectedMessage != null) {
+      assertThat(refUpdate.getMessage()).contains(expectedMessage);
+    }
+  }
+
+  private void enableCreateNewChangeForAllNotInTarget() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject()
+        .setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 53412cb..8b77238 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -16,52 +16,166 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 
 import java.util.concurrent.atomic.AtomicInteger;
 
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
+
+  protected SubmitType getSubmitType() {
+    return cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+  }
+
+  protected static Config submitByMergeAlways() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.MERGE_ALWAYS);
+    return cfg;
+  }
+
+  protected static Config submitByMergeIfNecessary() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+    return cfg;
+  }
+
+  protected static Config submitByCherryPickConifg() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.CHERRY_PICK);
+    return cfg;
+  }
+
+  protected static Config submitByRebaseConifg() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.REBASE_IF_NECESSARY);
+    return cfg;
+  }
+
+  protected TestRepository<?> createProjectWithPush(String name,
+      @Nullable Project.NameKey parent, boolean createEmptyCommit,
+      SubmitType submitType) throws Exception {
+    Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
+    grant("push", project, "refs/heads/*");
+    grant("submit", project, "refs/for/refs/heads/*");
+    return cloneProject(project);
+  }
+
+  protected TestRepository<?> createProjectWithPush(String name,
+      @Nullable Project.NameKey parent) throws Exception {
+    return createProjectWithPush(name, parent, true, getSubmitType());
+  }
+
+  protected TestRepository<?> createProjectWithPush(String name,
+      boolean createEmptyCommit) throws Exception {
+    return createProjectWithPush(name, null, createEmptyCommit, getSubmitType());
+  }
+
   protected TestRepository<?> createProjectWithPush(String name)
       throws Exception {
-    Project.NameKey project = createProject(name);
-    grant(Permission.PUSH, project, "refs/heads/*");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
-    return cloneProject(project);
+    return createProjectWithPush(name, null, true, getSubmitType());
   }
 
   private static AtomicInteger contentCounter = new AtomicInteger(0);
 
   protected ObjectId pushChangeTo(TestRepository<?> repo, String ref,
-      String message, String topic) throws Exception {
+      String file, String content, String message, String topic)
+      throws Exception {
     ObjectId ret = repo.branch("HEAD").commit().insertChangeId()
       .message(message)
-      .add("a.txt", "a contents: " + contentCounter.incrementAndGet())
+      .add(file, content)
       .create();
-    String refspec = "HEAD:" + ref;
+
+    String pushedRef = ref;
     if (!topic.isEmpty()) {
-      refspec += "/" + topic;
+      pushedRef += "/" + name(topic);
     }
-    repo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec(refspec)).call();
+    String refspec = "HEAD:" + pushedRef;
+
+    Iterable<PushResult> res = repo.git().push()
+        .setRemote("origin").setRefSpecs(new RefSpec(refspec)).call();
+
+    RemoteRefUpdate u = Iterables.getOnlyElement(res).getRemoteUpdate(pushedRef);
+    assertThat(u).isNotNull();
+    assertThat(u.getStatus()).isEqualTo(Status.OK);
+    assertThat(u.getNewObjectId()).isEqualTo(ret);
+
     return ret;
   }
 
+  protected ObjectId pushChangeTo(TestRepository<?> repo, String ref,
+      String message, String topic) throws Exception {
+    return pushChangeTo(repo, ref, "a.txt",
+        "a contents: " + contentCounter.incrementAndGet(), message, topic);
+  }
+
   protected ObjectId pushChangeTo(TestRepository<?> repo, String branch)
       throws Exception {
     return pushChangeTo(repo, "refs/heads/" + branch, "some change", "");
   }
 
+  protected void allowSubmoduleSubscription(String submodule,
+      String subBranch, String superproject, String superBranch, boolean match)
+      throws Exception {
+    Project.NameKey sub = new Project.NameKey(name(submodule));
+    Project.NameKey superName = new Project.NameKey(name(superproject));
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(sub)) {
+      md.setMessage("Added superproject subscription");
+      SubscribeSection s;
+      ProjectConfig pc = ProjectConfig.read(md);
+      if (pc.getSubscribeSections().containsKey(superName)) {
+        s = pc.getSubscribeSections().get(superName);
+      } else {
+        s = new SubscribeSection(superName);
+      }
+      String refspec;
+      if (superBranch == null) {
+        refspec = subBranch;
+      } else {
+        refspec = subBranch + ":" + superBranch;
+      }
+      if (match) {
+        s.addMatchingRefSpec(refspec);
+      } else {
+        s.addMultiMatchRefSpec(refspec);
+      }
+      pc.addSubscribeSection(s);
+      ObjectId oldId = pc.getRevision();
+      ObjectId newId = pc.commit(md);
+      assertThat(newId).isNotEqualTo(oldId);
+      projectCache.evict(pc.getProject());
+    }
+  }
+
+  protected void allowMatchingSubmoduleSubscription(String submodule,
+      String subBranch, String superproject, String superBranch)
+      throws Exception {
+    allowSubmoduleSubscription(submodule, subBranch, superproject,
+        superBranch, true);
+  }
+
   protected void createSubmoduleSubscription(TestRepository<?> repo, String branch,
       String subscribeToRepo, String subscribeToBranch) throws Exception {
     Config config = new Config();
@@ -69,18 +183,48 @@
     pushSubmoduleConfig(repo, branch, config);
   }
 
+  protected void createRelativeSubmoduleSubscription(TestRepository<?> repo,
+      String branch, String subscribeToRepoPrefix, String subscribeToRepo,
+      String subscribeToBranch) throws Exception {
+    Config config = new Config();
+    prepareRelativeSubmoduleConfigEntry(config, subscribeToRepoPrefix,
+        subscribeToRepo, subscribeToBranch);
+    pushSubmoduleConfig(repo, branch, config);
+  }
+
+  protected void prepareRelativeSubmoduleConfigEntry(Config config,
+      String subscribeToRepoPrefix, String subscribeToRepo,
+      String subscribeToBranch) {
+    subscribeToRepo = name(subscribeToRepo);
+    String url = subscribeToRepoPrefix + subscribeToRepo;
+    config.setString("submodule", subscribeToRepo, "path", subscribeToRepo);
+    config.setString("submodule", subscribeToRepo, "url", url);
+    if (subscribeToBranch != null) {
+      config.setString("submodule", subscribeToRepo, "branch", subscribeToBranch);
+    }
+  }
+
   protected void prepareSubmoduleConfigEntry(Config config,
       String subscribeToRepo, String subscribeToBranch) {
+    // The submodule subscription module checks for gerrit.canonicalWebUrl to
+    // detect if it's configured for automatic updates. It doesn't matter if
+    // it serves from that URL.
+    prepareSubmoduleConfigEntry(config, subscribeToRepo, subscribeToRepo, subscribeToBranch);
+  }
+
+  protected void prepareSubmoduleConfigEntry(Config config,
+      String subscribeToRepo, String subscribeToRepoPath, String subscribeToBranch) {
     subscribeToRepo = name(subscribeToRepo);
+    subscribeToRepoPath = name(subscribeToRepoPath);
     // The submodule subscription module checks for gerrit.canonicalWebUrl to
     // detect if it's configured for automatic updates. It doesn't matter if
     // it serves from that URL.
     String url = cfg.getString("gerrit", null, "canonicalWebUrl") + "/"
         + subscribeToRepo;
-    config.setString("submodule", subscribeToRepo, "path", subscribeToRepo);
-    config.setString("submodule", subscribeToRepo, "url", url);
+    config.setString("submodule", subscribeToRepoPath, "path", subscribeToRepoPath);
+    config.setString("submodule", subscribeToRepoPath, "url", url);
     if (subscribeToBranch != null) {
-      config.setString("submodule", subscribeToRepo, "branch", subscribeToBranch);
+      config.setString("submodule", subscribeToRepoPath, "branch", subscribeToBranch);
     }
   }
 
@@ -97,20 +241,110 @@
   }
 
   protected void expectToHaveSubmoduleState(TestRepository<?> repo,
+      String branch, String submodule, TestRepository<?> subRepo,
+      String subBranch) throws Exception {
+
+    submodule = name(submodule);
+    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+
+    ObjectId subHead = subRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/" + subBranch).getObjectId();
+
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    rw.parseBody(c.getTree());
+
+    RevTree tree = c.getTree();
+    RevObject actualId = repo.get(tree, submodule);
+
+    assertThat(actualId).isEqualTo(subHead);
+  }
+
+  protected void expectToHaveSubmoduleState(TestRepository<?> repo,
       String branch, String submodule, ObjectId expectedId) throws Exception {
 
     submodule = name(submodule);
     ObjectId commitId = repo.git().fetch().setRemote("origin").call()
         .getAdvertisedRef("refs/heads/" + branch).getObjectId();
 
-    try (RevWalk rw = repo.getRevWalk()) {
-      RevCommit c = rw.parseCommit(commitId);
-      rw.parseBody(c.getTree());
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    rw.parseBody(c.getTree());
 
-      RevTree tree = c.getTree();
-      RevObject actualId = repo.get(tree, submodule);
+    RevTree tree = c.getTree();
+    RevObject actualId = repo.get(tree, submodule);
 
-      assertThat(actualId).isEqualTo(expectedId);
+    assertThat(actualId).isEqualTo(expectedId);
+  }
+
+  protected void deleteAllSubscriptions(TestRepository<?> repo, String branch)
+      throws Exception {
+    repo.git().fetch().setRemote("origin").call();
+    repo.reset("refs/remotes/origin/" + branch);
+
+    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
+        .message("delete contents in .gitmodules")
+        .add(".gitmodules", "") // Just remove the contents of the file!
+        .create();
+    repo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/" + branch)).call();
+
+    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+    assertThat(actualId).isEqualTo(expectedId);
+  }
+
+  protected void deleteGitModulesFile(TestRepository<?> repo, String branch)
+      throws Exception {
+    repo.git().fetch().setRemote("origin").call();
+    repo.reset("refs/remotes/origin/" + branch);
+
+    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
+        .message("delete .gitmodules")
+        .rm(".gitmodules")
+        .create();
+    repo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/" + branch)).call();
+
+    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+    assertThat(actualId).isEqualTo(expectedId);
+  }
+
+  protected boolean hasSubmodule(TestRepository<?> repo, String branch,
+      String submodule) throws Exception {
+
+    submodule = name(submodule);
+    Ref branchTip = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/" + branch);
+    if (branchTip == null) {
+      return false;
     }
+
+    ObjectId commitId = branchTip.getObjectId();
+
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    rw.parseBody(c.getTree());
+
+    RevTree tree = c.getTree();
+    try {
+      repo.get(tree, submodule);
+      return true;
+    } catch (AssertionError e) {
+      return false;
+    }
+  }
+
+  protected void expectToHaveCommitMessage(TestRepository<?> repo,
+      String branch, String expectedMessage) throws Exception {
+
+    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+
+    RevWalk rw = repo.getRevWalk();
+    RevCommit c = rw.parseCommit(commitId);
+    assertThat(c.getFullMessage()).isEqualTo(expectedMessage);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
new file mode 100644
index 0000000..db0d8e9
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
@@ -0,0 +1,26 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'git',
+  srcs = glob(['*IT.java']),
+  deps = [
+    ':submodule_util',
+    ':push_for_review',
+  ],
+  labels = ['git'],
+)
+
+java_library(
+  name = 'push_for_review',
+  srcs = ['AbstractPushForReview.java'],
+  deps = [
+    '//gerrit-acceptance-tests:lib',
+    '//lib/joda:joda-time',
+  ],
+)
+
+java_library(
+  name = 'submodule_util',
+  srcs = ['AbstractSubmoduleSubscription.java',],
+  deps = ['//gerrit-acceptance-tests:lib',]
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
index 27b8b0a..41f47a2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -31,9 +29,7 @@
 
   @Before
   public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    saveProjectConfig(project, cfg);
+    block(Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
index 26db819..38f28df 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -29,7 +29,7 @@
 
   @Test
   public void forcePushNotAllowed() throws Exception {
-    ObjectId initial = repo().getRef(HEAD).getLeaf().getObjectId();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -49,7 +49,7 @@
 
   @Test
   public void forcePushAllowed() throws Exception {
-    ObjectId initial = repo().getRef(HEAD).getLeaf().getObjectId();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
     grant(Permission.PUSH, project, "refs/*", true);
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
new file mode 100644
index 0000000..e7097f0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.server.git.ProjectConfig;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ImplicitMergeCheckIT extends AbstractDaemonTest {
+
+  @Test
+  public void implicitMergeViaFastForward() throws Exception {
+    setRejectImplicitMerges();
+
+    pushHead(testRepo, "refs/heads/stable", false);
+    PushOneCommit.Result m = push("refs/heads/master", "0", "file", "0");
+    PushOneCommit.Result c = push("refs/for/stable", "1", "file", "1");
+
+    c.assertMessage(implicitMergeOf(m.getCommit()));
+    c.assertErrorStatus();
+  }
+
+  @Test
+  public void implicitMergeViaRealMerge() throws Exception {
+    setRejectImplicitMerges();
+
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
+
+    c.assertMessage(implicitMergeOf(m.getCommit()));
+    c.assertErrorStatus();
+  }
+
+  @Test
+  public void implicitMergeCheckOff() throws Exception {
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
+
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(
+        implicitMergeOf(m.getCommit()));
+  }
+
+  @Test
+  public void notImplicitMerge_noWarning() throws Exception {
+    setRejectImplicitMerges();
+
+    ObjectId base = repo().exactRef("HEAD").getObjectId();
+    push("refs/heads/stable", "0", "f", "0");
+    testRepo.reset(base);
+    PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
+    PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
+
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(
+        implicitMergeOf(m.getCommit()));
+  }
+
+  private static String implicitMergeOf(ObjectId commit) {
+    return "implicit merge of " + commit.abbreviate(7).name();
+  }
+
+  private void setRejectImplicitMerges() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getProject().setRejectImplicitMerges(InheritableBoolean.TRUE);
+    saveProjectConfig(project, cfg);
+  }
+
+  private PushOneCommit.Result push(String ref, String subject,
+      String fileName, String content) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        subject, fileName, content);
+    return push.to(ref);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 78ffa20..848b428 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -25,10 +27,10 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.git.CommitMergeStatus;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,16 +43,11 @@
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
-import java.io.IOException;
-
 @NoHttpd
 public class SubmitOnPushIT extends AbstractDaemonTest {
   @Inject
   private ApprovalsUtil approvalsUtil;
 
-  @Inject
-  private ChangeNotes.Factory changeNotesFactory;
-
   @Test
   public void submitOnPush() throws Exception {
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
@@ -97,18 +94,18 @@
     grant(Permission.SUBMIT, project, "refs/for/refs/meta/config");
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
-    testRepo.reset("refs/meta/config");
+    testRepo.reset(RefNames.REFS_CONFIG);
 
     PushOneCommit.Result r = pushTo("refs/for/refs/meta/config%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
     assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/meta/config");
+    assertCommit(project, RefNames.REFS_CONFIG);
   }
 
   @Test
   public void submitOnPushMergeConflict() throws Exception {
-    ObjectId objectId = repo().getRef("HEAD").getObjectId();
+    ObjectId objectId = repo().exactRef("HEAD").getObjectId();
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
@@ -117,13 +114,14 @@
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
     r.assertChange(Change.Status.NEW, null);
-    r.assertMessage(CommitMergeStatus.PATH_CONFLICT.getMessage());
+    r.assertMessage("Change " + r.getChange().getId()
+        + ": change could not be merged due to a path conflict.");
   }
 
   @Test
   public void submitOnPushSuccessfulMerge() throws Exception {
     String master = "refs/heads/master";
-    ObjectId objectId = repo().getRef("HEAD").getObjectId();
+    ObjectId objectId = repo().exactRef("HEAD").getObjectId();
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
@@ -145,9 +143,9 @@
         "other content", r.getChangeId());
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
-    Change c = Iterables.getOnlyElement(
-        queryProvider.get().byKeyPrefix(r.getChangeId())).change();
-    assertThat(db.patchSets().byChange(c.getId()).toList()).hasSize(2);
+    ChangeData cd = Iterables.getOnlyElement(
+        queryProvider.get().byKeyPrefix(r.getChangeId()));
+    assertThat(cd.patchSets()).hasSize(2);
     assertSubmitApproval(r.getPatchSetId());
     assertCommit(project, "refs/heads/master");
   }
@@ -189,12 +187,20 @@
     r.assertOkStatus();
 
     git().push()
-        .setRefSpecs(new RefSpec(r.getCommitId().name() + ":refs/heads/master"))
+        .setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master"))
         .call();
     assertCommit(project, "refs/heads/master");
-    assertThat(getSubmitter(r.getPatchSetId())).isNull();
-    Change c = db.changes().get(r.getPatchSetId().getParentKey());
-    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
+
+    ChangeData cd = Iterables.getOnlyElement(
+        queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+    RevCommit c = r.getCommit();
+    PatchSet.Id psId = cd.currentPatchSet().getId();
+    assertThat(psId.get()).isEqualTo(1);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertSubmitApproval(psId);
+
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(cd.patchSet(psId).getRevision().get()).isEqualTo(c.name());
   }
 
   @Test
@@ -202,6 +208,9 @@
     grant(Permission.PUSH, project, "refs/heads/master");
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
+    RevCommit c1 = r.getCommit();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    assertThat(psId1.get()).isEqualTo(1);
 
     PushOneCommit push =
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
@@ -210,30 +219,88 @@
     r = push.to("refs/heads/master");
     r.assertOkStatus();
 
+    ChangeData cd = r.getChange();
+    RevCommit c2 = r.getCommit();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId2 = cd.change().currentPatchSetId();
+    assertThat(psId2.get()).isEqualTo(2);
     assertCommit(project, "refs/heads/master");
-    assertThat(getSubmitter(r.getPatchSetId())).isNull();
-    Change c = db.changes().get(r.getPatchSetId().getParentKey());
-    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
+    assertSubmitApproval(psId2);
+
+    assertThat(cd.patchSets()).hasSize(2);
+    assertThat(cd.patchSet(psId1).getRevision().get()).isEqualTo(c1.name());
+    assertThat(cd.patchSet(psId2).getRevision().get()).isEqualTo(c2.name());
+  }
+
+  @Test
+  public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
+    grant(Permission.PUSH, project, "refs/heads/master");
+
+    // Create 2 changes.
+    ObjectId initialHead = getRemoteHead();
+    PushOneCommit.Result r1 = createChange("Change 1", "a", "a");
+    r1.assertOkStatus();
+    PushOneCommit.Result r2 = createChange("Change 2", "b", "b");
+    r2.assertOkStatus();
+
+    RevCommit c1_1 = r1.getCommit();
+    RevCommit c2_1 = r2.getCommit();
+    PatchSet.Id psId1_1 = r1.getPatchSetId();
+    PatchSet.Id psId2_1 = r2.getPatchSetId();
+    assertThat(c1_1.getParent(0)).isEqualTo(initialHead);
+    assertThat(c2_1.getParent(0)).isEqualTo(c1_1);
+
+    // Amend both changes.
+    testRepo.reset(initialHead);
+    RevCommit c1_2 = testRepo.branch("HEAD").commit()
+        .message(c1_1.getShortMessage() + "v2")
+        .insertChangeId(r1.getChangeId().substring(1))
+        .create();
+    RevCommit c2_2 = testRepo.cherryPick(c2_1);
+
+    // Push directly to branch.
+    assertPushOk(
+        pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
+
+    ChangeData cd2 = r2.getChange();
+    assertThat(cd2.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId2_2 = cd2.change().currentPatchSetId();
+    assertThat(psId2_2.get()).isEqualTo(2);
+    assertThat(cd2.patchSet(psId2_1).getRevision().get())
+        .isEqualTo(c2_1.name());
+    assertThat(cd2.patchSet(psId2_2).getRevision().get())
+        .isEqualTo(c2_2.name());
+
+    ChangeData cd1 = r1.getChange();
+    assertThat(cd1.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId1_2 = cd1.change().currentPatchSetId();
+    assertThat(psId1_2.get()).isEqualTo(2);
+    assertThat(cd1.patchSet(psId1_1).getRevision().get())
+        .isEqualTo(c1_1.name());
+    assertThat(cd1.patchSet(psId1_2).getRevision().get())
+        .isEqualTo(c1_2.name());
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId)
-      throws OrmException {
-    Change c = db.changes().get(patchSetId.getParentKey());
-    ChangeNotes notes = changeNotesFactory.create(c).load();
+      throws Exception {
+    ChangeNotes notes =
+        notesFactory.createChecked(db, project, patchSetId.getParentKey())
+            .load();
     return approvalsUtil.getSubmitter(db, notes, patchSetId);
   }
 
-  private void assertSubmitApproval(PatchSet.Id patchSetId) throws OrmException {
+  private void assertSubmitApproval(PatchSet.Id patchSetId) throws Exception {
     PatchSetApproval a = getSubmitter(patchSetId);
-    assertThat(a.isSubmit()).isTrue();
+    assertThat(a.isLegacySubmit()).isTrue();
     assertThat(a.getValue()).isEqualTo((short) 1);
     assertThat(a.getAccountId()).isEqualTo(admin.id);
   }
 
-  private void assertCommit(Project.NameKey project, String branch) throws IOException {
+  private void assertCommit(Project.NameKey project, String branch)
+      throws Exception {
     try (Repository r = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(r)) {
-      RevCommit c = rw.parseCommit(r.getRef(branch).getObjectId());
+      RevCommit c = rw.parseCommit(r.exactRef(branch).getObjectId());
       assertThat(c.getShortMessage()).isEqualTo(PushOneCommit.SUBJECT);
       assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
       assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(
@@ -241,10 +308,10 @@
     }
   }
 
-  private void assertMergeCommit(String branch, String subject) throws IOException {
+  private void assertMergeCommit(String branch, String subject) throws Exception {
     try (Repository r = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(r)) {
-      RevCommit c = rw.parseCommit(r.getRef(branch).getObjectId());
+      RevCommit c = rw.parseCommit(r.exactRef(branch).getObjectId());
       assertThat(c.getParentCount()).isEqualTo(2);
       assertThat(c.getShortMessage()).isEqualTo("Merge \"" + subject + "\"");
       assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
@@ -254,9 +321,9 @@
   }
 
   private void assertTag(Project.NameKey project, String branch,
-      PushOneCommit.Tag tag) throws IOException {
+      PushOneCommit.Tag tag) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      Ref tagRef = repo.getRef(tag.name);
+      Ref tagRef = repo.findRef(tag.name);
       assertThat(tagRef).isNotNull();
       ObjectId taggedCommit = null;
       if (tag instanceof PushOneCommit.AnnotatedTag) {
@@ -273,7 +340,7 @@
       } else {
         taggedCommit = tagRef.getObjectId();
       }
-      ObjectId headCommit = repo.getRef(branch).getObjectId();
+      ObjectId headCommit = repo.exactRef(branch).getObjectId();
       assertThat(taggedCommit).isNotNull();
       assertThat(taggedCommit).isEqualTo(headCommit);
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
new file mode 100644
index 0000000..09e498f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
@@ -0,0 +1,393 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import com.google.gerrit.server.util.SubmoduleSectionParser;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+import java.util.Set;
+
+public class SubmoduleSectionParserIT extends AbstractDaemonTest {
+  private static final String THIS_SERVER = "http://localhost/";
+
+  @Test
+  public void testFollowMasterBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = localpath-to-a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = master\n");
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "localpath-to-a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testFollowMatchingBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = .\n");
+
+    Branch.NameKey targetBranch1 = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res1 = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch1).parseAllSections();
+
+    Set<SubmoduleSubscription> expected1 = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch1, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res1).containsExactlyElementsIn(expected1);
+
+    Branch.NameKey targetBranch2 = new Branch.NameKey(
+        new Project.NameKey("project"), "somebranch");
+
+    Set<SubmoduleSubscription> res2 = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch2).parseAllSections();
+
+    Set<SubmoduleSubscription> expected2 = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch2, new Branch.NameKey(
+            p, "somebranch"), "a"));
+
+    assertThat(res2).containsExactlyElementsIn(expected2);
+  }
+
+  @Test
+  public void testFollowAnotherBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = anotherbranch\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "anotherbranch"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithAnotherURI() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSlashesInProjectName() throws Exception {
+    Project.NameKey p = createProject("project/with/slashes/a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"project/with/slashes/a\"]\n"
+        + "path = a\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSlashesInPath() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a/b/c/d/e\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a/b/c/d/e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithMoreSections() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Project.NameKey p2 = createProject("b");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "     path = a\n"
+        + "     url = ssh://localhost/" + p1.get() + "\n"
+        + "     branch = .\n"
+        + "[submodule \"b\"]\n"
+        + "		path = b\n"
+        + "		url = http://localhost:80/" + p2.get() + "\n"
+        + "		branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p2, "master"), "b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSubProjectFound() throws Exception {
+    Project.NameKey p1 = createProject("a/b");
+    Project.NameKey p2 = createProject("b");
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a/b\"]\n"
+        + "path = a/b\n"
+        + "url = ssh://localhost/" + p1.get() + "\n"
+        + "branch = .\n"
+        + "[submodule \"b\"]\n"
+        + "path = b\n"
+        + "url = http://localhost/" + p2.get() + "\n"
+        + "branch = .\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p2, "master"), "b"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a/b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithAnInvalidSection() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Project.NameKey p2 = createProject("b");
+    Project.NameKey p3 = createProject("d");
+    Project.NameKey p4 = createProject("e");
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a\"]\n"
+        + "    path = a\n"
+        + "    url = ssh://localhost/" + p1.get() + "\n"
+        + "    branch = .\n"
+        + "[submodule \"b\"]\n"
+            // path missing
+        + "    url = http://localhost:80/" + p2.get() + "\n"
+        + "    branch = master\n"
+        + "[submodule \"c\"]\n"
+        + "    path = c\n"
+            // url missing
+        + "    branch = .\n"
+        + "[submodule \"d\"]\n"
+        + "    path = d-parent/the-d-folder\n"
+        + "    url = ssh://localhost/" + p3.get() + "\n"
+            // branch missing
+        + "[submodule \"e\"]\n"
+        + "    path = e\n"
+        + "    url = ssh://localhost/" + p4.get() + "\n"
+        + "    branch = refs/heads/master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p4, "master"), "e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSectionOfNonexistingProject() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://non-localhost/a\n"
+        // Project "a" doesn't exist
+        + "branch = .\\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void testWithSectionToOtherServer() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]"
+        + "path = a"
+        + "url = ssh://non-localhost/" + p1.get() + "\n"
+        + "branch = .");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void testWithRelativeURI() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ../" + p1.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithDeepRelativeURI() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ../../" + p1.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("nested/project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithOverlyDeepRelativeURI() throws Exception {
+    Project.NameKey p1 = createProject("nested/a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ../../" + p1.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("nested/project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 1216b00..6684e85 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,17 +16,20 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 @NoHttpd
@@ -37,12 +40,45 @@
   }
 
   @Test
-  public void testSubscriptionToEmptyRepo() throws Exception {
+  @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
+  public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionWithoutSpecificSubscription() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionToEmptyRepo() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isTrue();
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subHEAD);
   }
@@ -51,74 +87,242 @@
   public void testSubscriptionToExistingRepo() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isTrue();
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subHEAD);
   }
 
   @Test
+  public void testSubscriptionWildcardACLForSingleBranch() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    // master is allowed to be subscribed to master branch only:
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", null);
+    // create 'branch':
+    pushChangeTo(superRepo, "branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "master");
+
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "branch",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLForMissingProject() throws Exception {
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
+        "not-existing-super-project", "refs/heads/*");
+    pushChangeTo(subRepo, "master");
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLForMissingBranch() throws Exception {
+    createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
+        "super-project", "refs/heads/*");
+    pushChangeTo(subRepo, "foo");
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLForMissingGitmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
+        "super-project", "refs/heads/*");
+    pushChangeTo(superRepo, "master");
+    pushChangeTo(subRepo, "master");
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLOneOnOneMapping() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    // any branch is allowed to be subscribed to the same superprojects branch:
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
+        "super-project", "refs/heads/*");
+
+    // create 'branch' in both repos:
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "branch");
+
+    ObjectId subHEAD1 = pushChangeTo(subRepo, "master");
+    ObjectId subHEAD2 = pushChangeTo(subRepo, "branch");
+
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch",
+        "subscribed-to-project", subHEAD2);
+
+    // Now test that cross subscriptions do not work:
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "branch");
+    ObjectId subHEAD3 = pushChangeTo(subRepo, "branch");
+
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch",
+        "subscribed-to-project", subHEAD3);
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLForManyBranches() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    // Any branch is allowed to be subscribed to any superproject branch:
+    allowSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
+        "super-project", null, false);
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "another-branch");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "another-branch");
+    ObjectId subHEAD = pushChangeTo(subRepo, "another-branch");
+    expectToHaveSubmoduleState(superRepo, "branch",
+        "subscribed-to-project", subHEAD);
+  }
+
+  @Test
+  public void testSubscriptionWildcardACLOneToManyBranches() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    // Any branch is allowed to be subscribed to any superproject branch:
+    allowSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/*", false);
+    pushChangeTo(superRepo, "branch");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "branch",
+        "subscribed-to-project", subHEAD);
+
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "branch");
+    pushChangeTo(subRepo, "branch");
+
+    // no change expected, as only master is subscribed:
+    expectToHaveSubmoduleState(superRepo, "branch",
+        "subscribed-to-project", subHEAD);
+  }
+
+  @Test
   @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "false")
   public void testSubmoduleShortCommitMessage() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     // The first update doesn't include any commit messages
     ObjectId subRepoId = pushChangeTo(subRepo, "master");
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subRepoId);
     expectToHaveCommitMessage(superRepo, "master",
-        "Updated git submodules\n\n");
+        "Update git submodules\n\n");
 
     // Any following update also has a short message
     subRepoId = pushChangeTo(subRepo, "master");
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subRepoId);
     expectToHaveCommitMessage(superRepo, "master",
-        "Updated git submodules\n\n");
+        "Update git submodules\n\n");
   }
 
   @Test
-
-  public void testSubmoduleCommitMessage() throws Exception {
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  public void testSubmoduleSubjectCommitMessage() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
     RevWalk rw = subRepo.getRevWalk();
-    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
     expectToHaveCommitMessage(superRepo, "master",
-        "Updated git submodules\n\n" +
-        "Project: " + name("subscribed-to-project")
-            + " master " + subHEAD.name() + "\n\n");
+        "Update git submodules\n\n" +
+            "* Update " + name("subscribed-to-project") + " from branch 'master'");
 
     // The next commit should generate only its commit message,
     // omitting previous commit logs
     subHEAD = pushChangeTo(subRepo, "master");
-    subCommitMsg = rw.parseCommit(subHEAD);
+    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
     expectToHaveCommitMessage(superRepo, "master",
-        "Updated git submodules\n\n" +
-        "Project: " + name("subscribed-to-project")
-            + " master " + subHEAD.name() + "\n\n" +
-        subCommitMsg.getFullMessage() + "\n\n");
+        "Update git submodules\n\n" +
+            "* Update " + name("subscribed-to-project") + " from branch 'master'"
+            + "\n  - " + subCommitMsg.getShortMessage());
+  }
+
+  @Test
+  public void testSubmoduleCommitMessage() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+
+    // The first update doesn't include the rev log
+    RevWalk rw = subRepo.getRevWalk();
+    expectToHaveCommitMessage(superRepo, "master",
+        "Update git submodules\n\n" +
+            "* Update " + name("subscribed-to-project") + " from branch 'master'");
+
+    // The next commit should generate only its commit message,
+    // omitting previous commit logs
+    subHEAD = pushChangeTo(subRepo, "master");
+    RevCommit subCommitMsg = rw.parseCommit(subHEAD);
+    expectToHaveCommitMessage(superRepo, "master",
+        "Update git submodules\n\n" +
+            "* Update " + name("subscribed-to-project") + " from branch 'master'"
+             + "\n  - " + subCommitMsg.getFullMessage().replace("\n", "\n    "));
   }
 
   @Test
   public void testSubscriptionUnsubscribe() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
@@ -136,12 +340,16 @@
   }
 
   @Test
-  public void testSubscriptionUnsubscribeByDeletingGitModules() throws Exception {
+  public void testSubscriptionUnsubscribeByDeletingGitModules()
+      throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
@@ -162,8 +370,11 @@
   public void testSubscriptionToDifferentBranches() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/foo",
+        "super-project", "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "foo");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "foo");
     ObjectId subFoo = pushChangeTo(subRepo, "foo");
     pushChangeTo(subRepo, "master");
 
@@ -172,84 +383,164 @@
   }
 
   @Test
-  public void testCircularSubscriptionIsDetected() throws Exception {
+  public void testBranchCircularSubscription() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("super-project", "refs/heads/master",
+        "subscribed-to-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "master", "super-project", "master");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+
+    assertThat(hasSubmodule(subRepo, "master",
+        "super-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testProjectCircularSubscription() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("super-project", "refs/heads/dev",
+        "subscribed-to-project", "refs/heads/dev");
+
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(superRepo, "master");
+    pushChangeTo(subRepo, "dev");
+    pushChangeTo(superRepo, "dev");
+
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+
+    ObjectId subMasterHead = pushChangeTo(subRepo, "master");
+    ObjectId superDevHead = pushChangeTo(superRepo, "dev");
+
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isTrue();
+    assertThat(hasSubmodule(subRepo, "dev",
+        "super-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subMasterHead);
+    expectToHaveSubmoduleState(subRepo, "dev",
+        "super-project", superDevHead);
+  }
+
+  @Test
+  public void testSubscriptionFailOnMissingACL() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(subRepo, "master", "super-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
 
+  @Test
+  public void testSubscriptionFailOnWrongProjectACL() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "wrong-super-project", "refs/heads/master");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionFailOnWrongBranchACL() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/wrong-branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionInheritACL() throws Exception {
+    createProjectWithPush("config-repo");
+    createProjectWithPush("config-repo2",
+        new Project.NameKey(name("config-repo")));
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project",
+        new Project.NameKey(name("config-repo2")));
+    allowMatchingSubmoduleSubscription("config-repo", "refs/heads/*",
+        "super-project", "refs/heads/*");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    pushChangeTo(superRepo, "master");
-
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subHEAD);
-
-    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
   }
 
-  private void deleteAllSubscriptions(TestRepository<?> repo, String branch)
-      throws Exception {
-    repo.git().fetch().setRemote("origin").call();
-    repo.reset("refs/remotes/origin/" + branch);
+  @Test
+  public void testAllowedButNotSubscribed() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
 
-    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
-      .message("delete contents in .gitmodules")
-      .add(".gitmodules", "") // Just remove the contents of the file!
-      .create();
-    repo.git().push().setRemote("origin").setRefSpecs(
-      new RefSpec("HEAD:refs/heads/" + branch)).call();
+    pushChangeTo(subRepo, "master");
+    subRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change")
+        .add("b.txt", "b contents for testing")
+        .create();
+    String refspec = "HEAD:refs/heads/master";
+    PushResult r = Iterables.getOnlyElement(subRepo.git().push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec(refspec))
+        .call());
+    assertThat(r.getMessages()).doesNotContain("error");
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus())
+    .isEqualTo(RemoteRefUpdate.Status.OK);
 
-    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
-      .getAdvertisedRef("refs/heads/master").getObjectId();
-    assertThat(actualId).isEqualTo(expectedId);
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
-  private void deleteGitModulesFile(TestRepository<?> repo, String branch)
-      throws Exception {
-    repo.git().fetch().setRemote("origin").call();
-    repo.reset("refs/remotes/origin/" + branch);
+  @Test
+  public void testSubscriptionDeepRelative() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush(
+        "nested/subscribed-to-project");
+    // master is allowed to be subscribed to any superprojects branch:
+    allowMatchingSubmoduleSubscription("nested/subscribed-to-project",
+        "refs/heads/master", "super-project", null);
 
-    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
-      .message("delete .gitmodules")
-      .rm(".gitmodules")
-      .create();
-    repo.git().push().setRemote("origin").setRefSpecs(
-      new RefSpec("HEAD:refs/heads/" + branch)).call();
+    pushChangeTo(subRepo, "master");
+    createRelativeSubmoduleSubscription(superRepo, "master",
+        "../", "nested/subscribed-to-project", "master");
 
-    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
-      .getAdvertisedRef("refs/heads/master").getObjectId();
-    assertThat(actualId).isEqualTo(expectedId);
-  }
+    ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
-  private boolean hasSubmodule(TestRepository<?> repo, String branch,
-      String submodule) throws Exception {
-
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
-
-    RevWalk rw = repo.getRevWalk();
-    RevCommit c = rw.parseCommit(commitId);
-    rw.parseBody(c.getTree());
-
-    RevTree tree = c.getTree();
-    try {
-      repo.get(tree, submodule);
-      return true;
-    } catch (AssertionError e) {
-      return false;
-    }
-  }
-
-  private void expectToHaveCommitMessage(TestRepository<?> repo,
-      String branch, String expectedMessage) throws Exception {
-
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
-
-    RevWalk rw = repo.getRevWalk();
-    RevCommit c = rw.parseCommit(commitId);
-    assertThat(c.getFullMessage()).isEqualTo(expectedMessage);
+    expectToHaveSubmoduleState(superRepo, "master",
+        "nested/subscribed-to-project", subHEAD);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index e4a054a..98405d7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
@@ -33,14 +35,32 @@
   extends AbstractSubmoduleSubscription {
 
   @ConfigSuite.Default
-  public static Config submitWholeTopicEnabled() {
-    return submitWholeTopicEnabledConfig();
+  public static Config mergeIfNecessary() {
+    return submitByMergeIfNecessary();
+  }
+
+  @ConfigSuite.Config
+  public static Config mergeAlways() {
+    return submitByMergeAlways();
+  }
+
+  @ConfigSuite.Config
+  public static Config cherryPick() {
+    return submitByCherryPickConifg();
+  }
+
+  @ConfigSuite.Config
+  public static Config rebase() {
+    return submitByRebaseConifg();
   }
 
   @Test
   public void testSubscriptionUpdateOfManyChanges() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
     createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
 
     ObjectId subHEAD = subRepo.branch("HEAD").commit().insertChangeId()
@@ -90,12 +110,84 @@
   }
 
   @Test
+  public void testSubscriptionUpdateIncludingChangeInSuperproject() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    ObjectId subHEAD = subRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change")
+        .add("a.txt", "a contents ")
+        .create();
+    subRepo.git().push().setRemote("origin").setRefSpecs(
+          new RefSpec("HEAD:refs/heads/master")).call();
+
+    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
+
+    RevCommit c1 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("first change")
+      .add("asdf", "asdf\n")
+      .create();
+    subRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    subRepo.reset(c.getId());
+    RevCommit c2 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("qwerty")
+      .add("qwerty", "qwerty")
+      .create();
+
+    RevCommit c3 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("qwerty followup")
+      .add("qwerty", "qwerty\nqwerty\n")
+      .create();
+    subRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    RevCommit c4 = superRepo.branch("HEAD").commit().insertChangeId()
+      .message("new change on superproject")
+      .add("foo", "bar")
+      .create();
+    superRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    String id1 = getChangeId(subRepo, c1).get();
+    String id2 = getChangeId(subRepo, c2).get();
+    String id3 = getChangeId(subRepo, c3).get();
+    String id4 = getChangeId(superRepo, c4).get();
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+    gApi.changes().id(id4).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+    ObjectId subRepoId = subRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subRepoId);
+  }
+
+  @Test
   public void testUpdateManySubmodules() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub1 = createProjectWithPush("sub1");
     TestRepository<?> sub2 = createProjectWithPush("sub2");
     TestRepository<?> sub3 = createProjectWithPush("sub3");
 
+    allowMatchingSubmoduleSubscription("sub1", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("sub2", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("sub3", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, "sub1", "master");
     prepareSubmoduleConfigEntry(config, "sub2", "master");
@@ -117,9 +209,9 @@
 
     gApi.changes().id(getChangeId(sub1, sub1Id).get()).current().submit();
 
-    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1Id);
-    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2Id);
-    expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3Id);
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3, "master");
 
     superRepo.git().fetch().setRemote("origin").call()
       .getAdvertisedRef("refs/heads/master").getObjectId();
@@ -129,4 +221,400 @@
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
+
+  @Test
+  public void testDoNotUseFastForward() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+
+    ObjectId subId =
+        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId =
+        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).contains("some message");
+    assertThat(superHead.getId()).isNotEqualTo(superId);
+  }
+
+  @Test
+  public void testUseFastForwardWhenNoSubmodule() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    ObjectId subId =
+        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId =
+        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).isEqualTo("some message");
+    assertThat(superHead.getId()).isEqualTo(superId);
+  }
+
+  @Test
+  public void testSameProjectSameBranchDifferentPaths() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub", "master");
+    prepareSubmoduleConfigEntry(config, "sub", "sub-copy", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "");
+
+    approve(getChangeId(sub, subId).get());
+
+    gApi.changes().id(getChangeId(sub, subId).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub-copy", sub, "master");
+
+    superRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    assertWithMessage("submodule subscription update "
+        + "should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void testSameProjectDifferentBranchDifferentPaths() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/dev",
+        "super-project", "refs/heads/master");
+
+    ObjectId devHead = pushChangeTo(sub, "dev");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub", "sub-master", "master");
+    prepareSubmoduleConfigEntry(config, "sub", "sub-dev", "dev");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId subMasterId =
+        pushChangeTo(sub, "refs/for/master", "some message", "b.txt",
+            "content b", "same-topic");
+
+    sub.reset(devHead);
+    ObjectId subDevId =
+        pushChangeTo(sub, "refs/for/dev", "some message in dev", "b.txt",
+            "content b", "same-topic");
+
+    approve(getChangeId(sub, subMasterId).get());
+    approve(getChangeId(sub, subDevId).get());
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    gApi.changes().id(getChangeId(sub, subMasterId).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
+
+    superRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    assertWithMessage("submodule subscription update "
+        + "should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void testNonSubmoduleInSameTopic() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+    TestRepository<?> standAlone = createProjectWithPush("standalone");
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    ObjectId subId =
+        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId standAloneId =
+        pushChangeTo(standAlone, "refs/for/master", "some message",
+            "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    String standAloneChangeId = getChangeId(standAlone, standAloneId).get();
+    approve(subChangeId);
+    approve(standAloneChangeId);
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+
+    ChangeStatus status = gApi.changes().id(standAloneChangeId).info().status;
+    assertThat(status).isEqualTo(ChangeStatus.MERGED);
+
+    superRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    assertWithMessage("submodule subscription update "
+        + "should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
+  public void testRecursiveSubmodules() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
+        "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
+        "mid-project", "refs/heads/master");
+
+    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+
+    ObjectId bottomHead =
+        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead =
+        pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+
+    String id1 = getChangeId(bottomRepo, bottomHead).get();
+    String id2 = getChangeId(topRepo, topHead).get();
+
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+
+    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+  }
+
+  @Test
+  public void testTriangleSubmodules() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
+        "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
+        "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
+        "top-project", "refs/heads/master");
+
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "bottom-project", "master");
+    prepareSubmoduleConfigEntry(config, "mid-project", "master");
+    pushSubmoduleConfig(topRepo, "master", config);
+
+    ObjectId bottomHead =
+        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead =
+        pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+
+    String id1 = getChangeId(bottomRepo, bottomHead).get();
+    String id2 = getChangeId(topRepo, topHead).get();
+
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+
+    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
+  }
+
+  @Test
+  public void testBranchCircularSubscription() throws Exception {
+    TestRepository<?> topRepo = createProjectWithPush("top-project");
+    TestRepository<?> midRepo = createProjectWithPush("mid-project");
+    TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
+
+    createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
+    createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
+    createSubmoduleSubscription(bottomRepo, "master", "top-project", "master");
+
+    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
+        "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
+        "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("top-project", "refs/heads/master",
+        "bottom-project", "refs/heads/master");
+
+    ObjectId bottomMasterHead =
+        pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
+    String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
+
+    approve(changeId);
+
+    exception.expectMessage("Branch level circular subscriptions detected");
+    exception.expectMessage("top-project,refs/heads/master");
+    exception.expectMessage("mid-project,refs/heads/master");
+    exception.expectMessage("bottom-project,refs/heads/master");
+    gApi.changes().id(changeId).current().submit();
+
+    assertThat(hasSubmodule(midRepo, "master", "bottom-project")).isFalse();
+    assertThat(hasSubmodule(topRepo, "master", "mid-project")).isFalse();
+  }
+
+  @Test
+  public void testProjectCircularSubscriptionWholeTopic() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("super-project", "refs/heads/dev",
+        "subscribed-to-project", "refs/heads/dev");
+
+    pushChangeTo(subRepo, "dev");
+    pushChangeTo(superRepo, "dev");
+
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
+
+    ObjectId subMasterHead =
+        pushChangeTo(subRepo, "refs/for/master", "b.txt", "content b",
+            "some message", "same-topic");
+    ObjectId superDevHead =
+        pushChangeTo(superRepo, "refs/for/dev",
+            "some message", "same-topic");
+
+    approve(getChangeId(subRepo, subMasterHead).get());
+    approve(getChangeId(superRepo, superDevHead).get());
+
+    exception.expectMessage("Project level circular subscriptions detected");
+    exception.expectMessage("subscribed-to-project");
+    exception.expectMessage("super-project");
+    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current()
+        .submit();
+
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project"))
+        .isFalse();
+    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isFalse();
+  }
+
+  @Test
+  public void testProjectNoSubscriptionWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    ObjectId a0 = pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    // create a change for master branch in repo a
+    ObjectId aHead =
+        pushChangeTo(repoA, "refs/for/master", "master.txt", "content master A",
+            "some message in a master.txt", "same-topic");
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
+            "some message in b master.txt", "same-topic");
+
+    // create a change for dev branch in repo a
+    repoA.reset(a0);
+    ObjectId aDevHead =
+        pushChangeTo(repoA, "refs/for/dev", "dev.txt", "content dev A",
+            "some message in a dev.txt", "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
+            "some message in b dev.txt", "same-topic");
+
+    approve(getChangeId(repoA, aHead).get());
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoA, aDevHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+
+    gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
+    assertThat(
+        getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
+        .contains("some message in a master.txt");
+    assertThat(
+        getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
+        .contains("some message in a dev.txt");
+    assertThat(
+        getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
+        .contains("some message in b master.txt");
+    assertThat(
+        getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
+        .contains("some message in b dev.txt");
+  }
+
+  @Test
+  public void testTwoProjectsMultipleBranchesWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    allowMatchingSubmoduleSubscription("project-b",
+        "refs/heads/master", "project-a", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("project-b", "refs/heads/dev",
+        "project-a", "refs/heads/dev");
+
+    createSubmoduleSubscription(repoA, "master", "project-b", "master");
+    createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
+
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
+            "some message in b master.txt", "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
+            "some message in b dev.txt", "same-topic");
+
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+    gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
+
+    expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
+    expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
index a9a7dfa..fd2385b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Splitter;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
@@ -31,13 +30,23 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.testutil.DisabledReviewDb;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -45,12 +54,26 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 
 @NoHttpd
 public class VisibleRefFilterIT extends AbstractDaemonTest {
   @Inject
   private ChangeEditModifier editModifier;
 
+  @Inject
+  private ProjectControl.GenericFactory projectControlFactory;
+
+  @Inject
+  @Nullable
+  private SearchingChangeCacheImpl changeCache;
+
+  @Inject
+  private TagCache tagCache;
+
+  @Inject
+  private Provider<CurrentUser> userProvider;
+
   private AccountGroup.UUID admins;
 
   private Change.Id c1;
@@ -104,13 +127,13 @@
       // master-tag -> master
       RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
       mtu.setExpectedOldObjectId(ObjectId.zeroId());
-      mtu.setNewObjectId(repo.getRef("refs/heads/master").getObjectId());
+      mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
       assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
 
       // branch-tag -> branch
       RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
-      btu.setNewObjectId(repo.getRef("refs/heads/branch").getObjectId());
+      btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
       assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
     }
   }
@@ -119,10 +142,11 @@
   public void allRefsVisibleNoRefsMetaConfig() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.READ, admins, "refs/meta/config");
-    Util.doNotInherit(cfg, Permission.READ, "refs/meta/config");
+    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
+    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
     saveProjectConfig(project, cfg);
 
+    setApiUser(user);
     assertRefs(
         "HEAD",
         r1 + "1",
@@ -138,7 +162,7 @@
   @Test
   public void allRefsVisibleWithRefsMetaConfig() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/*");
-    allow(Permission.READ, REGISTERED_USERS, "refs/meta/config");
+    allow(Permission.READ, REGISTERED_USERS, RefNames.REFS_CONFIG);
 
     assertRefs(
         "HEAD",
@@ -148,7 +172,7 @@
         r2 + "meta",
         "refs/heads/branch",
         "refs/heads/master",
-        "refs/meta/config",
+        RefNames.REFS_CONFIG,
         "refs/tags/branch-tag",
         "refs/tags/master-tag");
   }
@@ -158,6 +182,7 @@
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
+    setApiUser(user);
     assertRefs(
         "HEAD",
         r1 + "1",
@@ -171,6 +196,7 @@
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
+    setApiUser(user);
     assertRefs(
         r2 + "1",
         r2 + "meta",
@@ -186,16 +212,16 @@
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
-    Change change1 = db.changes().get(c1);
-    PatchSet ps1 = db.patchSets().get(new PatchSet.Id(c1, 1));
+    Change c = notesFactory.createChecked(db, project, c1).getChange();
+    PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
 
     // Admin's edit is not visible.
     setApiUser(admin);
-    editModifier.createEdit(change1, ps1);
+    editModifier.createEdit(c, ps1);
 
     // User's edit is visible.
     setApiUser(user);
-    editModifier.createEdit(change1, ps1);
+    editModifier.createEdit(c, ps1);
 
     assertRefs(
         "HEAD",
@@ -213,12 +239,11 @@
       deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
       allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
-      Change change1 = db.changes().get(c1);
-      PatchSet ps1 = db.patchSets().get(new PatchSet.Id(c1, 1));
+      Change c = notesFactory.createChecked(db, project, c1).getChange();
+      PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
       setApiUser(admin);
-      editModifier.createEdit(change1, ps1);
+      editModifier.createEdit(c, ps1);
       setApiUser(user);
-      editModifier.createEdit(change1, ps1);
 
       assertRefs(
           // Change 1 is visible due to accessDatabase capability, even though
@@ -232,37 +257,149 @@
           // See comment in subsetOfBranchesVisibleNotIncludingHead.
           "refs/tags/master-tag",
           // All edits are visible due to accessDatabase capability.
-          "refs/users/00/1000000/edit-" + c1.get() + "/1",
-          "refs/users/01/1000001/edit-" + c1.get() + "/1");
+          "refs/users/00/1000000/edit-" + c1.get() + "/1");
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     }
   }
 
+  @Test
+  public void draftRefs() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
+
+    PushOneCommit.Result br = pushFactory.create(db, admin.getIdent(), testRepo)
+        .to("refs/drafts/master");
+    br.assertOkStatus();
+    Change.Id c3 = br.getChange().getId();
+    String r3 = changeRefPrefix(c3);
+
+    // Only admin can see admin's draft change.
+    setApiUser(admin);
+    assertRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        RefNames.REFS_CONFIG,
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+
+    // user can't.
+    setApiUser(user);
+    assertRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void noSearchingChangeCacheImpl() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
+
+    setApiUser(user);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo,
+          new VisibleRefFilter(tagCache, notesFactory, null, repo,
+              projectControl(), db, true),
+          // Can't use stored values from the index so DB must be enabled.
+          false,
+          "HEAD",
+          r1 + "1",
+          r1 + "meta",
+          r2 + "1",
+          r2 + "meta",
+          "refs/heads/branch",
+          "refs/heads/master",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag");
+    }
+  }
+
+  @Test
+  public void sequencesWithAccessDatabase() throws Exception {
+    assume().that(notesMigration.readChangeSequence()).isTrue();
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      setApiUser(user);
+      assertRefs(repo, newFilter(db, repo, allProjects), true);
+
+      allowGlobalCapabilities(
+          REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      try {
+        setApiUser(user);
+        assertRefs(
+            repo, newFilter(db, repo, allProjects), true,
+            "refs/sequences/changes");
+      } finally {
+        removeGlobalCapabilities(
+            REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      }
+    }
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
-   * @param expected expected refs, in order. If notedb is disabled by the
-   *     configuration, any notedb refs (i.e. ending in "/meta") are removed
+   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by
+   *     the configuration, any NoteDb refs (i.e. ending in "/meta") are removed
    *     from the expected list before comparing to the actual results.
    * @throws Exception
    */
-  private void assertRefs(String... expected) throws Exception {
-    String out = sshSession.exec(String.format(
-        "gerrit ls-user-refs -p %s -u %s",
-        project.get(), user.getId().get()));
-    assert_().withFailureMessage(sshSession.getError())
-      .that(sshSession.hasError()).isFalse();
+  private void assertRefs(String... expectedWithMeta) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo,
+          new VisibleRefFilter(tagCache, notesFactory, changeCache, repo,
+              projectControl(), new DisabledReviewDb(), true),
+          true,
+          expectedWithMeta);
+    }
+  }
 
-    List<String> filtered = new ArrayList<>(expected.length);
-    for (String r : expected) {
+  private void assertRefs(Repository repo, VisibleRefFilter filter,
+      boolean disableDb, String... expectedWithMeta) throws Exception {
+    List<String> expected = new ArrayList<>(expectedWithMeta.length);
+    for (String r : expectedWithMeta) {
       if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
-        filtered.add(r);
+        expected.add(r);
       }
     }
 
-    Splitter s = Splitter.on(CharMatcher.whitespace()).omitEmptyStrings();
-    assertThat(filtered).containsExactlyElementsIn(
-        Ordering.natural().sortedCopy(s.split(out))).inOrder();
+    AcceptanceTestRequestScope.Context ctx = null;
+    if (disableDb) {
+      ctx = disableDb();
+    }
+    try {
+      Map<String, Ref> all = repo.getAllRefs();
+      assertThat(filter.filter(all, false).keySet())
+          .containsExactlyElementsIn(expected);
+    } finally {
+      if (disableDb) {
+        enableDb(ctx);
+      }
+    }
+  }
+
+  private ProjectControl projectControl() throws Exception {
+    return projectControlFactory.controlFor(project, userProvider.get());
+  }
+
+  private VisibleRefFilter newFilter(ReviewDb db, Repository repo,
+      Project.NameKey project) throws Exception {
+    return new VisibleRefFilter(
+        tagCache, notesFactory, null, repo,
+        projectControlFactory.controlFor(project, userProvider.get()),
+        db, true);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
new file mode 100644
index 0000000..806acd2
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
@@ -0,0 +1,8 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'pgm',
+  srcs = glob(['*IT.java']),
+  source_under_test = ['//gerrit-pgm:pgm'],
+  labels = ['pgm'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
new file mode 100644
index 0000000..66e0c73
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.Files;
+import com.google.gerrit.launcher.GerritLauncher;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
+import com.google.gerrit.testutil.TempFileUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+
+public class RebuildNoteDbIT {
+  private File sitePath;
+
+  @Before
+  public void createTempDirectory() throws Exception {
+    sitePath = TempFileUtil.createTempDirectory();
+  }
+
+  @After
+  public void destroySite() throws Exception {
+    if (sitePath != null) {
+      TempFileUtil.cleanup();
+    }
+  }
+
+  @Test
+  public void rebuildEmptySite() throws Exception {
+    initSite();
+    Files.append(ConfigNotesMigration.allEnabledConfig().toText(),
+        new File(sitePath.toString(), "etc/gerrit.config"),
+        UTF_8);
+    runGerrit("RebuildNoteDb", "-d", sitePath.toString(),
+        "--show-stack-trace");
+  }
+
+  private void initSite() throws Exception {
+    runGerrit("init", "-d", sitePath.getPath(),
+        "--batch", "--no-auto-start", "--skip-plugins", "--show-stack-trace");
+  }
+
+  private static void runGerrit(String... args) throws Exception {
+    assertThat(GerritLauncher.mainImpl(args)).isEqualTo(0);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNotedbIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNotedbIT.java
deleted file mode 100644
index 5d0e7df..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNotedbIT.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.pgm;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.io.Files;
-import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.testutil.TempFileUtil;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.io.File;
-
-public class RebuildNotedbIT {
-  private File sitePath;
-
-  @Before
-  public void createTempDirectory() throws Exception {
-    sitePath = TempFileUtil.createTempDirectory();
-  }
-
-  @After
-  public void destroySite() throws Exception {
-    if (sitePath != null) {
-      TempFileUtil.cleanup();
-    }
-  }
-
-  @Test
-  public void rebuildEmptySite() throws Exception {
-    initSite();
-    Files.append(NotesMigration.allEnabledConfig().toText(),
-        new File(sitePath.toString(), "etc/gerrit.config"),
-        UTF_8);
-    runGerrit("RebuildNotedb", "-d", sitePath.toString(),
-        "--show-stack-trace");
-  }
-
-  private void initSite() throws Exception {
-    runGerrit("init", "-d", sitePath.getPath(),
-        "--batch", "--no-auto-start", "--skip-plugins", "--show-stack-trace");
-  }
-
-  private static void runGerrit(String... args) throws Exception {
-    assertThat(GerritLauncher.mainImpl(args)).isEqualTo(0);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
index b7c1819..76c918b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
@@ -1,10 +1,10 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'rest-account',
+  group = 'rest_account',
   srcs = glob(['*IT.java']),
   deps = [':util'],
-  labels = ['rest']
+  labels = ['rest'],
 )
 
 java_library(
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
new file mode 100644
index 0000000..558d0a9
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
@@ -0,0 +1,23 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'rest_account',
+  srcs = glob(['*IT.java']),
+  deps = [':util'],
+  labels = ['rest']
+)
+
+java_library(
+  name = 'util',
+  srcs = [
+    'AccountAssert.java',
+    'CapabilityInfo.java',
+  ],
+  deps = [
+    '//gerrit-acceptance-tests:lib',
+    '//gerrit-reviewdb:server',
+    '//lib:gwtorm',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index 3e7c2bf..ce82270 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -34,7 +34,6 @@
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
@@ -52,8 +51,8 @@
     allowGlobalCapabilities(REGISTERED_USERS, all);
     try {
       RestResponse r =
-          userSession.get("/accounts/self/capabilities");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+          userRestSession.get("/accounts/self/capabilities");
+      r.assertOK();
       CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
           new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
@@ -80,8 +79,8 @@
   @Test
   public void testCapabilitiesAdmin() throws Exception {
     RestResponse r =
-        adminSession.get("/accounts/self/capabilities");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+        adminRestSession.get("/accounts/self/capabilities");
+    r.assertOK();
     CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
         new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java
deleted file mode 100644
index 9dbb1ff..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.Theme;
-
-import org.apache.http.HttpStatus;
-import org.junit.Test;
-
-public class DiffPreferencesIT extends AbstractDaemonTest {
-  @Test
-  public void getDiffPreferencesOfNonExistingAccount_NotFound()
-      throws Exception {
-    assertEquals(HttpStatus.SC_NOT_FOUND,
-        adminSession.get("/accounts/non-existing/preferences.diff")
-        .getStatusCode());
-  }
-
-  @Test
-  public void getDiffPreferences() throws Exception {
-    RestResponse r = adminSession.get("/accounts/" + admin.email
-        + "/preferences.diff");
-    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
-    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o =
-        newGson().fromJson(r.getReader(), DiffPreferencesInfo.class);
-
-    assertThat(o.context).isEqualTo(d.context);
-    assertThat(o.tabSize).isEqualTo(d.tabSize);
-    assertThat(o.lineLength).isEqualTo(d.lineLength);
-    assertThat(o.cursorBlinkRate).isEqualTo(d.cursorBlinkRate);
-    assertThat(o.expandAllComments).isNull();
-    assertThat(o.intralineDifference).isEqualTo(d.intralineDifference);
-    assertThat(o.manualReview).isNull();
-    assertThat(o.retainHeader).isNull();
-    assertThat(o.showLineEndings).isEqualTo(d.showLineEndings);
-    assertThat(o.showTabs).isEqualTo(d.showTabs);
-    assertThat(o.showWhitespaceErrors).isEqualTo(d.showWhitespaceErrors);
-    assertThat(o.skipDeleted).isNull();
-    assertThat(o.skipUncommented).isNull();
-    assertThat(o.syntaxHighlighting).isEqualTo(d.syntaxHighlighting);
-    assertThat(o.hideTopMenu).isNull();
-    assertThat(o.autoHideDiffTableHeader).isEqualTo(d.autoHideDiffTableHeader);
-    assertThat(o.hideLineNumbers).isNull();
-    assertThat(o.renderEntireFile).isNull();
-    assertThat(o.hideEmptyPane).isNull();
-    assertThat(o.matchBrackets).isNull();
-    assertThat(o.lineWrapping).isNull();
-    assertThat(o.ignoreWhitespace).isEqualTo(d.ignoreWhitespace);
-    assertThat(o.theme).isEqualTo(d.theme);
-  }
-
-  @Test
-  public void setDiffPreferences() throws Exception {
-    DiffPreferencesInfo i = DiffPreferencesInfo.defaults();
-
-    // change all default values
-    i.context *= -1;
-    i.tabSize *= -1;
-    i.lineLength *= -1;
-    i.cursorBlinkRate = 500;
-    i.theme = Theme.MIDNIGHT;
-    i.ignoreWhitespace = Whitespace.IGNORE_ALL;
-    i.expandAllComments ^= true;
-    i.intralineDifference ^= true;
-    i.manualReview ^= true;
-    i.retainHeader ^= true;
-    i.showLineEndings ^= true;
-    i.showTabs ^= true;
-    i.showWhitespaceErrors ^= true;
-    i.skipDeleted ^= true;
-    i.skipUncommented ^= true;
-    i.syntaxHighlighting ^= true;
-    i.hideTopMenu ^= true;
-    i.autoHideDiffTableHeader ^= true;
-    i.hideLineNumbers ^= true;
-    i.renderEntireFile ^= true;
-    i.hideEmptyPane ^= true;
-    i.matchBrackets ^= true;
-    i.lineWrapping ^= true;
-
-    RestResponse r = adminSession.put("/accounts/" + admin.email
-        + "/preferences.diff", i);
-    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
-    DiffPreferencesInfo o = newGson().fromJson(r.getReader(),
-        DiffPreferencesInfo.class);
-
-    assertThat(o.context).isEqualTo(i.context);
-    assertThat(o.tabSize).isEqualTo(i.tabSize);
-    assertThat(o.lineLength).isEqualTo(i.lineLength);
-    assertThat(o.cursorBlinkRate).isEqualTo(i.cursorBlinkRate);
-    assertThat(o.expandAllComments).isEqualTo(i.expandAllComments);
-    assertThat(o.intralineDifference).isNull();
-    assertThat(o.manualReview).isEqualTo(i.manualReview);
-    assertThat(o.retainHeader).isEqualTo(i.retainHeader);
-    assertThat(o.showLineEndings).isNull();
-    assertThat(o.showTabs).isNull();
-    assertThat(o.showWhitespaceErrors).isNull();
-    assertThat(o.skipDeleted).isEqualTo(i.skipDeleted);
-    assertThat(o.skipUncommented).isEqualTo(i.skipUncommented);
-    assertThat(o.syntaxHighlighting).isNull();
-    assertThat(o.hideTopMenu).isEqualTo(i.hideTopMenu);
-    assertThat(o.autoHideDiffTableHeader).isNull();
-    assertThat(o.hideLineNumbers).isEqualTo(i.hideLineNumbers);
-    assertThat(o.renderEntireFile).isEqualTo(i.renderEntireFile);
-    assertThat(o.hideEmptyPane).isEqualTo(i.hideEmptyPane);
-    assertThat(o.matchBrackets).isEqualTo(i.matchBrackets);
-    assertThat(o.lineWrapping).isEqualTo(i.lineWrapping);
-    assertThat(o.ignoreWhitespace).isEqualTo(i.ignoreWhitespace);
-    assertThat(o.theme).isEqualTo(i.theme);
-
-    // Partially fill input record
-    i = new DiffPreferencesInfo();
-    i.tabSize = 42;
-    r = adminSession.put("/accounts/" + admin.email
-        + "/preferences.diff", i);
-    DiffPreferencesInfo a = newGson().fromJson(r.getReader(),
-        DiffPreferencesInfo.class);
-
-    assertThat(a.context).isEqualTo(o.context);
-    assertThat(a.tabSize).isEqualTo(42);
-    assertThat(a.lineLength).isEqualTo(o.lineLength);
-    assertThat(a.expandAllComments).isEqualTo(o.expandAllComments);
-    assertThat(a.intralineDifference).isNull();
-    assertThat(a.manualReview).isEqualTo(o.manualReview);
-    assertThat(a.retainHeader).isEqualTo(o.retainHeader);
-    assertThat(a.showLineEndings).isNull();
-    assertThat(a.showTabs).isNull();
-    assertThat(a.showWhitespaceErrors).isNull();
-    assertThat(a.skipDeleted).isEqualTo(o.skipDeleted);
-    assertThat(a.skipUncommented).isEqualTo(o.skipUncommented);
-    assertThat(a.syntaxHighlighting).isNull();
-    assertThat(a.hideTopMenu).isEqualTo(o.hideTopMenu);
-    assertThat(a.autoHideDiffTableHeader).isNull();
-    assertThat(a.hideLineNumbers).isEqualTo(o.hideLineNumbers);
-    assertThat(a.renderEntireFile).isEqualTo(o.renderEntireFile);
-    assertThat(a.hideEmptyPane).isEqualTo(o.hideEmptyPane);
-    assertThat(a.ignoreWhitespace).isEqualTo(o.ignoreWhitespace);
-    assertThat(a.theme).isEqualTo(o.theme);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java
deleted file mode 100644
index b97219c..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.account;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.KeyMapType;
-import com.google.gerrit.extensions.client.Theme;
-
-import org.apache.http.HttpStatus;
-import org.junit.Test;
-
-import java.io.IOException;
-
-public class EditPreferencesIT extends AbstractDaemonTest {
-  @Test
-  public void getSetEditPreferences() throws Exception {
-    String endPoint = "/accounts/" + admin.email + "/preferences.edit";
-    RestResponse r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
-    EditPreferencesInfo out = getEditPrefInfo(r);
-
-    assertThat(out.lineLength).isEqualTo(100);
-    assertThat(out.tabSize).isEqualTo(8);
-    assertThat(out.cursorBlinkRate).isEqualTo(0);
-    assertThat(out.hideTopMenu).isNull();
-    assertThat(out.showTabs).isTrue();
-    assertThat(out.showWhitespaceErrors).isNull();
-    assertThat(out.syntaxHighlighting).isTrue();
-    assertThat(out.hideLineNumbers).isNull();
-    assertThat(out.matchBrackets).isTrue();
-    assertThat(out.lineWrapping).isNull();
-    assertThat(out.autoCloseBrackets).isNull();
-    assertThat(out.theme).isEqualTo(Theme.DEFAULT);
-    assertThat(out.keyMapType).isEqualTo(KeyMapType.DEFAULT);
-
-    // change some default values
-    out.lineLength = 80;
-    out.tabSize = 4;
-    out.cursorBlinkRate = 500;
-    out.hideTopMenu = true;
-    out.showTabs = false;
-    out.showWhitespaceErrors = true;
-    out.syntaxHighlighting = false;
-    out.hideLineNumbers = true;
-    out.matchBrackets = false;
-    out.lineWrapping = true;
-    out.autoCloseBrackets = true;
-    out.theme = Theme.TWILIGHT;
-    out.keyMapType = KeyMapType.EMACS;
-
-    r = adminSession.put(endPoint, out);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
-
-    r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
-    EditPreferencesInfo info = getEditPrefInfo(r);
-    assertEditPreferences(info, out);
-
-    // Partially filled input record
-    EditPreferencesInfo in = new EditPreferencesInfo();
-    in.tabSize = 42;
-    r = adminSession.put(endPoint, in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
-
-    r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
-    info = getEditPrefInfo(r);
-    out.tabSize = in.tabSize;
-    assertEditPreferences(info, out);
-  }
-
-  private EditPreferencesInfo getEditPrefInfo(RestResponse r)
-      throws IOException {
-    return newGson().fromJson(r.getReader(),
-        EditPreferencesInfo.class);
-  }
-
-  private void assertEditPreferences(EditPreferencesInfo out,
-      EditPreferencesInfo in) {
-    assertThat(out.lineLength).isEqualTo(in.lineLength);
-    assertThat(out.tabSize).isEqualTo(in.tabSize);
-    assertThat(out.cursorBlinkRate).isEqualTo(in.cursorBlinkRate);
-    assertThat(out.hideTopMenu).isEqualTo(in.hideTopMenu);
-    assertThat(out.showTabs).isNull();
-    assertThat(out.showWhitespaceErrors).isEqualTo(in.showWhitespaceErrors);
-    assertThat(out.syntaxHighlighting).isNull();
-    assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
-    assertThat(out.matchBrackets).isNull();
-    assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
-    assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
-    assertThat(out.theme).isEqualTo(in.theme);
-    assertThat(out.keyMapType).isEqualTo(in.keyMapType);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index da1d3ec..f48f9fa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -27,7 +27,7 @@
 public class GetAccountDetailIT extends AbstractDaemonTest {
   @Test
   public void getDetail() throws Exception {
-    RestResponse r = adminSession.get("/accounts/" + admin.username + "/detail/");
+    RestResponse r = adminRestSession.get("/accounts/" + admin.username + "/detail/");
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
     Account account = accountCache.get(admin.getId()).getAccount();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 2543095..3297c60 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -22,11 +22,9 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.PutUsername;
-import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.Collections;
@@ -40,8 +38,8 @@
     PutUsername.Input in = new PutUsername.Input();
     in.username = "myUsername";
     RestResponse r =
-        adminSession.put("/accounts/" + createUser().get() + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+        adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
+    r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(
         in.username);
   }
@@ -50,28 +48,28 @@
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    RestResponse r =
-        adminSession.put("/accounts/" + createUser().get() + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    adminRestSession
+        .put("/accounts/" + createUser().get() + "/username", in)
+        .assertConflict();
   }
 
   @Test
   public void setNew_MethodNotAllowed() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "newUsername";
-    RestResponse r =
-        adminSession.put("/accounts/" + admin.username + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_METHOD_NOT_ALLOWED);
+    adminRestSession
+        .put("/accounts/" + admin.username + "/username", in)
+        .assertMethodNotAllowed();
   }
 
   @Test
   public void delete_MethodNotAllowed() throws Exception {
-    RestResponse r =
-        adminSession.put("/accounts/" + admin.username + "/username");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_METHOD_NOT_ALLOWED);
+    adminRestSession
+        .put("/accounts/" + admin.username + "/username")
+        .assertMethodNotAllowed();
   }
 
-  private Account.Id createUser() throws OrmException {
+  private Account.Id createUser() throws Exception {
     try (ReviewDb db = reviewDbProvider.open()) {
       Account.Id id = new Account.Id(db.nextAccountId());
       Account a = new Account(id, TimeUtil.nowTs());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
new file mode 100644
index 0000000..32cfc9b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -0,0 +1,247 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+public class WatchedProjectsIT extends AbstractDaemonTest {
+
+  private static final String NEW_PROJECT_NAME = "newProjectAccess";
+
+  @Test
+  public void setAndGetWatchedProjects() throws Exception {
+    String projectName1 = createProject(NEW_PROJECT_NAME).get();
+    String projectName2 = createProject(NEW_PROJECT_NAME + "2").get();
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>(2);
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName1;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    pwi = new ProjectWatchInfo();
+    pwi.project = projectName2;
+    pwi.filter = "branch:master";
+    pwi.notifySubmittedChanges = true;
+    pwi.notifyNewPatchSets = true;
+    projectsToWatch.add(pwi);
+
+    List<ProjectWatchInfo> persistedWatchedProjects =
+        gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    assertThat(persistedWatchedProjects)
+        .containsAllIn(projectsToWatch).inOrder();
+  }
+
+  @Test
+  public void setAndDeleteWatchedProjects() throws Exception {
+    String projectName1 = createProject(NEW_PROJECT_NAME).get();
+    String projectName2 = createProject(NEW_PROJECT_NAME + "2").get();
+
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName1;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    pwi = new ProjectWatchInfo();
+    pwi.project = projectName2;
+    pwi.filter = "branch:master";
+    pwi.notifySubmittedChanges = true;
+    pwi.notifyNewPatchSets = true;
+    projectsToWatch.add(pwi);
+
+    // Persist watched projects
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    List<ProjectWatchInfo> d = Lists.newArrayList(pwi);
+    gApi.accounts().self().deleteWatchedProjects(d);
+    projectsToWatch.remove(pwi);
+
+    List<ProjectWatchInfo> persistedWatchedProjects =
+        gApi.accounts().self().getWatchedProjects();
+
+    assertThat(persistedWatchedProjects).doesNotContain(pwi);
+    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+  }
+
+  @Test
+  public void setConflictingWatches() throws Exception {
+    String projectName = createProject(NEW_PROJECT_NAME).get();
+
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.notifySubmittedChanges = true;
+    pwi.notifyNewPatchSets = true;
+    projectsToWatch.add(pwi);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("duplicate entry for project " + projectName);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+  }
+
+  @Test
+  public void setAndGetEmptyWatch() throws Exception {
+    String projectName = createProject(NEW_PROJECT_NAME).get();
+
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    projectsToWatch.add(pwi);
+
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    List<ProjectWatchInfo> persistedWatchedProjects =
+        gApi.accounts().self().getWatchedProjects();
+    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+  }
+
+  @Test
+  public void watchNonExistingProject() throws Exception {
+    String projectName = NEW_PROJECT_NAME + "3";
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>(2);
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    exception.expect(UnprocessableEntityException.class);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+  }
+
+  @Test
+  public void deleteNonExistingProjectWatch() throws Exception {
+    String projectName = project.get();
+
+    // Let another user watch a project
+    setApiUser(admin);
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    // Try to delete a watched project using a different user
+    List<ProjectWatchInfo> d = Lists.newArrayList(pwi);
+    gApi.accounts().self().deleteWatchedProjects(d);
+
+    // Check that trying to delete a non-existing watch doesn't fail
+    setApiUser(user);
+    gApi.accounts().self().deleteWatchedProjects(d);
+  }
+
+  @Test
+  public void modifyProjectWatchUsingOmittedValues() throws Exception {
+    String projectName = project.get();
+
+    // Let another user watch a project
+    setApiUser(admin);
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    // Persist a defined state
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    // Omit previously set value - will set it to false on the server
+    // The response will not carry this field then as we omit sending
+    // false values in JSON
+    pwi.notifyNewChanges = null;
+
+    // Perform update
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    List<ProjectWatchInfo> watchedProjects =
+        gApi.accounts().self().getWatchedProjects();
+
+    assertThat(watchedProjects).containsAllIn(projectsToWatch);
+  }
+
+  @Test
+  public void setAndDeleteWatchedProjectsWithDifferentFilter()
+      throws Exception {
+    String projectName = project.get();
+
+    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.filter = "branch:stable";
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    pwi = new ProjectWatchInfo();
+    pwi.project = projectName;
+    pwi.filter = "branch:master";
+    pwi.notifySubmittedChanges = true;
+    pwi.notifyNewPatchSets = true;
+    projectsToWatch.add(pwi);
+
+    // Persist watched projects
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    List<ProjectWatchInfo> d = Lists.newArrayList(pwi);
+    gApi.accounts().self().deleteWatchedProjects(d);
+    projectsToWatch.remove(pwi);
+
+    List<ProjectWatchInfo> persistedWatchedProjects =
+        gApi.accounts().self().getWatchedProjects();
+
+    assertThat(persistedWatchedProjects).doesNotContain(pwi);
+    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index a9d24fc..c0e6306 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -14,31 +14,25 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Function;
-import com.google.common.base.Strings;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.EventListener;
-import com.google.gerrit.common.EventSource;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -46,28 +40,27 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.events.ChangeMergedEvent;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gson.reflect.TypeToken;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -82,54 +75,33 @@
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 
-@Sandboxed
+@NoHttpd
 public abstract class AbstractSubmit extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config submitWholeTopicEnabled() {
     return submitWholeTopicEnabledConfig();
   }
 
-  private Map<String, String> mergeResults;
-  protected Multimap<String, RefUpdateAttribute> refUpdatedEvents;
-
-  @Inject
-  private ChangeNotes.Factory notesFactory;
-
   @Inject
   private ApprovalsUtil approvalsUtil;
 
   @Inject
-  private IdentifiedUser.GenericFactory factory;
+  private Submit submitHandler;
 
-  @Inject
-  EventSource source;
+  private String systemTimeZone;
 
   @Before
-  public void setUp() throws Exception {
-    mergeResults = Maps.newHashMap();
-    refUpdatedEvents = HashMultimap.create();
-    CurrentUser listenerUser = factory.create(user.id);
-    source.addEventListener(new EventListener() {
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
 
-      @Override
-      public void onEvent(Event event) {
-        if (event instanceof ChangeMergedEvent) {
-          ChangeMergedEvent changeMergedEvent = (ChangeMergedEvent) event;
-          mergeResults.put(changeMergedEvent.change.number,
-              changeMergedEvent.newRev);
-        } else if (event instanceof RefUpdatedEvent) {
-          RefUpdatedEvent e = (RefUpdatedEvent) event;
-          RefUpdateAttribute r = e.refUpdate;
-          refUpdatedEvents.put(r.project + "-" + r.refName, r);
-        }
-      }
-
-    }, listenerUser);
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
   }
 
   @After
@@ -144,7 +116,7 @@
   public void submitToEmptyRepo() throws Exception {
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommitId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
   }
 
   @Test
@@ -241,10 +213,11 @@
     approve(change2.getChangeId());
     approve(change3.getChangeId());
     submit(change3.getChangeId());
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
 
-    change1.assertChange(Change.Status.MERGED, topic, admin);
-    change2.assertChange(Change.Status.MERGED, topic, admin);
-    change3.assertChange(Change.Status.MERGED, topic, admin);
     // Check for the exact change to have the correct submitter.
     assertSubmitter(change3);
     // Also check submitters for changes submitted via the topic relationship.
@@ -269,11 +242,49 @@
         "Initial empty repository", "Change 1", "Change 2", "Change 3");
     if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
       assertThat(commitsInRepo).contains(
-          "Merge changes from topic '" + topic + "'");
+          "Merge changes from topic '" + expectedTopic + "'");
     }
   }
 
   @Test
+  public void submitDraftChange() throws Exception {
+    PushOneCommit.Result draft = createDraftChange();
+    Change.Id num = draft.getChange().getId();
+    submitWithConflict(draft.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+        + "Change " + num + ": Change " + num + " is draft");
+  }
+
+  @Test
+  public void submitDraftPatchSet() throws Exception {
+    PushOneCommit.Result change = createChange();
+    PushOneCommit.Result draft = amendChangeAsDraft(change.getChangeId());
+    Change.Id num = draft.getChange().getId();
+
+    submitWithConflict(draft.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+        + "Change " + num + ": submit rule error: "
+        + "Cannot submit draft patch sets");
+  }
+
+  @Test
+  public void submitWithHiddenBranchInSameTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result visible =
+        createChange("refs/for/master/" + name("topic"));
+    Change.Id num = visible.getChange().getId();
+
+    createBranch(new Branch.NameKey(project, "hidden"));
+    PushOneCommit.Result hidden =
+        createChange("refs/for/hidden/" + name("topic"));
+    approve(hidden.getChangeId());
+    blockRead("refs/heads/hidden");
+
+    submit(visible.getChangeId(), new SubmitInput(), AuthException.class,
+        "A change to be submitted with " + num + " is not visible");
+  }
+
+  @Test
   public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
     // Chain of two commits
     // Push both to topic-branch
@@ -378,12 +389,20 @@
   private void assertSubmitter(PushOneCommit.Result change) throws Exception {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
-    assertThat(info.messages).hasSize(3);
+    Iterable<String> messages = Iterables.transform(info.messages,
+        new Function<ChangeMessageInfo, String>() {
+          @Override
+          public String apply(ChangeMessageInfo in) {
+            return in.message;
+          }
+        });
+    assertThat(messages).hasSize(3);
+    String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(Iterables.getLast(info.messages).message).startsWith(
+      assertThat(last).startsWith(
           "Change has been successfully cherry-picked as ");
     } else {
-      assertThat(Iterables.getLast(info.messages).message).isEqualTo(
+      assertThat(last).isEqualTo(
           "Change has been successfully merged by Administrator");
     }
   }
@@ -396,77 +415,72 @@
     }
   }
 
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master");
-  }
-
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content, String topic)
-          throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master/" + topic);
-  }
-
-  protected PushOneCommit.Result createChange(TestRepository<?> repo,
-      String branch, String subject, String fileName, String content,
-      String topic) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "/" + name(topic));
-  }
-
   protected void submit(String changeId) throws Exception {
-    submit(changeId, HttpStatus.SC_OK, null);
+    submit(changeId, new SubmitInput(), null, null);
+  }
+
+  protected void submit(String changeId, SubmitInput input) throws Exception {
+    submit(changeId, input, null, null);
   }
 
   protected void submitWithConflict(String changeId,
       String expectedError) throws Exception {
-    submit(changeId, HttpStatus.SC_CONFLICT, expectedError);
+    submit(changeId, new SubmitInput(), ResourceConflictException.class,
+        expectedError);
   }
 
-  private void submit(String changeId, int expectedStatus, String msg)
-      throws Exception {
+  protected void submit(String changeId, SubmitInput input,
+      Class<? extends RestApiException> expectedExceptionType,
+      String expectedExceptionMsg) throws Exception {
     approve(changeId);
-    SubmitInput subm = new SubmitInput();
-    RestResponse r =
-        adminSession.post("/changes/" + changeId + "/submit", subm);
-    assertThat(r.getStatusCode()).isEqualTo(expectedStatus);
-    if (expectedStatus == HttpStatus.SC_OK) {
-      checkArgument(msg == null, "msg must be null for successful submits");
-      ChangeInfo change =
-          newGson().fromJson(r.getReader(),
-              new TypeToken<ChangeInfo>() {}.getType());
-      assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
-
-      checkMergeResult(change);
-    } else {
-      checkArgument(!Strings.isNullOrEmpty(msg), "msg must be a valid string " +
-          "containing an error message for unsuccessful submits");
-      assertThat(r.getEntityContent()).isEqualTo(msg);
+    if (expectedExceptionType == null) {
+      assertSubmittable(changeId);
     }
-    r.consume();
+    try {
+      gApi.changes().id(changeId).current().submit(input);
+      if (expectedExceptionType != null) {
+        fail("Expected exception of type "
+            + expectedExceptionType.getSimpleName());
+      }
+    } catch (RestApiException e) {
+      if (expectedExceptionType == null) {
+        throw e;
+      }
+      // More verbose than using assertThat and/or ExpectedException, but gives
+      // us the stack trace.
+      if (!expectedExceptionType.isAssignableFrom(e.getClass())
+          || !e.getMessage().equals(expectedExceptionMsg)) {
+        throw new AssertionError("Expected exception of type "
+            + expectedExceptionType.getSimpleName() + " with message: \""
+            + expectedExceptionMsg + "\" but got exception of type "
+            + e.getClass().getSimpleName() + " with message \""
+            + e.getMessage() + "\"", e);
+      }
+      return;
+    }
+    ChangeInfo change = gApi.changes().id(changeId).info();
+    assertMerged(change.changeId);
   }
 
-  private void checkMergeResult(ChangeInfo change) throws IOException {
-    // Get the revision of the branch after the submit to compare with the
-    // newRev of the ChangeMergedEvent.
-    RestResponse b =
-        adminSession.get("/projects/" + change.project + "/branches/"
-            + change.branch);
-    if (b.getStatusCode() == HttpStatus.SC_OK) {
-      BranchInfo branch =
-          newGson().fromJson(b.getReader(),
-              new TypeToken<BranchInfo>() {}.getType());
-      assertThat(mergeResults).isNotEmpty();
-      String newRev = mergeResults.get(Integer.toString(change._number));
-      assertThat(newRev).isNotNull();
-      assertThat(branch.revision).isEqualTo(newRev);
-    }
-    b.consume();
+  protected void assertSubmittable(String changeId) throws Exception {
+    assertThat(gApi.changes().id(changeId).info().submittable)
+        .named("submit bit on ChangeInfo")
+        .isEqualTo(true);
+    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+    UiAction.Description desc = submitHandler.getDescription(rsrc);
+    assertThat(desc.isVisible()).named("visible bit on submit action").isTrue();
+    assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
+  }
+
+  protected void assertChangeMergedEvents(String... expected) throws Exception {
+    eventRecorder.assertChangeMergedEvents(
+        project.get(), "refs/heads/master", expected);
+  }
+
+  protected void assertRefUpdatedEvents(RevCommit... expected)
+      throws Exception {
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), "refs/heads/master", expected);
   }
 
   protected void assertCurrentRevision(String changeId, int expectedNum,
@@ -478,7 +492,7 @@
         repoManager.openRepository(new Project.NameKey(c.project))) {
       String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum)
           .toRefName();
-      Ref ref = repo.getRef(refName);
+      Ref ref = repo.exactRef(refName);
       assertThat(ref).named(refName).isNotNull();
       assertThat(ref.getObjectId()).isEqualTo(expectedId);
     }
@@ -489,16 +503,20 @@
   }
 
   protected void assertApproved(String changeId) throws Exception {
+    assertApproved(changeId, admin);
+  }
+
+  protected void assertApproved(String changeId, TestAccount user)
+      throws Exception {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
     LabelInfo cr = c.labels.get("Code-Review");
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
-    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(admin.getId());
+    assertThat(new Account.Id(cr.all.get(0)._accountId))
+        .isEqualTo(user.getId());
   }
 
-  protected void assertMerged(PushOneCommit.Result change)
-      throws RestApiException {
-    String changeId = change.getChangeId();
+  protected void assertMerged(String changeId) throws RestApiException {
     ChangeStatus status = gApi.changes().id(changeId).info().status;
     assertThat(status).isEqualTo(ChangeStatus.MERGED);
   }
@@ -514,26 +532,34 @@
   }
 
   protected void assertSubmitter(String changeId, int psId)
-      throws OrmException {
-    ChangeNotes cn = notesFactory.create(
-        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change());
-    PatchSetApproval submitter = approvalsUtil.getSubmitter(
-        db, cn, new PatchSet.Id(cn.getChangeId(), psId));
-    assertThat(submitter.isSubmit()).isTrue();
-    assertThat(submitter.getAccountId()).isEqualTo(admin.getId());
+      throws Exception {
+    assertSubmitter(changeId, psId, admin);
+  }
+
+  protected void assertSubmitter(String changeId, int psId, TestAccount user)
+      throws Exception {
+    Change c =
+        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+    ChangeNotes cn = notesFactory.createChecked(db, c);
+    PatchSetApproval submitter = approvalsUtil.getSubmitter(db, cn,
+        new PatchSet.Id(cn.getChangeId(), psId));
+    assertThat(submitter).isNotNull();
+    assertThat(submitter.isLegacySubmit()).isTrue();
+    assertThat(submitter.getAccountId()).isEqualTo(user.getId());
   }
 
   protected void assertNoSubmitter(String changeId, int psId)
-      throws OrmException {
-    ChangeNotes cn = notesFactory.create(
-        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change());
+      throws Exception {
+    Change c =
+        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+    ChangeNotes cn = notesFactory.createChecked(db, c);
     PatchSetApproval submitter = approvalsUtil.getSubmitter(
         db, cn, new PatchSet.Id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
   }
 
   protected void assertCherryPick(TestRepository<?> testRepo,
-      boolean contentMerge) throws IOException {
+      boolean contentMerge) throws Exception {
     assertRebase(testRepo, contentMerge);
     RevCommit remoteHead = getRemoteHead();
     assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
@@ -541,7 +567,7 @@
   }
 
   protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge)
-      throws IOException {
+      throws Exception {
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo);
     RevCommit remoteHead = getRemoteHead();
@@ -555,56 +581,27 @@
     assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
   }
 
-  private RevCommit getHead(Repository repo) throws IOException {
-    return getHead(repo, "HEAD");
-  }
-
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch)
-      throws IOException {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return getHead(repo, "refs/heads/" + branch);
-    }
-  }
-
-  protected RevCommit getRemoteHead()
-      throws IOException {
-    return getRemoteHead(project, "master");
-  }
-
-
   protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch)
-      throws IOException {
+      throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.markStart(rw.parseCommit(
-          repo.getRef("refs/heads/" + branch).getObjectId()));
+          repo.exactRef("refs/heads/" + branch).getObjectId()));
       return Lists.newArrayList(rw);
     }
   }
 
-  protected List<RevCommit> getRemoteLog() throws IOException {
+  protected List<RevCommit> getRemoteLog() throws Exception {
     return getRemoteLog(project, "master");
   }
 
-  protected RefUpdateAttribute getOneRefUpdate(String key) {
-    Collection<RefUpdateAttribute> refUpdates = refUpdatedEvents.get(key);
-    assertThat(refUpdates).hasSize(1);
-    return refUpdates.iterator().next();
-  }
-
-  private RevCommit getHead(Repository repo, String name) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.getRef(name).getObjectId());
-    }
-  }
-
-  private String getLatestDiff(Repository repo) throws IOException {
+  private String getLatestDiff(Repository repo) throws Exception {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
     return getLatestDiff(repo, oldTreeId, newTreeId);
   }
 
-  private String getLatestRemoteDiff() throws IOException {
+  private String getLatestRemoteDiff() throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       ObjectId oldTreeId = repo.resolve("refs/heads/master~1^{tree}");
@@ -614,7 +611,7 @@
   }
 
   private String getLatestDiff(Repository repo, ObjectId oldTreeId,
-      ObjectId newTreeId) throws IOException {
+      ObjectId newTreeId) throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     try (DiffFormatter fmt = new DiffFormatter(out)) {
       fmt.setRepository(repo);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 2b3d035..741864a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -15,12 +15,24 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
 
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
@@ -40,7 +52,7 @@
     RevCommit head = getRemoteHead();
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change2.getCommitId());
+    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
   }
 
   @Test
@@ -54,14 +66,14 @@
     submit(change2.getChangeId());
 
     RevCommit oldHead = getRemoteHead();
-    testRepo.reset(change.getCommitId());
+    testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     RevCommit head = getRemoteHead();
     assertThat(head.getParentCount()).isEqualTo(2);
     assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change3.getCommitId());
+    assertThat(head.getParent(1)).isEqualTo(change3.getCommit());
   }
 
   @Test
@@ -77,10 +89,95 @@
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "other content");
     submitWithConflict(change2.getChangeId(),
-        "Cannot merge " + change2.getCommit().name() + "\n" +
-        "Change could not be merged due to a path conflict.\n\n" +
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change2.getChange().getId() + ": " +
+        "Change could not be merged due to a path conflict. " +
         "Please rebase the change locally " +
         "and upload the rebased commit for review.");
     assertThat(getRemoteHead()).isEqualTo(oldHead);
   }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommit());
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result change1 = pushFactory.create(
+          db, admin.getIdent(), testRepo, "Change 1", "a", "a")
+        .to("refs/for/master/" + name("topic"));
+
+    PushOneCommit push2 = pushFactory.create(
+          db, admin.getIdent(), testRepo, "Change 2", "b", "b");
+    push2.noParents();
+    PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
+    change2.assertOkStatus();
+
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getParents()).hasLength(2);
+    assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
+    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+    RevCommit afterChange1Head = getRemoteHead();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    SubmitInput failAfterRefUpdates =
+        new TestSubmitInput(new SubmitInput(), true);
+    submit(change2.getChangeId(), failAfterRefUpdates,
+        ResourceConflictException.class, "Failing after ref updates");
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+
+    RevCommit tip;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      tip = rw.parseCommit(repo.exactRef("refs/heads/master").getObjectId());
+      assertThat(tip.getParentCount()).isEqualTo(2);
+      assertThat(tip.getParent(0)).isEqualTo(afterChange1Head);
+      assertThat(tip.getParent(1)).isEqualTo(change2.getCommit());
+    }
+
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(tip);
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index ba98963..880fe89 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -16,11 +16,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -35,6 +40,9 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject
+  private GetRevisionActions getRevisionActions;
+
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -79,6 +87,107 @@
   }
 
   @Test
+  public void revisionActionsETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    approve(parent);
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    approve(changeWithSameTopic);
+    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  public void revisionActionsETagWithHiddenDraftInTopic() throws Exception {
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+
+    setApiUser(user);
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    String draft = createDraftWithTopic().getChangeId();
+    approve(draft);
+
+    setApiUser(user);
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(etag2).isNotEqualTo(etag1);
+    } else {
+      assertThat(etag2).isEqualTo(etag1);
+    }
+  }
+
+  @Test
+  public void revisionActionsAnonymousETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+
+    setApiUserAnonymous();
+    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(changeWithSameTopic);
+
+    setApiUserAnonymous();
+    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChange().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    assertThat(etag2).isEqualTo(etag1);
+  }
+
+  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
@@ -175,13 +284,20 @@
     assertThat(actions).containsKey("rebase");
   }
 
+  private PushOneCommit.Result createCommitAndPush(
+      TestRepository<InMemoryRepository> repo, String ref,
+      String commitMsg, String fileName, String content) throws Exception {
+    return pushFactory
+        .create(db, admin.getIdent(), repo, commitMsg, fileName, content)
+        .to(ref);
+  }
+
   private PushOneCommit.Result createChangeWithTopic(
       TestRepository<InMemoryRepository> repo, String topic,
       String commitMsg, String fileName, String content) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(),
-        repo, commitMsg, fileName, content);
     assertThat(topic).isNotEmpty();
-    return push.to("refs/for/master/" + name(topic));
+    return createCommitAndPush(repo, "refs/for/master/" + name(topic),
+        commitMsg, fileName, content);
   }
 
   private PushOneCommit.Result createChangeWithTopic()
@@ -189,4 +305,10 @@
     return createChangeWithTopic(testRepo, "foo2",
         "a message", "a.txt", "content\n");
   }
+
+  private PushOneCommit.Result createDraftWithTopic()
+      throws Exception {
+    return createCommitAndPush(testRepo, "refs/drafts/master/" + name("foo2"),
+        "a message", "a.txt", "content\n");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
index 1a8e151..04e71eb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
@@ -9,17 +9,19 @@
 OTHER_TESTS = glob(['*IT.java'], excludes = SUBMIT_TESTS)
 
 acceptance_tests(
-  group = 'rest-change-other',
+  group = 'rest_change_other',
   srcs = OTHER_TESTS,
   deps = [
     ':submit_util',
+    '//gerrit-server:server',
+    '//lib/guice:guice',
     '//lib/joda:joda-time',
   ],
   labels = ['rest'],
 )
- 
+
 acceptance_tests(
-  group = 'rest-change-submit',
+  group = 'rest_change_submit',
   srcs = SUBMIT_TESTS,
   deps = [
     ':submit_util',
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
new file mode 100644
index 0000000..c06f02f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
@@ -0,0 +1,36 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+SUBMIT_UTIL_SRCS = [
+  'AbstractSubmit.java',
+  'AbstractSubmitByMerge.java',
+]
+
+SUBMIT_TESTS = glob(['Submit*IT.java'])
+OTHER_TESTS = glob(['*IT.java'], exclude = SUBMIT_TESTS)
+
+acceptance_tests(
+  group = 'rest_change_other',
+  srcs = OTHER_TESTS,
+  deps = [
+    ':submit_util',
+    '//lib/joda:joda-time',
+  ],
+  labels = ['rest'],
+)
+
+acceptance_tests(
+  group = 'rest_change_submit',
+  srcs = SUBMIT_TESTS,
+  deps = [
+    ':submit_util',
+  ],
+  labels = ['rest'],
+)
+
+java_library(
+  name = 'submit_util',
+  srcs = SUBMIT_UTIL_SRCS,
+  deps = [
+    '//gerrit-acceptance-tests:lib',
+  ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 91f2962..40b0391 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -81,13 +81,34 @@
     assertMessage(secondMessage, it.next().message);
   }
 
+  @Test
+  public void postMessageWithTag() throws Exception {
+    String changeId = createChange().getChangeId();
+    String tag = "jenkins";
+    String msg = "Message with tag.";
+    postMessage(changeId, msg, tag);
+    ChangeInfo c = get(changeId);
+    assertThat(c.messages).isNotNull();
+    assertThat(c.messages).hasSize(2);
+    Iterator<ChangeMessageInfo> it = c.messages.iterator();
+    assertThat(it.next().message).isEqualTo("Uploaded patch set 1.");
+    ChangeMessageInfo actual = it.next();
+    assertMessage(msg, actual.message);
+    assertThat(actual.tag).isEqualTo(tag);
+  }
+
   private void assertMessage(String expected, String actual) {
     assertThat(actual).isEqualTo("Patch Set 1:\n\n" + expected);
   }
 
   private void postMessage(String changeId, String msg) throws Exception {
+    postMessage(changeId, msg, null);
+  }
+
+  private void postMessage(String changeId, String msg, String tag) throws Exception {
     ReviewInput in = new ReviewInput();
     in.message = msg;
+    in.tag = tag;
     gApi.changes().id(changeId).current().review(in);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 2229577..875725f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -30,12 +30,9 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-
 public class ChangeOwnerIT extends AbstractDaemonTest {
 
   private TestAccount user2;
@@ -76,27 +73,24 @@
   }
 
   private void assertApproveFails(TestAccount a, String changeId) throws Exception {
-    try {
-      approve(a, changeId);
-    } catch (AuthException expected) {
-      // Expected.
-    }
+    exception.expect(AuthException.class);
+    approve(a, changeId);
   }
 
-  private void grantApproveToChangeOwner() throws IOException,
-      ConfigInvalidException {
-    MetaDataUpdate md = metaDataUpdateFactory.create(project);
-    md.setMessage(String.format("Grant approve to change owner"));
-    ProjectConfig config = ProjectConfig.read(md);
-    AccessSection s = config.getAccessSection("refs/heads/*", true);
-    Permission p = s.getPermission(LABEL + "Code-Review", true);
-    PermissionRule rule = new PermissionRule(config
-        .resolve(SystemGroupBackend.getGroup(SystemGroupBackend.CHANGE_OWNER)));
-    rule.setMin(-2);
-    rule.setMax(+2);
-    p.add(rule);
-    config.commit(md);
-    projectCache.evict(config.getProject());
+  private void grantApproveToChangeOwner() throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Grant approve to change owner"));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection("refs/heads/*", true);
+      Permission p = s.getPermission(LABEL + "Code-Review", true);
+      PermissionRule rule = new PermissionRule(config
+          .resolve(SystemGroupBackend.getGroup(SystemGroupBackend.CHANGE_OWNER)));
+      rule.setMin(-2);
+      rule.setMax(+2);
+      p.add(rule);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
   }
 
   private String createMyChange() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
new file mode 100644
index 0000000..a42f5cd
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -0,0 +1,619 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
+import com.google.gerrit.server.change.PostReviewers;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gson.stream.JsonReader;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+
+public class ChangeReviewersIT extends AbstractDaemonTest {
+  @Test
+  public void addGroupAsReviewer() throws Exception {
+    // Set up two groups, one that is too large too add as reviewer, and one
+    // that is too large to add without confirmation.
+    String largeGroup = createGroup("largeGroup");
+    String mediumGroup = createGroup("mediumGroup");
+
+    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    List<TestAccount> users =
+        createAccounts(largeGroupSize, "addGroupAsReviewer");
+    List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
+    for (TestAccount u : users) {
+      largeGroupUsernames.add(u.username);
+    }
+    List<String> mediumGroupUsernames =
+        largeGroupUsernames.subList(0, mediumGroupSize);
+    gApi.groups().id(largeGroup).addMembers(
+        largeGroupUsernames.toArray(new String[largeGroupSize]));
+    gApi.groups().id(mediumGroup).addMembers(
+        mediumGroupUsernames.toArray(new String[mediumGroupSize]));
+
+    // Attempt to add overly large group as reviewers.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerResult result = addReviewer(changeId, largeGroup);
+    assertThat(result.input).isEqualTo(largeGroup);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error)
+        .contains("has too many members to add them all as reviewers");
+    assertThat(result.reviewers).isNull();
+
+    // Attempt to add medium group without confirmation.
+    result = addReviewer(changeId, mediumGroup);
+    assertThat(result.input).isEqualTo(mediumGroup);
+    assertThat(result.confirm).isTrue();
+    assertThat(result.error)
+        .contains("has " + mediumGroupSize + " members. Do you want to add them"
+            + " all as reviewers?");
+    assertThat(result.reviewers).isNull();
+
+    // Add medium group with confirmation.
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = mediumGroup;
+    in.confirmed = true;
+    result = addReviewer(changeId, in);
+    assertThat(result.input).isEqualTo(mediumGroup);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    assertThat(result.reviewers).hasSize(mediumGroupSize);
+
+    // Verify that group members were added as reviewers.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, notesMigration.readChanges() ? REVIEWER : CC,
+        users.subList(0, mediumGroupSize));
+  }
+
+  @Test
+  public void addCcAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(user.email);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+      assertThat(result.ccs).hasSize(1);
+      AccountInfo ai = result.ccs.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, CC, user);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(1);
+      AccountInfo ai = result.reviewers.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, CC, user);
+    }
+
+    // Verify email was sent to CCed account.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    if (notesMigration.readChanges()) {
+      assertThat(m.body())
+          .contains(admin.fullName + " has uploaded a new change for review.");
+    } else {
+      assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+      assertThat(m.body()).contains("I'd like you to do a code review.");
+    }
+  }
+
+  @Test
+  public void addCcGroup() throws Exception {
+    List<TestAccount> users = createAccounts(6, "addCcGroup");
+    List<String> usernames = new ArrayList<>(6);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    List<TestAccount> firstUsers = users.subList(0, 3);
+    List<String> firstUsernames = usernames.subList(0, 3);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = createGroup("cc1");
+    in.state = CC;
+    gApi.groups().id(in.reviewer)
+        .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+    } else {
+      assertThat(result.ccs).isNull();
+    }
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, CC, firstUsers);
+
+    // Verify emails were sent to each of the group's accounts.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
+    for (TestAccount u : firstUsers) {
+      expectedAddresses.add(u.emailAddress);
+    }
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+
+    // CC a group that overlaps with some existing reviewers and CCed accounts.
+    TestAccount reviewer = accounts.create(name("reviewer"),
+        "addCcGroup-reviewer@example.com", "Reviewer");
+    result = addReviewer(changeId, reviewer.username);
+    assertThat(result.error).isNull();
+    sender.clear();
+    in.reviewer = createGroup("cc2");
+    gApi.groups().id(in.reviewer)
+        .addMembers(usernames.toArray(new String[usernames.size()]));
+    gApi.groups().id(in.reviewer).addMembers(reviewer.username);
+    result = addReviewer(changeId, in);
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.ccs).hasSize(3);
+      assertThat(result.reviewers).isNull();
+      assertReviewers(c, REVIEWER, reviewer);
+      assertReviewers(c, CC, users);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(3);
+      List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
+      expectedUsers.addAll(users);
+      expectedUsers.add(reviewer);
+      assertReviewers(c, CC, expectedUsers);
+    }
+
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    expectedAddresses = new ArrayList<>(4);
+    for (int i = 0; i < 3; i++) {
+      expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
+    }
+    if (notesMigration.readChanges()) {
+      expectedAddresses.add(reviewer.emailAddress);
+    }
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+  }
+
+  @Test
+  public void transitionCcToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    addReviewer(changeId, in);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC, user);
+
+    in.state = REVIEWER;
+    addReviewer(changeId, in);
+    c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+    } else {
+      // If NoteDb not enabled, should have had no effect.
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+    }
+  }
+
+  @Test
+  public void reviewAndAddReviewers() throws Exception {
+    TestAccount observer = accounts.user2();
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false);
+
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    // Verify reviewer and CC were added. If not in NoteDb read mode, both
+    // parties will be returned as CCed.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, observer);
+    } else {
+      // In legacy mode, change owner should be the only reviewer.
+      assertReviewers(c, REVIEWER, admin);
+      assertReviewers(c, CC, user, observer);
+    }
+
+    // Verify emails were sent to added reviewers.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+
+    Message m = messages.get(0);
+    assertThat(m.rcpt())
+        .containsExactly(user.emailAddress,observer.emailAddress);
+    assertThat(m.body())
+        .contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.body())
+        .contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
+
+    m = messages.get(1);
+    assertThat(m.rcpt())
+        .containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+  }
+
+  @Test
+  public void reviewAndAddGroupReviewers() throws Exception {
+    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    List<TestAccount> users =
+        createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
+    List<String> usernames = new ArrayList<>(largeGroupSize);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    String largeGroup = createGroup("largeGroup");
+    String mediumGroup = createGroup("mediumGroup");
+    gApi.groups().id(largeGroup).addMembers(
+        usernames.toArray(new String[largeGroupSize]));
+    gApi.groups().id(mediumGroup).addMembers(
+        usernames.subList(0, mediumGroupSize)
+            .toArray(new String[mediumGroupSize]));
+
+    TestAccount observer = accounts.user2();
+    PushOneCommit.Result r = createChange();
+
+    // Attempt to add overly large group as reviewers.
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false)
+        .reviewer(largeGroup);
+    ReviewResult result = review(
+        r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isNull();
+    assertThat(reviewerResult.error).isNotNull();
+    assertThat(reviewerResult.error).contains("has too many members to add them all as reviewers");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Attempt to add group large enough to require confirmation, without
+    // confirmation, as reviewers.
+    input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false)
+        .reviewer(mediumGroup);
+    result = review(r.getChangeId(), r.getCommit().name(), input,
+        SC_BAD_REQUEST);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    reviewerResult = result.reviewers.get(mediumGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isTrue();
+    assertThat(reviewerResult.error)
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all"
+            + " as reviewers?");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Retrying with confirmation should successfully approve and add reviewers/CCs.
+    input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(mediumGroup, CC, true);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(2);
+
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, users.subList(0, mediumGroupSize));
+    } else {
+      // If not in NoteDb mode, then user is returned with the CC group.
+      assertReviewers(c, REVIEWER, admin);
+      List<TestAccount> expectedCC = users.subList(0, mediumGroupSize);
+      expectedCC.add(user);
+      assertReviewers(c, CC, expectedCC);
+    }
+  }
+
+  @Test
+  public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    addReviewer(changeId, in);
+
+    in.state = REVIEWER;
+    addReviewer(changeId, in);
+
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    setApiUser(user);
+    // NoteDb adds reviewer to a change on every review.
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    deleteReviewer(changeId, user).assertNoContent();
+
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    assertThat(c.reviewerUpdates).isNotNull();
+    assertThat(c.reviewerUpdates).hasSize(3);
+
+    Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
+    ReviewerUpdateInfo reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(CC);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
+        user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
+        admin.getId().get());
+
+    reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(REVIEWER);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
+        user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
+        admin.getId().get());
+
+    reviewerChange = it.next();
+    assertThat(reviewerChange.state).isEqualTo(REMOVED);
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
+        user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
+        admin.getId().get());
+  }
+
+  @Test
+  public void addDuplicateReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(user.email);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+    AddReviewerResult reviewerResult = result.reviewers.get(user.email);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isNull();
+    assertThat(reviewerResult.error).isNull();
+  }
+
+  @Test
+  public void addOverlappingGroups() throws Exception {
+    String emailPrefix = "addOverlappingGroups-";
+    TestAccount user1 = accounts.create(name("user1"),
+        emailPrefix + "user1@example.com", "User1");
+    TestAccount user2 = accounts.create(name("user2"),
+        emailPrefix + "user2@example.com", "User2");
+    TestAccount user3 = accounts.create(name("user3"),
+        emailPrefix + "user3@example.com", "User3");
+    String group1 = createGroup("group1");
+    String group2 = createGroup("group2");
+    gApi.groups().id(group1).addMembers(user1.username, user2.username);
+    gApi.groups().id(group2).addMembers(user2.username, user3.username);
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(group1)
+        .reviewer(group2);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    AddReviewerResult reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(1);
+
+    // Repeat the above for CCs
+    if (!notesMigration.readChanges()) {
+      return;
+    }
+    r = createChange();
+    input = ReviewInput.approve()
+        .reviewer(group1, CC, false)
+        .reviewer(group2, CC, false);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.ccs).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.ccs).hasSize(1);
+
+    // Repeat again with one group REVIEWER, the other CC. The overlapping
+    // member should end up as a REVIEWER.
+    r = createChange();
+    input = ReviewInput.approve()
+        .reviewer(group1, REVIEWER, false)
+        .reviewer(group2, CC, false);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group1);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).hasSize(2);
+    reviewerResult = result.reviewers.get(group2);
+    assertThat(reviewerResult.error).isNull();
+    assertThat(reviewerResult.reviewers).isNull();
+    assertThat(reviewerResult.ccs).hasSize(1);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, String reviewer)
+      throws Exception {
+    return addReviewer(changeId, reviewer, SC_OK);
+  }
+
+  private AddReviewerResult addReviewer(
+      String changeId, String reviewer, int expectedStatus) throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = reviewer;
+    return addReviewer(changeId, in, expectedStatus);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in)
+      throws Exception {
+    return addReviewer(changeId, in, SC_OK);
+  }
+
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in,
+      int expectedStatus) throws Exception {
+    RestResponse resp =
+        adminRestSession.post("/changes/" + changeId + "/reviewers", in);
+    return readContentFromJson(
+        resp, expectedStatus, AddReviewerResult.class);
+  }
+
+  private RestResponse deleteReviewer(String changeId, TestAccount account)
+      throws Exception {
+    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" +
+        account.getId().get());
+  }
+
+  private ReviewResult review(
+      String changeId, String revisionId, ReviewInput in) throws Exception {
+    return review(changeId, revisionId, in, SC_OK);
+  }
+
+  private ReviewResult review(
+      String changeId, String revisionId, ReviewInput in, int expectedStatus)
+      throws Exception {
+    RestResponse resp = adminRestSession.post(
+        "/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
+    return readContentFromJson(resp, expectedStatus, ReviewResult.class);
+  }
+
+  private static <T> T readContentFromJson(
+      RestResponse r, int expectedStatus, Class<T> clazz)
+      throws Exception {
+    r.assertStatus(expectedStatus);
+    JsonReader jsonReader = new JsonReader(r.getReader());
+    jsonReader.setLenient(true);
+    return newGson().fromJson(jsonReader, clazz);
+  }
+
+  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
+      TestAccount... accounts) throws Exception {
+    List<TestAccount> accountList = new ArrayList<>(accounts.length);
+    for (TestAccount a : accounts) {
+      accountList.add(a);
+    }
+    assertReviewers(c, reviewerState, accountList);
+  }
+
+  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
+      Iterable<TestAccount> accounts) throws Exception {
+    Collection<AccountInfo> actualAccounts = c.reviewers.get(reviewerState);
+    if (actualAccounts == null) {
+      assertThat(accounts.iterator().hasNext()).isFalse();
+      return;
+    }
+    assertThat(actualAccounts).isNotNull();
+    List<Integer> actualAccountIds = new ArrayList<>(actualAccounts.size());
+    for (AccountInfo account : actualAccounts) {
+      actualAccountIds.add(account._accountId);
+    }
+    List<Integer> expectedAccountIds = new ArrayList<>();
+    for (TestAccount account : accounts) {
+      expectedAccountIds.add(account.getId().get());
+    }
+    assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
+  }
+
+  private List<TestAccount> createAccounts(int n, String emailPrefix)
+      throws Exception {
+    List<TestAccount> result = new ArrayList<>(n);
+    for (int i = 0; i < n; i++) {
+      result.add(accounts.create(name("u" + i),
+          emailPrefix + "-" + i + "@example.com", "Full Name " + i));
+    }
+    return result;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index c78231b..b7f09d1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -26,7 +26,9 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
 
@@ -46,7 +48,7 @@
     Util.allow(cfg, Permission.OWNER, REGISTERED_USERS, "refs/*");
     Util.allow(
         cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/meta/config");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
     saveProjectConfig(project, cfg);
 
     setApiUser(user);
@@ -56,6 +58,18 @@
   @Test
   @TestProjectInput(cloneAs = "user")
   public void updateProjectConfig() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  public void updateProjectConfigWithCherryPick() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
+  }
+
+  private String testUpdateProjectConfig() throws Exception {
     Config cfg = readProjectConfig();
     assertThat(cfg.getString("project", null, "description")).isNull();
     String desc = "new project description";
@@ -74,6 +88,11 @@
     fetchRefsMetaConfig();
     assertThat(readProjectConfig().getString("project", null, "description"))
         .isEqualTo(desc);
+    String changeRev = gApi.changes().id(id).get().currentRevision;
+    String branchRev = gApi.projects().name(project.get())
+        .branch(RefNames.REFS_CONFIG).get().revision;
+    assertThat(changeRev).isEqualTo(branchRev);
+    return id;
   }
 
   @Test
@@ -99,10 +118,11 @@
       gApi.changes().id(id).current().submit();
       fail("expected submit to fail");
     } catch (ResourceConflictException e) {
+      int n = gApi.changes().id(id).info()._number;
       assertThat(e).hasMessage(
-          "Cannot merge " + r.getCommit().name() + "\n"
-          + "Change contains a project configuration that changes the parent"
-          + " project.\n"
+          "Failed to submit 1 change due to the following problems:\n"
+          + "Change " + n + ": Change contains a project configuration that"
+          + " changes the parent project.\n"
           + "The change must be submitted by a Gerrit administrator.");
     }
 
@@ -126,7 +146,7 @@
   private void fetchRefsMetaConfig() throws Exception {
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config"))
         .call();
-    testRepo.reset("refs/meta/config");
+    testRepo.reset(RefNames.REFS_CONFIG);
   }
 
   private Config readProjectConfig() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 9edf9b2..993f3f5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,25 +16,46 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.git.ChangeAlreadyMergedException;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
 
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-@NoHttpd
 public class CreateChangeIT extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config allowDraftsDisabled() {
@@ -51,10 +72,9 @@
     TestTimeUtil.useSystemTime();
   }
 
-
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
-    ChangeInfo ci = new ChangeInfo();
+    ChangeInput ci = new ChangeInput();
     ci.project = project.get();
     assertCreateFails(ci, BadRequestException.class,
         "branch must be non-empty");
@@ -62,7 +82,7 @@
 
   @Test
   public void createEmptyChange_MissingMessage() throws Exception {
-    ChangeInfo ci = new ChangeInfo();
+    ChangeInput ci = new ChangeInput();
     ci.project = project.get();
     ci.branch = "master";
     assertCreateFails(ci, BadRequestException.class,
@@ -71,32 +91,211 @@
 
   @Test
   public void createEmptyChange_InvalidStatus() throws Exception {
-    ChangeInfo ci = newChangeInfo(ChangeStatus.MERGED);
+    ChangeInput ci = newChangeInput(ChangeStatus.MERGED);
     assertCreateFails(ci, BadRequestException.class,
         "unsupported change status");
   }
 
   @Test
+  public void createEmptyChange_InvalidChangeId() throws Exception {
+   ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+   ci.subject = "Subject\n\nChange-Id: I0000000000000000000000000000000000000000";
+   assertCreateFails(ci, ResourceConflictException.class,
+       "invalid Change-Id line format in commit message footer");
+  }
+
+  @Test
+  public void createEmptyChange_InvalidSubject() throws Exception {
+   ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+   ci.subject = "Change-Id: I1234000000000000000000000000000000000000";
+   assertCreateFails(ci, ResourceConflictException.class,
+       "missing subject; Change-Id must be in commit message footer");
+  }
+
+  @Test
   public void createNewChange() throws Exception {
-    assertCreateSucceeds(newChangeInfo(ChangeStatus.NEW));
+    ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .contains("Change-Id: " + info.changeId);
   }
 
   @Test
-  public void createDraftChange() throws Exception {
+  public void createNewChangeWithChangeId() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    String changeId = "I1234000000000000000000000000000000000000";
+    String changeIdLine = "Change-Id: " + changeId;
+    ci.subject = "Subject\n\n" + changeIdLine;
+    ChangeInfo info = assertCreateSucceeds(ci);
+    assertThat(info.changeId).isEqualTo(changeId);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).contains(changeIdLine);
+  }
+
+  @Test
+  public void createNewChangeSignedOffByFooter() throws Exception {
+    setSignedOffByFooter(true);
+    try {
+      ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+      String message = info.revisions.get(info.currentRevision).commit.message;
+      assertThat(message).contains(
+          String.format("%sAdministrator <%s>", SIGNED_OFF_BY_TAG,
+              admin.getIdent().getEmailAddress()));
+    } finally {
+      setSignedOffByFooter(false);
+    }
+  }
+
+  @Test
+  public void createNewChangeSignedOffByFooterWithChangeId() throws Exception {
+    setSignedOffByFooter(true);
+    try {
+      ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+      String changeId = "I1234000000000000000000000000000000000000";
+      String changeIdLine = "Change-Id: " + changeId;
+      ci.subject = "Subject\n\n" + changeIdLine;
+      ChangeInfo info = assertCreateSucceeds(ci);
+      assertThat(info.changeId).isEqualTo(changeId);
+      String message = info.revisions.get(info.currentRevision).commit.message;
+      assertThat(message).contains(changeIdLine);
+      assertThat(message).contains(
+          String.format("%sAdministrator <%s>", SIGNED_OFF_BY_TAG,
+              admin.getIdent().getEmailAddress()));
+    } finally {
+      setSignedOffByFooter(false);
+    }
+  }
+
+  @Test
+  public void createNewDraftChange() throws Exception {
     assume().that(isAllowDrafts()).isTrue();
-    assertCreateSucceeds(newChangeInfo(ChangeStatus.DRAFT));
+    assertCreateSucceeds(newChangeInput(ChangeStatus.DRAFT));
   }
 
   @Test
-  public void createDraftChangeNotAllowed() throws Exception {
+  public void createNewDraftChangeNotAllowed() throws Exception {
     assume().that(isAllowDrafts()).isFalse();
-    ChangeInfo ci = newChangeInfo(ChangeStatus.DRAFT);
+    ChangeInput ci = newChangeInput(ChangeStatus.DRAFT);
     assertCreateFails(ci, MethodNotAllowedException.class,
         "draft workflow is disabled");
   }
 
-  private ChangeInfo newChangeInfo(ChangeStatus status) {
-    ChangeInfo in = new ChangeInfo();
+  @Test
+  public void noteDbCommit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(
+          repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+
+      assertThat(commit.getShortMessage()).isEqualTo("Create change");
+
+      PersonIdent expectedAuthor = changeNoteUtil.newIdent(
+          accountCache.get(admin.id).getAccount(), c.created, serverIdent.get(),
+          AnonymousCowardNameProvider.DEFAULT);
+      assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
+
+      assertThat(commit.getCommitterIdent())
+          .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
+      assertThat(commit.getParentCount()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void createMergeChange() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in =
+        newMergeChangeInput("branchA", "branchB", "");
+    assertCreateSucceeds(in);
+  }
+
+  @Test
+  public void createMergeChange_Conflicts() throws Exception {
+    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
+    ChangeInput in =
+        newMergeChangeInput("branchA", "branchB", "");
+    assertCreateFails(in, RestApiException.class, "merge conflict");
+  }
+
+  @Test
+  public void createMergeChange_Conflicts_Ours() throws Exception {
+    changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
+    ChangeInput in =
+        newMergeChangeInput("branchA", "branchB", "ours");
+    assertCreateSucceeds(in);
+  }
+
+  @Test
+  public void invalidSource() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in =
+        newMergeChangeInput("branchA", "invalid", "");
+    assertCreateFails(in, BadRequestException.class,
+        "Cannot resolve 'invalid' to a commit");
+  }
+
+  @Test
+  public void invalidStrategy() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in =
+        newMergeChangeInput("branchA", "branchB", "octopus");
+    assertCreateFails(in, BadRequestException.class,
+        "invalid merge strategy: octopus");
+  }
+
+  @Test
+  public void alreadyMerged() throws Exception {
+    ObjectId c0 = testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("second commit")
+        .add("b.txt", "b contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    ChangeInput in =
+        newMergeChangeInput("master", c0.getName(), "");
+    assertCreateFails(in, ChangeAlreadyMergedException.class,
+        "'" + c0.getName() + "' has already been merged");
+  }
+
+  @Test
+  public void onlyContentMerged() throws Exception {
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    // create a change, and cherrypick into master
+    PushOneCommit.Result cId = createChange();
+    RevCommit commitId = cId.getCommit();
+    CherryPickInput cpi = new CherryPickInput();
+    cpi.destination = "master";
+    cpi.message = "cherry pick the commit";
+    ChangeApi orig = gApi.changes()
+        .id(cId.getChangeId());
+    ChangeApi cherry = orig.current().cherryPick(cpi);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    ObjectId remoteId = getRemoteHead();
+    assertThat(remoteId).isNotEqualTo(commitId);
+
+    ChangeInput in =
+        newMergeChangeInput("master", commitId.getName(), "");
+    assertCreateSucceeds(in);
+  }
+
+  private ChangeInput newChangeInput(ChangeStatus status) {
+    ChangeInput in = new ChangeInput();
     in.project = project.get();
     in.branch = "master";
     in.subject = "Empty change";
@@ -105,18 +304,20 @@
     return in;
   }
 
-  private void assertCreateSucceeds(ChangeInfo in) throws Exception {
+  private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
     assertThat(out.branch).isEqualTo(in.branch);
-    assertThat(out.subject).isEqualTo(in.subject);
+    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     assertThat(out.revisions).hasSize(1);
+    assertThat(out.submitted).isNull();
     Boolean draft = Iterables.getOnlyElement(out.revisions.values()).draft;
     assertThat(booleanToDraftStatus(draft)).isEqualTo(in.status);
+    return out;
   }
 
-  private void assertCreateFails(ChangeInfo in,
+  private void assertCreateFails(ChangeInput in,
       Class<? extends RestApiException> errType, String errSubstring)
       throws Exception {
     exception.expect(errType);
@@ -130,4 +331,69 @@
     }
     return draft ? ChangeStatus.DRAFT : ChangeStatus.NEW;
   }
+
+  // TODO(davido): Expose setting of account preferences in the API
+  private void setSignedOffByFooter(boolean value) throws Exception {
+    RestResponse r = adminRestSession.get("/accounts/" + admin.email
+        + "/preferences");
+    r.assertOK();
+    GeneralPreferencesInfo i =
+        newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
+    i.signedOffBy = value;
+
+    r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
+    r.assertOK();
+    GeneralPreferencesInfo o = newGson().fromJson(r.getReader(),
+        GeneralPreferencesInfo.class);
+
+    if (value) {
+      assertThat(o.signedOffBy).isTrue();
+    } else {
+      assertThat(o.signedOffBy).isNull();
+    }
+  }
+
+  private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef,
+      String strategy) {
+    // create a merge change from branchA to master in gerrit
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = targetBranch;
+    in.subject = "merge " + sourceRef + " to " + targetBranch;
+    in.status = ChangeStatus.NEW;
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = sourceRef;
+    in.merge = mergeInput;
+    if (!Strings.isNullOrEmpty(strategy)) {
+      in.merge.strategy = strategy;
+    }
+    return in;
+  }
+
+  private void changeInTwoBranches(String branchA, String fileA, String branchB,
+      String fileB) throws Exception {
+    // create a initial commit in master
+    Result initialCommit = pushFactory
+        .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt",
+            "initial commit")
+        .to("refs/heads/master");
+    initialCommit.assertOkStatus();
+
+    // create two new branches
+    createBranch(new Branch.NameKey(project, branchA));
+    createBranch(new Branch.NameKey(project, branchB));
+
+    // create a commit in branchA
+    Result changeA = pushFactory
+        .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
+        .to("refs/heads/" + branchA);
+    changeA.assertOkStatus();
+
+    // create a commit in branchB
+    PushOneCommit commitB = pushFactory
+        .create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
+    commitB.setParent(initialCommit.getCommit());
+    Result changeB = commitB.to("refs/heads/" + branchB);
+    changeB.assertOkStatus();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 47d071f..31e52f7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -16,36 +16,57 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-import java.io.IOException;
+import java.util.HashMap;
 
+@NoHttpd
 public class DeleteDraftPatchSetIT extends AbstractDaemonTest {
 
+  @Inject
+  private AllUsersName allUsers;
+
   @Test
-  public void deletePatchSet() throws Exception {
+  public void deletePatchSetNotDraft() throws Exception {
     String changeId = createChange().getChangeId();
     PatchSet ps = getCurrentPatchSet(changeId);
     String triplet = project.get() + "~master~" + changeId;
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    RestResponse r = deletePatchSet(changeId, ps, adminSession);
-    assertThat(r.getEntityContent()).isEqualTo("Patch set is not a draft");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Patch set is not a draft");
+    setApiUser(admin);
+    deletePatchSet(changeId, ps);
   }
 
   @Test
@@ -56,26 +77,188 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse r = deletePatchSet(changeId, ps, userSession);
-    assertThat(r.getEntityContent()).isEqualTo("Not found: " + changeId);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + changeId);
+    setApiUser(user);
+    deletePatchSet(changeId, ps);
   }
 
   @Test
   public void deleteDraftPatchSetAndChange() throws Exception {
     String changeId = createDraftChangeWith2PS();
     PatchSet ps = getCurrentPatchSet(changeId);
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse r = deletePatchSet(changeId, ps, adminSession);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
-    assertThat(getChange(changeId).patchSets()).hasSize(1);
+    Change.Id id = ps.getId().getParentKey();
+
+    DraftInput din = new DraftInput();
+    din.path = "a.txt";
+    din.message = "comment on a.txt";
+    gApi.changes().id(changeId).current().createDraft(din);
+
+    if (notesMigration.writeChanges()) {
+      assertThat(getDraftRef(admin, id)).isNotNull();
+    }
+
+    ChangeData cd = getChange(changeId);
+    assertThat(cd.patchSets()).hasSize(2);
+    assertThat(cd.change().currentPatchSetId().get()).isEqualTo(2);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.DRAFT);
+    deletePatchSet(changeId, ps);
+
+    cd = getChange(changeId);
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(cd.change().currentPatchSetId().get()).isEqualTo(1);
+
     ps = getCurrentPatchSet(changeId);
-    r = deletePatchSet(changeId, ps, adminSession);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    deletePatchSet(changeId, ps);
     assertThat(queryProvider.get().byKeyPrefix(changeId)).isEmpty();
+
+    if (notesMigration.writeChanges()) {
+      assertThat(getDraftRef(admin, id)).isNull();
+      assertThat(getMetaRef(id)).isNull();
+    }
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(id.get());
+  }
+
+  @Test
+  public void deleteDraftPS1() throws Exception {
+    String changeId = createDraftChangeWith2PS();
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "Change message";
+    CommentInput cin = new CommentInput();
+    cin.line = 1;
+    cin.patchSet = 1;
+    cin.path = PushOneCommit.FILE_NAME;
+    cin.side = Side.REVISION;
+    cin.message = "Inline comment";
+    rin.comments = new HashMap<>();
+    rin.comments.put(cin.path, ImmutableList.of(cin));
+    gApi.changes().id(changeId).revision(1).review(rin);
+
+    ChangeData cd = getChange(changeId);
+    PatchSet.Id delPsId = new PatchSet.Id(cd.getId(), 1);
+    PatchSet ps = cd.patchSet(delPsId);
+    deletePatchSet(changeId, ps);
+
+    cd = getChange(changeId);
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get())
+        .isEqualTo(2);
+
+    // Other entities based on deleted patch sets are also deleted.
+    for (ChangeMessage m : cd.messages()) {
+      assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
+    }
+    for (PatchLineComment c : cd.publishedComments()) {
+      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    }
+  }
+
+  @Test
+  public void deleteDraftPS2() throws Exception {
+    String changeId = createDraftChangeWith2PS();
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "Change message";
+    CommentInput cin = new CommentInput();
+    cin.line = 1;
+    cin.patchSet = 1;
+    cin.path = PushOneCommit.FILE_NAME;
+    cin.side = Side.REVISION;
+    cin.message = "Inline comment";
+    rin.comments = new HashMap<>();
+    rin.comments.put(cin.path, ImmutableList.of(cin));
+    gApi.changes().id(changeId).revision(1).review(rin);
+
+    ChangeData cd = getChange(changeId);
+    PatchSet.Id delPsId = new PatchSet.Id(cd.getId(), 2);
+    PatchSet ps = cd.patchSet(delPsId);
+    deletePatchSet(changeId, ps);
+
+    cd = getChange(changeId);
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get())
+        .isEqualTo(1);
+
+    // Other entities based on deleted patch sets are also deleted.
+    for (ChangeMessage m : cd.messages()) {
+      assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
+    }
+    for (PatchLineComment c : cd.publishedComments()) {
+      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    }
+  }
+
+  @Test
+  public void deleteDraftPatchSetAndPushNewDraftPatchSet() throws Exception {
+    String ref = "refs/drafts/master";
+
+    // Clone repository
+    TestRepository<InMemoryRepository> testRepo =
+        cloneProject(project, admin);
+
+    // Create change
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r1 = push.to(ref);
+    r1.assertOkStatus();
+    String revPs1 = r1.getChange().currentPatchSet().getRevision().get();
+
+    // Push draft patch set
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), ref, admin, testRepo);
+    r2.assertOkStatus();
+    String revPs2 = r2.getChange().currentPatchSet().getRevision().get();
+
+    assertThat(
+        gApi.changes()
+            .id(r1.getChange().getId().get()).get()
+            .currentRevision)
+        .isEqualTo(revPs2);
+
+    // Remove draft patch set
+    gApi.changes()
+        .id(r1.getChange().getId().get())
+        .revision(revPs2)
+        .delete();
+
+    assertThat(
+        gApi.changes()
+            .id(r1.getChange().getId().get()).get()
+            .currentRevision)
+        .isEqualTo(revPs1);
+
+    // Push new draft patch set
+    PushOneCommit.Result r3 = amendChange(
+        r1.getChangeId(), ref, admin, testRepo);
+    r3.assertOkStatus();
+    String revPs3 = r2.getChange().currentPatchSet().getRevision().get();
+
+    assertThat(
+        gApi.changes()
+            .id(r1.getChange().getId().get()).get()
+            .currentRevision)
+        .isEqualTo(revPs3);
+
+    // Check that all patch sets have different SHA1s
+    assertThat(revPs1).doesNotMatch(revPs2);
+    assertThat(revPs2).doesNotMatch(revPs3);
+  }
+
+  private Ref getDraftRef(TestAccount account, Change.Id changeId)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.exactRef(RefNames.refsDraftComments(changeId, account.id));
+    }
+  }
+
+  private Ref getMetaRef(Change.Id changeId) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return repo.exactRef(RefNames.changeMetaRef(changeId));
+    }
   }
 
   private String createDraftChangeWith2PS() throws Exception {
@@ -86,19 +269,15 @@
     return push.to("refs/drafts/master").getChangeId();
   }
 
-  private PatchSet getCurrentPatchSet(String changeId) throws OrmException {
+  private PatchSet getCurrentPatchSet(String changeId) throws Exception {
     return getChange(changeId).currentPatchSet();
   }
 
-  private ChangeData getChange(String changeId) throws OrmException {
+  private ChangeData getChange(String changeId) throws Exception {
     return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
   }
 
-  private static RestResponse deletePatchSet(String changeId,
-      PatchSet ps, RestSession s) throws IOException {
-    return s.delete("/changes/"
-        + changeId
-        + "/revisions/"
-        + ps.getRevision().get());
+  private void deletePatchSet(String changeId, PatchSet ps) throws Exception {
+    gApi.changes().id(changeId).revision(ps.getId().get()).delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index 2707507..3228a22a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -22,18 +22,21 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.testutil.NoteDbMode;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-import java.io.IOException;
+import java.util.Collection;
 
 public class DraftChangeIT extends AbstractDaemonTest {
   @ConfigSuite.Config
@@ -50,9 +53,10 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    RestResponse response = deleteChange(changeId, adminSession);
-    assertThat(response.getEntityContent()).isEqualTo("Change is not a draft");
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    RestResponse response = deleteChange(changeId, adminRestSession);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Change is not a draft: " + c._number);
+    response.assertConflict();
   }
 
   @Test
@@ -65,8 +69,7 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse response = deleteChange(changeId, adminSession);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    deleteChange(changeId, adminRestSession).assertNoContent();
 
     exception.expect(ResourceNotFoundException.class);
     get(triplet);
@@ -83,8 +86,7 @@
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
     assertThat(c.revisions.get(c.currentRevision).draft).isTrue();
-    RestResponse response = publishChange(changeId);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    publishChange(changeId).assertNoContent();
     c = get(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     assertThat(c.revisions.get(c.currentRevision).draft).isNull();
@@ -100,8 +102,7 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse response = publishPatchSet(changeId);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    publishPatchSet(changeId).assertNoContent();
     assertThat(get(triplet).status).isEqualTo(ChangeStatus.NEW);
   }
 
@@ -112,24 +113,51 @@
     r.assertErrorStatus("draft workflow is disabled");
   }
 
-  private PushOneCommit.Result createDraftChange() throws Exception {
-    return pushTo("refs/drafts/master");
+  @Test
+  public void listApprovalsOnDraftChange() throws Exception {
+    assume().that(isAllowDrafts()).isTrue();
+    PushOneCommit.Result result = createDraftChange();
+    result.assertOkStatus();
+    String changeId = result.getChangeId();
+    String triplet = project.get() + "~master~" + changeId;
+
+    gApi.changes().id(triplet).addReviewer(user.fullName);
+
+    ChangeInfo info = get(triplet);
+    LabelInfo label = info.labels.get("Code-Review");
+    assertThat(label.all).hasSize(1);
+    assertThat(label.all.get(0)._accountId).isEqualTo(user.id.get());
+    assertThat(label.all.get(0).value).isEqualTo(0);
+
+    ReviewerState rs = NoteDbMode.readWrite()
+        ? ReviewerState.REVIEWER : ReviewerState.CC;
+    Collection<AccountInfo> ccs = info.reviewers.get(rs);
+    assertThat(ccs).hasSize(1);
+    assertThat(ccs.iterator().next()._accountId).isEqualTo(user.id.get());
+
+    setApiUser(user);
+    gApi.changes().id(triplet).current().review(ReviewInput.recommend());
+    setApiUser(admin);
+
+    label = get(triplet).labels.get("Code-Review");
+    assertThat(label.all).hasSize(1);
+    assertThat(label.all.get(0)._accountId).isEqualTo(user.id.get());
+    assertThat(label.all.get(0).value).isEqualTo(1);
   }
 
   private static RestResponse deleteChange(String changeId,
-      RestSession s) throws IOException {
+      RestSession s) throws Exception {
     return s.delete("/changes/" + changeId);
   }
 
-  private RestResponse publishChange(String changeId) throws IOException {
-    return adminSession.post("/changes/" + changeId + "/publish");
+  private RestResponse publishChange(String changeId) throws Exception {
+    return adminRestSession.post("/changes/" + changeId + "/publish");
   }
 
-  private RestResponse publishPatchSet(String changeId) throws IOException,
-      OrmException {
+  private RestResponse publishPatchSet(String changeId) throws Exception {
     PatchSet patchSet = Iterables.getOnlyElement(
         queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
-    return adminSession.post("/changes/"
+    return adminRestSession.post("/changes/"
         + changeId
         + "/revisions/"
         + patchSet.getRevision().get()
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index aa7305a..a044772 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -14,24 +14,41 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.truth.IterableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.testutil.TestTimeUtil;
 
+import org.junit.AfterClass;
 import org.junit.Before;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 @NoHttpd
 public class HashtagsIT extends AbstractDaemonTest {
   @Before
   public void before() {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
+  }
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
   }
 
   @Test
@@ -48,11 +65,13 @@
     // Adding a single hashtag returns a single hashtag.
     addHashtags(r, "tag2");
     assertThatGet(r).containsExactly("tag2");
+    assertMessage(r, "Hashtag added: tag2");
 
     // Adding another single hashtag to change that already has one hashtag
     // returns a sorted list of hashtags with existing and new.
     addHashtags(r, "tag1");
     assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag1");
   }
 
   @Test
@@ -62,11 +81,13 @@
     // Adding multiple hashtags returns a sorted list of hashtags.
     addHashtags(r, "tag3", "tag1");
     assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtags added: tag1, tag3");
 
     // Adding multiple hashtags to change that already has hashtags returns a
     // sorted list of hashtags with existing and new.
     addHashtags(r, "tag2", "tag4");
     assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
+    assertMessage(r, "Hashtags added: tag2, tag4");
   }
 
   @Test
@@ -76,10 +97,16 @@
     PushOneCommit.Result r = createChange();
     addHashtags(r, "tag2");
     assertThatGet(r).containsExactly("tag2");
+    assertMessage(r, "Hashtag added: tag2");
+    ChangeMessageInfo last = getLastMessage(r);
+
     addHashtags(r, "tag2");
     assertThatGet(r).containsExactly("tag2");
+    assertNoNewMessageSince(r, last);
+
     addHashtags(r, "tag1", "tag2");
     assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag1");
   }
 
   @Test
@@ -89,30 +116,37 @@
     // Leading # is stripped from added tag.
     addHashtags(r, "#tag1");
     assertThatGet(r).containsExactly("tag1");
+    assertMessage(r, "Hashtag added: tag1");
 
     // Leading # is stripped from multiple added tags.
     addHashtags(r, "#tag2", "#tag3");
     assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertMessage(r, "Hashtags added: tag2, tag3");
 
     // Leading # is stripped from removed tag.
     removeHashtags(r, "#tag2");
     assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtag removed: tag2");
 
     // Leading # is stripped from multiple removed tags.
     removeHashtags(r, "#tag1", "#tag3");
     assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtags removed: tag1, tag3");
 
     // Leading # and space are stripped from added tag.
     addHashtags(r, "# tag1");
     assertThatGet(r).containsExactly("tag1");
+    assertMessage(r, "Hashtag added: tag1");
 
     // Multiple leading # are stripped from added tag.
     addHashtags(r, "##tag2");
     assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
+    assertMessage(r, "Hashtag added: tag2");
 
     // Multiple leading spaces and # are stripped from added tag.
     addHashtags(r, "# # tag3");
     assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertMessage(r, "Hashtag added: tag3");
   }
 
   @Test
@@ -124,12 +158,14 @@
     assertThatGet(r).containsExactly("tag1");
     removeHashtags(r, "tag1");
     assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtag removed: tag1");
 
     // Removing a single tag from a change that has multiple tags returns a
     // sorted list of remaining tags.
     addHashtags(r, "tag1", "tag2", "tag3");
     removeHashtags(r, "tag2");
     assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtag removed: tag2");
   }
 
   @Test
@@ -141,6 +177,7 @@
     assertThatGet(r).containsExactly("tag1", "tag2").inOrder();
     removeHashtags(r, "tag1", "tag2");
     assertThatGet(r).isEmpty();
+    assertMessage(r, "Hashtags removed: tag1, tag2");
 
     // Removing multiple tags from a change that has multiple tags returns a
     // sorted list of remaining tags.
@@ -148,6 +185,7 @@
     assertThatGet(r).containsExactly("tag1", "tag2", "tag3", "tag4").inOrder();
     removeHashtags(r, "tag2", "tag4");
     assertThatGet(r).containsExactly("tag1", "tag3").inOrder();
+    assertMessage(r, "Hashtags removed: tag2, tag4");
   }
 
   @Test
@@ -155,20 +193,26 @@
     // Removing a single hashtag from change that has no hashtags returns an
     // empty list.
     PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
     removeHashtags(r, "tag1");
     assertThatGet(r).isEmpty();
+    assertNoNewMessageSince(r, last);
 
     // Removing a single non-existing tag from a change that only has one other
     // tag returns a list of only one tag.
     addHashtags(r, "tag1");
+    last = getLastMessage(r);
     removeHashtags(r, "tag4");
     assertThatGet(r).containsExactly("tag1");
+    assertNoNewMessageSince(r, last);
 
     // Removing a single non-existing tag from a change that has multiple tags
     // returns a sorted list of tags without any deleted.
     addHashtags(r, "tag1", "tag2", "tag3");
+    last = getLastMessage(r);
     removeHashtags(r, "tag4");
     assertThatGet(r).containsExactly("tag1", "tag2", "tag3").inOrder();
+    assertNoNewMessageSince(r, last);
   }
 
   @Test
@@ -181,6 +225,7 @@
     input.remove = Sets.newHashSet("tag1");
     gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
     assertThatGet(r).containsExactly("tag2", "tag3", "tag4");
+    assertMessage(r, "Hashtags added: tag3, tag4\nHashtag removed: tag1");
 
     // Adding and removing the same hashtag actually removes it.
     addHashtags(r, "tag1", "tag2");
@@ -189,6 +234,15 @@
     input.remove = Sets.newHashSet("tag3");
     gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
     assertThatGet(r).containsExactly("tag1", "tag2", "tag4");
+    assertMessage(r, "Hashtag removed: tag3");
+  }
+
+  @Test
+  public void testHashtagWithMixedCase() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "MyHashtag");
+    assertThatGet(r).containsExactly("MyHashtag");
+    assertMessage(r, "Hashtag added: MyHashtag");
   }
 
   private IterableSubject<
@@ -217,4 +271,25 @@
         .id(r.getChange().getId().get())
         .setHashtags(input);
   }
+
+  private void assertMessage(PushOneCommit.Result r, String expectedMessage)
+      throws Exception {
+    assertThat(getLastMessage(r).message).isEqualTo(expectedMessage);
+  }
+
+  private void assertNoNewMessageSince(PushOneCommit.Result r,
+      ChangeMessageInfo expected) throws Exception {
+    checkNotNull(expected);
+    ChangeMessageInfo last = getLastMessage(r);
+    assertThat(last.message).isEqualTo(expected.message);
+    assertThat(last.id).isEqualTo(expected.id);
+  }
+
+  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r)
+      throws Exception {
+    ChangeMessageInfo lastMessage = Iterables.getLast(
+        gApi.changes().id(r.getChange().getId().get()).get().messages, null);
+    assertThat(lastMessage).named(lastMessage.message).isNotNull();
+    return lastMessage;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 405f8e5..9dba788b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -14,27 +14,25 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
-    RestResponse r = adminSession.post("/changes/" + changeId + "/index/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminRestSession
+        .post("/changes/" + changeId + "/index/")
+        .assertNoContent();
   }
 
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
-    blockRead(project, "refs/heads/master");
-    RestResponse r = userSession.post("/changes/" + changeId + "/index/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    blockRead("refs/heads/master");
+    userRestSession
+        .post("/changes/" + changeId + "/index/")
+        .assertNotFound();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
index 3728a51..b0d34f0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
@@ -20,7 +20,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -29,6 +28,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+import java.util.ArrayList;
 import java.util.List;
 
 @NoHttpd
@@ -39,7 +39,7 @@
 
   @Before
   public void setUp() throws Exception {
-    results = Lists.newArrayList();
+    results = new ArrayList<>();
     results.add(push("file contents", null));
     changeId = results.get(0).getChangeId();
     results.add(push("new contents 1", changeId));
@@ -95,6 +95,6 @@
   }
 
   private String commitId(int i) {
-    return results.get(i).getCommitId().name();
+    return results.get(i).getCommit().name();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
new file mode 100644
index 0000000..b66358f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -0,0 +1,269 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class MoveChangeIT extends AbstractDaemonTest {
+  @Test
+  public void moveChangeWithShortRef() throws Exception {
+    // Move change to a different branch using short ref name
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    move(r.getChangeId(), newBranch.getShortName());
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+  }
+
+  @Test
+  public void moveChangeWithFullRef() throws Exception {
+    // Move change to a different branch using full ref name
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    move(r.getChangeId(), newBranch.get());
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+  }
+
+  @Test
+  public void moveChangeWithMessage() throws Exception {
+    // Provide a message using --message flag
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    String moveMessage = "Moving for the move test";
+    move(r.getChangeId(), newBranch.get(), moveMessage);
+    assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
+    StringBuilder expectedMessage = new StringBuilder();
+    expectedMessage.append("Change destination moved from master to moveTest");
+    expectedMessage.append("\n\n");
+    expectedMessage.append(moveMessage);
+    assertThat(r.getChange().messages().get(1).getMessage())
+        .isEqualTo(expectedMessage.toString());
+  }
+
+  @Test
+  public void moveChangeToSameRefAsCurrent() throws Exception {
+    // Move change to the branch same as change's destination
+    PushOneCommit.Result r = createChange();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is already destined for the specified branch");
+    move(r.getChangeId(), r.getChange().change().getDest().get());
+  }
+
+  @Test
+  public void moveChangeToSameChangeId() throws Exception {
+    // Move change to a branch with existing change with same change ID
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    int changeNum = r.getChange().change().getChangeId();
+    createChange(newBranch.get(), r.getChangeId());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Destination " + newBranch.getShortName()
+        + " has a different change with same change key " + r.getChangeId());
+    move(changeNum, newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToNonExistentRef() throws Exception {
+    // Move change to a non-existing branch
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch = new Branch.NameKey(
+        r.getChange().change().getProject(), "does_not_exist");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Destination " + newBranch.get()
+        + " not found in the project");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveClosedChange() throws Exception {
+    // Move a change which is not open
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    merge(r);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Change is merged");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveMergeCommitChange() throws Exception {
+    // Move a change which has a merge commit as the current PS
+    // Create a merge commit and push for review
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.branch("HEAD").commit().insertChangeId();
+    commitBuilder
+      .parent(r1.getCommit())
+      .parent(r2.getCommit())
+      .message("Move change Merge Commit")
+      .author(admin.getIdent())
+      .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    RevCommit c = commitBuilder.create();
+    pushHead(testRepo, "refs/for/master", false, false);
+
+    // Try to move the merge commit to another branch
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Merge commit cannot be moved");
+    move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToBranchWithoutUploadPerms() throws Exception {
+    // Move change to a destination where user doesn't have upload permissions
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
+    createBranch(newBranch);
+    block(Permission.PUSH,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
+        "refs/for/" + newBranch.get());
+    exception.expect(AuthException.class);
+    exception.expectMessage("Move not permitted");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
+    // Move change for which user does not have abandon permissions
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+    block(Permission.ABANDON,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
+        r.getChange().change().getDest().get());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("Move not permitted");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  @Test
+  public void moveChangeToBranchThatContainsCurrentCommit() throws Exception {
+    // Move change to a branch for which current PS revision is reachable from
+    // tip
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    int changeNum = r.getChange().change().getChangeId();
+
+    // Create a branch with that same commit
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    BranchInput bi = new BranchInput();
+    bi.revision = r.getCommit().name();
+    gApi.projects()
+      .name(newBranch.getParentKey().get())
+      .branch(newBranch.get())
+      .create(bi);
+
+    // Try to move the change to the branch with the same commit
+    exception.expect(ResourceConflictException.class);
+    exception
+        .expectMessage("Current patchset revision is reachable from tip of "
+            + newBranch.get());
+    move(changeNum, newBranch.get());
+  }
+
+  @Test
+  public void moveChangeWithCurrentPatchSetLocked() throws Exception {
+    // Move change that is locked
+    PushOneCommit.Result r = createChange();
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    createBranch(newBranch);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType patchSetLock = Util.patchSetLock();
+    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers,
+        "refs/heads/*");
+    saveProjectConfig(cfg);
+    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("Move not permitted");
+    move(r.getChangeId(), newBranch.get());
+  }
+
+  private void move(int changeNum, String destination)
+      throws RestApiException {
+    gApi.changes().id(changeNum).move(destination);
+  }
+
+  private void move(String changeId, String destination)
+      throws RestApiException {
+    gApi.changes().id(changeId).move(destination);
+  }
+
+  private void move(String changeId, String destination, String message)
+      throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.message = message;
+    gApi.changes().id(changeId).move(in);
+  }
+
+  private PushOneCommit.Result createChange(String branch, String changeId)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    PushOneCommit.Result result = push.to("refs/for/" + branch);
+    result.assertOkStatus();
+    return result;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index efaa1df..af43373 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -19,13 +19,22 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 import java.util.List;
@@ -39,11 +48,16 @@
 
   @Test
   public void submitWithCherryPickIfFastForwardPossible() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
-    assertThat(getRemoteHead().getParent(0))
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParent(0))
       .isEqualTo(change.getCommit().getParent(0));
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -53,7 +67,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -61,36 +75,51 @@
     assertCherryPick(testRepo, false);
     RevCommit newHead = getRemoteHead();
     assertThat(newHead.getParentCount()).isEqualTo(1);
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 2, newHead);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
     assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, newHead);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), newHead.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
         createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
 
-    RevCommit oldHead = getRemoteHead();
-    testRepo.reset(change.getCommitId());
+    testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterThirdSubmit = getRemoteHead();
+    assertThat(headAfterThirdSubmit.getParent(0))
+        .isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, newHead);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit,
+        headAfterSecondSubmit, headAfterThirdSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterThirdSubmit.name());
   }
 
   @Test
@@ -101,19 +130,22 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "other content");
     submitWithConflict(change2.getChangeId(),
-        "Cannot merge " + change2.getCommitId().name() + "\n" +
-        "Change could not be merged due to a path conflict.\n\n" +
-        "Please rebase the change locally and " +
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change2.getChange().getId() + ": Change could not be " +
+        "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommitId());
+    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -123,19 +155,25 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 =
         createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0))
+        .isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, newHead);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -145,20 +183,23 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 =
         createChange("Change 3", "b.txt", "different content");
     submitWithConflict(change3.getChangeId(),
-        "Cannot merge " + change3.getCommitId().name() + "\n" +
-        "Change could not be merged due to a path conflict.\n\n" +
-        "Please rebase the change locally and " +
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change3.getChange().getId() + ": Change could not be " +
+        "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
-    assertCurrentRevision(change3.getChangeId(), 1, change3.getCommitId());
+    assertThat(getRemoteHead()).isEqualTo(newHead);
+    assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
     assertNoSubmitter(change3.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -166,25 +207,28 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
 
+    approve(change.getChangeId());
     approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
+    submit(change3.getChangeId());
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change4.getCommit().getShortMessage());
+        change3.getCommit().getShortMessage());
     assertThat(log.get(1).getId()).isEqualTo(initialHead.getId());
 
+    assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, log.get(0));
+    assertChangeMergedEvents(change3.getChangeId(), log.get(0).name());
   }
 
   @Test
@@ -192,27 +236,34 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
-    assertThat(change3.getCommit().getParent(0)).isEqualTo(change2.getCommit());
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
 
-    // Submit succeeds; change3 is successfully cherry-picked onto head.
-    submit(change3.getChangeId());
-    // Submit succeeds; change2 is successfully cherry-picked onto head
-    // (which was change3's cherry-pick).
+    // Submit succeeds; change2 is successfully cherry-picked onto head.
     submit(change2.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    // Submit succeeds; change is successfully cherry-picked onto head
+    // (which was change2's cherry-pick).
+    submit(change.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
 
-    // change2 is the new tip.
+    // change is the new tip.
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+        change.getCommit().getShortMessage());
     assertThat(log.get(0).getParent(0)).isEqualTo(log.get(1));
 
     assertThat(log.get(1).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
+        change2.getCommit().getShortMessage());
     assertThat(log.get(1).getParent(0)).isEqualTo(log.get(2));
 
     assertThat(log.get(2).getId()).isEqualTo(initialHead.getId());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
+        change.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -220,27 +271,28 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b1");
-    PushOneCommit.Result change3 = createChange("Change 3", "b", "b2");
-    assertThat(change3.getCommit().getParent(0)).isEqualTo(change2.getCommit());
+    PushOneCommit.Result change = createChange("Change 1", "b", "b1");
+    PushOneCommit.Result change2 = createChange("Change 2", "b", "b2");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
 
-    // Submit fails; change3 contains the delta "b1" -> "b2", which cannot be
+    // Submit fails; change2 contains the delta "b1" -> "b2", which cannot be
     // applied against tip.
-    submitWithConflict(change3.getChangeId(),
-        "Cannot merge " + change3.getCommitId().name() + "\n" +
-        "Change could not be merged due to a path conflict.\n\n" +
-        "Please rebase the change locally and " +
+    submitWithConflict(change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change2.getChange().getId() + ": Change could not be " +
+        "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    ChangeInfo info3 = get(change3.getChangeId(), ListChangesOption.MESSAGES);
+    ChangeInfo info3 = get(change2.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(Iterables.getLast(info3.messages).message.toLowerCase())
-        .contains("path conflict");
 
     // Tip has not changed.
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0)).isEqualTo(initialHead.getId());
-    assertNoSubmitter(change3.getChangeId(), 1);
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
@@ -248,16 +300,116 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
-    PushOneCommit.Result change4 = createChange("Change 5", "e", "e");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    PushOneCommit.Result change3 = createChange("Change 3", "e", "e");
 
-    // Out of the above, only submit 4. 2,3 are not related to 4
-    // by topic or ancestor (due to cherrypicking!)
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
+    // Out of the above, only submit change 3. Changes 1 and 2 are not
+    // related to change 3 by topic or ancestor (due to cherrypicking!)
+    approve(change2.getChangeId());
+    submit(change3.getChangeId());
+    RevCommit newHead = getRemoteHead();
 
+    assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change3.getChangeId(), newHead.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitIdenticalTree() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
+
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
+
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
+
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
+
+    ChangeInfo info2 = get(change2.getChangeId());
+    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(Iterables.getLast(info2.messages).message)
+        .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    SubmitInput failAfterRefUpdates =
+        new TestSubmitInput(new SubmitInput(), true);
+    submit(change2.getChangeId(), failAfterRefUpdates,
+        ResourceConflictException.class, "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0))
+          .isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully cherry-picked as "
+            + rev2.name() + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 374fb13..65f3fc8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -15,14 +15,27 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
 
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 import java.util.Map;
@@ -36,58 +49,66 @@
 
   @Test
   public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change.getCommitId());
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
   }
 
   @Test
-  public void submitTwoChangesWithFastForward() throws Exception {
-    RevCommit originalHead = getRemoteHead();
+  public void submitMultipleChangesWithFastForward() throws Exception {
+    RevCommit initialHead = getRemoteHead();
 
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result change2 = createChange();
+    PushOneCommit.Result change3 = createChange();
 
     String id1 = change.getChangeId();
     String id2 = change2.getChangeId();
+    String id3 = change3.getChangeId();
     approve(id1);
-    submit(id2);
+    approve(id2);
+    submit(id3);
 
     RevCommit updatedHead = getRemoteHead();
-    assertThat(updatedHead.getId()).isEqualTo(change2.getCommit());
-    assertThat(updatedHead.getParent(0).getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
+    assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
     assertSubmitter(change.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 1);
+    assertSubmitter(change3.getChangeId(), 1);
     assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
-    assertSubmittedTogether(id1, id2, id1);
-    assertSubmittedTogether(id2, id2, id1);
+    assertSubmittedTogether(id1, id3, id2, id1);
+    assertSubmittedTogether(id2, id3, id2, id1);
+    assertSubmittedTogether(id3, id3, id2, id1);
 
-    RefUpdateAttribute refUpdate = getOneRefUpdate(
-        project.get() + "-refs/heads/master");
-    assertThat(refUpdate).isNotNull();
-    assertThat(refUpdate.oldRev).isEqualTo(originalHead.name());
-    assertThat(refUpdate.newRev).isEqualTo(updatedHead.name());
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(id1, updatedHead.name(),
+        id2, updatedHead.name(),
+        id3, updatedHead.name());
   }
 
   @Test
   public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
     Change.Id id1 = change1.getPatchSetId().getParentKey();
     submitWithConflict(change2.getChangeId(),
-        "The change could not be submitted because it depends on change(s) [" +
-        id1 + "], which could not be submitted because:\n" +
-        id1 + ": needs Code-Review;");
+        "Failed to submit 2 changes due to the following problems:\n"
+        + "Change " + id1 + ": needs Code-Review");
 
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(oldHead.getId());
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
@@ -97,7 +118,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -110,10 +131,87 @@
     assertThat(info.enabled).isNull();
 
     submitWithConflict(change2.getChangeId(),
-        "Cannot merge " + change2.getCommitId().name() + "\n" +
-        "Project policy requires all submissions to be a fast-forward.\n\n" +
-        "Please rebase the change locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change2.getChange().getId() + ": Project policy requires " +
+        "all submissions to be a fast-forward. Please rebase the change " +
+        "locally and upload again for review.");
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    Change.Id id = change.getChange().getId();
+    SubmitInput failAfterRefUpdates =
+        new TestSubmitInput(new SubmitInput(), true);
+    submit(change.getChangeId(), failAfterRefUpdates,
+        ResourceConflictException.class, "Failing after ref updates");
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    ChangeInfo info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+
+    ObjectId rev;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rev = repo.exactRef(psId.toRefName()).getObjectId();
+      assertThat(rev).isNotNull();
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev);
+    }
+
+    submit(change.getChangeId());
+
+    // Change status was updated, and branch tip stayed the same.
+    info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev);
+    }
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents(change.getChangeId(), getRemoteHead().name());
+  }
+
+  @Test
+  public void submitSameCommitsAsInExperimentalBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    grant(Permission.CREATE, project, "refs/heads/*");
+    grant(Permission.PUSH, project, "refs/heads/experimental");
+
+    RevCommit c1 = commitBuilder()
+        .add("b.txt", "1")
+        .message("commit at tip")
+        .create();
+    String id1 = GitUtil.getChangeId(testRepo, c1).get();
+
+    PushResult r1 = pushHead(testRepo, "refs/for/master", false);
+    assertThat(r1.getRemoteUpdate("refs/for/master").getNewObjectId())
+        .isEqualTo(c1.getId());
+
+    PushResult r2 = pushHead(testRepo, "refs/heads/experimental", false);
+    assertThat(r2.getRemoteUpdate("refs/heads/experimental").getNewObjectId())
+        .isEqualTo(c1.getId());
+
+    submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
+    assertSubmitter(id1, 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id1, headAfterSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index eb1d16b..315971f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -22,8 +22,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-import java.util.List;
-
 public class SubmitByMergeAlwaysIT extends AbstractSubmitByMerge {
 
   @Override
@@ -33,47 +31,69 @@
 
   @Test
   public void submitWithMergeIfFastForwardPossible() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParentCount()).isEqualTo(2);
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change.getCommitId());
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit.getParentCount()).isEqualTo(2);
+    assertThat(headAfterSubmit.getParent(0)).isEqualTo(initialHead);
+    assertThat(headAfterSubmit.getParent(1)).isEqualTo(change.getCommit());
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), head.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterSubmit.name());
   }
 
   @Test
   public void submitMultipleChanges() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    // Submit a change so that the remote head advances
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    submit(change.getChangeId());
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    // The remote head should now be a merge of the previous head
+    // and "Change 1"
+    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
+    assertThat(headAfterFirstSubmit.getParent(1).getShortMessage()).isEqualTo(
+        change.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getShortMessage()).isEqualTo(
+        initialHead.getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
+        initialHead.getId());
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
-
+    // Submit three changes at the same time
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
     approve(change2.getChangeId());
     approve(change3.getChangeId());
     submit(change4.getChangeId());
 
-    List<RevCommit> log = getRemoteLog();
-    RevCommit tip = log.get(0);
-    assertThat(tip.getParent(1).getShortMessage()).isEqualTo(
+    // Submitting change 4 should result in changes 2 and 3 also being submitted
+    assertMerged(change2.getChangeId());
+    assertMerged(change3.getChangeId());
+
+    // The remote head should now be a merge of the new head after
+    // the previous submit, and "Change 4".
+    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
         change4.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getShortMessage()).isEqualTo(
-        initialHead.getShortMessage());
-    assertThat(tip.getParent(0).getId()).isEqualTo(initialHead.getId());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
+        headAfterFirstSubmit.getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getId()).isEqualTo(
+        headAfterFirstSubmit.getId());
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(),
+        headAfterSecondSubmit.getCommitterIdent());
 
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), tip.getCommitterIdent());
-
-    assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 04baacd..29fda2d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -1,14 +1,34 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 import java.util.List;
@@ -22,15 +42,18 @@
 
   @Test
   public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change.getCommitId());
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
   }
 
   @Test
@@ -38,40 +61,52 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    PushOneCommit.Result change5 = createChange("Change 5", "f", "f");
 
-    // Change 2 stays untouched.
-    approve(change2.getChangeId());
-    // Change 3 is a fast-forward, no need to merge.
-    submit(change3.getChangeId());
+    // Change 2 is a fast-forward, no need to merge.
+    submit(change2.getChangeId());
 
-    RevCommit tip = getRemoteLog().get(0);
-    assertThat(tip.getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getId()).isEqualTo(
+    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
+        change2.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
         initialHead.getId());
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), tip.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
 
-    // We need to merge change 4.
-    submit(change4.getChangeId());
+    // We need to merge changes 3, 4 and 5.
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change5.getChangeId());
 
-    tip = getRemoteLog().get(0);
-    assertThat(tip.getParent(1).getShortMessage()).isEqualTo(
-        change4.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
+    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
+        change5.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
+        change2.getCommit().getShortMessage());
 
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), tip.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
 
-    assertNew(change2.getChangeId());
+    // First change stays untouched.
+    assertNew(change.getChangeId());
+
+    // The two submit operations should have resulted in two ref-update events
+    // and three change-merged events.
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name(),
+        change5.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -181,9 +216,9 @@
 
     if (isSubmitWholeTopicEnabled()) {
       submitWithConflict(change1b.getChangeId(),
-          "Cannot merge " + change3.getCommit().name() + "\n" +
-          "Change could not be merged due to a path conflict.\n\n" +
-          "Please rebase the change locally " +
+          "Failed to submit 5 changes due to the following problems:\n" +
+          "Change " + change3.getChange().getId() + ": Change could not be " +
+          "merged due to a path conflict. Please rebase the change locally " +
           "and upload the rebased commit for review.");
     } else {
       submit(change1b.getChangeId());
@@ -218,10 +253,13 @@
 
   @Test
   public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     PushOneCommit.Result change1 = createChange(testRepo,  "master",
         "base commit",
         "a.txt", "1", "");
     submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
 
     gApi.projects()
         .name(project.get())
@@ -234,8 +272,8 @@
 
     submit(change2.getChangeId());
 
-    RevCommit tip1 = getRemoteLog(project, "master").get(0);
-    assertThat(tip1.getShortMessage()).isEqualTo(
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(
         change2.getCommit().getShortMessage());
 
     RevCommit tip2 = getRemoteLog(project, "branch").get(0);
@@ -254,21 +292,28 @@
         change3.getCommit().getShortMessage());
     assertThat(log3.get(1).getShortMessage()).isEqualTo(
         change2.getCommit().getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
   public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
-    PushOneCommit.Result change1 = createChange(testRepo,  "master",
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange(testRepo, "master",
         "base commit",
         "a.txt", "1", "");
     submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
 
     gApi.projects()
         .name(project.get())
         .branch("branch")
         .create(new BranchInput());
 
-    PushOneCommit.Result change2 = createChange(testRepo,  "master",
+    PushOneCommit.Result change2 = createChange(testRepo, "master",
         "We want to commit this to master first",
         "a.txt", "2", "");
 
@@ -282,23 +327,25 @@
     assertThat(tip2.getShortMessage()).isEqualTo(
         change1.getCommit().getShortMessage());
 
-    PushOneCommit.Result change3a = createChange(testRepo,  "branch",
+    PushOneCommit.Result change3a = createChange(testRepo, "branch",
         "This commit is based on change2 pending for master, "
         + "but is targeted itself at branch, which doesn't include it.",
         "a.txt", "3", "a-topic-here");
 
     Project.NameKey p3 = createProject("project-related-to-change3");
     TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit initialHead = getRemoteHead(p3, "master");
+    RevCommit repo3Head = getRemoteHead(p3, "master");
     PushOneCommit.Result change3b = createChange(repo3, "master",
         "some accompanying changes for change3a in another repo "
         + "tied together via topic",
         "a.txt", "1", "a-topic-here");
     approve(change3b.getChangeId());
 
-    submitWithConflict(change3a.getChangeId(), "Cannot merge " +
-        change3a.getCommit().name() +
-        "\nMissing dependency");
+    String cnt = isSubmitWholeTopicEnabled() ? "2 changes" : "1 change";
+    submitWithConflict(change3a.getChangeId(),
+        "Failed to submit " + cnt + " due to the following problems:\n"
+        + "Change " + change3a.getChange().getId() + ": depends on change that"
+        + " was not submitted");
 
     RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
     assertThat(tipbranch.getShortMessage()).isEqualTo(
@@ -306,6 +353,143 @@
 
     RevCommit tipmaster = getRemoteLog(p3, "master").get(0);
     assertThat(tipmaster.getShortMessage()).isEqualTo(
-        initialHead.getShortMessage());
+        repo3Head.getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void testGerritWorkflow() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    // We'll setup a master and a stable branch.
+    // Then we create a change to be applied to master, which is
+    // then cherry picked back to stable. The stable branch will
+    // be merged up into master again.
+    gApi.projects()
+        .name(project.get())
+        .branch("stable")
+        .create(new BranchInput());
+
+    // Push a change to master
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo,
+            "small fix", "a.txt", "2");
+    PushOneCommit.Result change = push.to("refs/for/master");
+    submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
+        change.getCommit().getShortMessage());
+
+    // Now cherry pick to stable
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "stable";
+    in.message = "This goes to stable as well\n"
+        + headAfterFirstSubmit.getFullMessage();
+    ChangeApi orig = gApi.changes()
+        .id(change.getChangeId());
+    String cherryId = orig.current().cherryPick(in).id();
+    gApi.changes().id(cherryId).current().review(ReviewInput.approve());
+    gApi.changes().id(cherryId).current().submit();
+
+    // Create the merge locally
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+    testRepo.git().fetch().call();
+    testRepo.git()
+        .branchCreate()
+        .setName("stable")
+        .setStartPoint(stable)
+        .call();
+    testRepo.git()
+        .branchCreate()
+        .setName("master")
+        .setStartPoint(master)
+        .call();
+
+    RevCommit merge = testRepo.commit()
+        .parent(master)
+        .parent(stable)
+        .message("Merge stable into master")
+        .insertChangeId()
+        .create();
+
+    testRepo.branch("refs/heads/master").update(merge);
+    testRepo.git().push()
+        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master"))
+        .call();
+
+    String changeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(changeId);
+    submit(changeId);
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage())
+        .isEqualTo(merge.getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(), headAfterFirstSubmit.name(),
+        changeId, headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void openChangeForTargetBranchPreventsMerge() throws Exception {
+    gApi.projects()
+        .name(project.get())
+        .branch("stable")
+        .create(new BranchInput());
+
+    // Propose a change for master, but leave it open for master!
+    PushOneCommit change =
+        pushFactory.create(db, user.getIdent(), testRepo,
+            "small fix", "a.txt", "2");
+    PushOneCommit.Result change2result = change.to("refs/for/master");
+
+    // Now cherry pick to stable
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "stable";
+    in.message = "it goes to stable branch";
+    ChangeApi orig = gApi.changes()
+        .id(change2result.getChangeId());
+    ChangeApi cherry = orig.current().cherryPick(in);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    // Create a commit locally
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/stable")).call();
+
+    PushOneCommit.Result change3 = createChange(testRepo, "stable",
+        "test","a.txt", "3", "");
+    submitWithConflict(change3.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n" +
+        "Change " + change3.getPatchSetId().getParentKey().get() +
+        ": depends on change that was not submitted");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void mergeWithMissingChange() throws Exception {
+    // create a draft change
+    PushOneCommit.Result draftResult = createDraftChange();
+
+    // create a new change based on the draft change
+    PushOneCommit.Result changeResult = createChange();
+
+    // delete the draft change
+    gApi.changes().id(draftResult.getChangeId()).delete();
+
+    // approve and submit the change
+    submitWithConflict(changeResult.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change " + changeResult.getChange().getId()
+            + ": depends on change that was not submitted");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 222816e..aa5386f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -17,14 +17,27 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -46,37 +59,119 @@
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change.getCommitId());
+    assertThat(head.getId()).isEqualTo(change.getCommit());
     assertThat(head.getParent(0)).isEqualTo(oldHead);
     assertApproved(change.getChangeId());
     assertCurrentRevision(change.getChangeId(), 1, head);
     assertSubmitter(change.getChangeId(), 1);
     assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertRefUpdatedEvents(oldHead, head);
+    assertChangeMergedEvents(change.getChangeId(), head.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithRebase() throws Exception {
+    submitWithRebase(admin);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
+        REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    submitWithRebase(user);
+  }
+
+  private void submitWithRebase(TestAccount submitter) throws Exception {
+    setApiUser(submitter);
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertRebase(testRepo, false);
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0))
+        .isEqualTo(headAfterFirstSubmit);
+    assertApproved(change2.getChangeId(), submitter);
+    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
+    assertSubmitter(change2.getChangeId(), 1, submitter);
+    assertSubmitter(change2.getChangeId(), 2, submitter);
+    assertPersonEquals(admin.getIdent(),
+        headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(submitter.getIdent(),
+        headAfterSecondSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 =
+        createChange("Change 1", "a.txt", "content");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    assertThat(headAfterFirstSubmit.name())
+        .isEqualTo(change1.getCommit().name());
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    assertThat(change2.getCommit().getParent(0))
+        .isNotEqualTo(change1.getCommit());
+    PushOneCommit.Result change3 =
+        createChange("Change 3", "c.txt", "third content");
+    PushOneCommit.Result change4 =
+        createChange("Change 4", "d.txt", "fourth content");
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    submit(change4.getChangeId());
+
+    assertRebase(testRepo, false);
     assertApproved(change2.getChangeId());
-    assertCurrentRevision(change2.getChangeId(), 2, head);
-    assertSubmitter(change2.getChangeId(), 1);
-    assertSubmitter(change2.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertApproved(change3.getChangeId());
+    assertApproved(change4.getChangeId());
+
+    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
+    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
+    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
+
+    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
+    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
+    assertThat(parent).isNotEqualTo(change3.getCommit());
+    assertCurrentRevision(change3.getChangeId(), 2, parent);
+
+    RevCommit grandparent = parse(parent.getParent(0));
+    assertThat(grandparent).isNotEqualTo(change2.getCommit());
+    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
+
+    RevCommit greatgrandparent = parse(grandparent.getParent(0));
+    assertThat(greatgrandparent).isEqualTo(change1.getCommit());
+    assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -130,37 +225,38 @@
     assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
   }
 
-  private RevCommit parse(ObjectId id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = rw.parseCommit(id);
-      rw.parseBody(c);
-      return c;
-    }
-  }
-
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
         createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
-    testRepo.reset(change.getCommitId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterThirdSubmit = getRemoteHead();
+    assertThat(headAfterThirdSubmit.getParent(0))
+        .isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, head);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit,
+        headAfterSecondSubmit, headAfterThirdSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterThirdSubmit.name());
   }
 
   @Test
@@ -171,21 +267,102 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(change2.getChangeId(), "Cannot rebase " +
-        change2.getCommit().name() +
-        ": The change could not be rebased due to a conflict during merge.");
+    submitWithConflict(change2.getChangeId(),
+        "Cannot rebase " + change2.getCommit().name()
+        + ": The change could not be rebased due to a conflict during merge.");
     RevCommit head = getRemoteHead();
-    assertThat(head).isEqualTo(oldHead);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommitId());
+    assertThat(head).isEqualTo(headAfterFirstSubmit);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    SubmitInput failAfterRefUpdates =
+        new TestSubmitInput(new SubmitInput(), true);
+    submit(change2.getChangeId(), failAfterRefUpdates,
+        ResourceConflictException.class, "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully rebased as "
+            + rev2.name() + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
+  }
+
+  private RevCommit parse(ObjectId id) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = rw.parseCommit(id);
+      rw.parseBody(c);
+      return c;
+    }
   }
 
   @Test
   public void submitAfterReorderOfCommits() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     // Create two commits and push.
     RevCommit c1 = commitBuilder()
         .add("a.txt", "1")
@@ -209,20 +386,32 @@
     approve(id1);
     approve(id2);
     submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id2, headAfterSubmit.name(),
+        id1, headAfterSubmit.name());
   }
 
   @Test
   public void submitChangesAfterBranchOnSecond() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
 
-    PushOneCommit.Result change2nd = createChange();
-    approve(change2nd.getChangeId());
-    Project.NameKey project = change2nd.getChange().change().getProject();
+    PushOneCommit.Result change2 = createChange();
+    approve(change2.getChangeId());
+    Project.NameKey project = change2.getChange().change().getProject();
     Branch.NameKey branch = new Branch.NameKey(project, "branch");
-    createBranchWithRevision(branch, change2nd.getCommit().getName());
-    gApi.changes().id(change2nd.getChangeId()).current().submit();
-    assertMerged(change2nd);
-    assertMerged(change);
+    createBranchWithRevision(branch, change2.getCommit().getName());
+    gApi.changes().id(change2.getChangeId()).current().submit();
+    assertMerged(change2.getChangeId());
+    assertMerged(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name(),
+        change2.getChangeId(), newHead.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 0fb5ce1..d5b6f14 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -92,10 +92,10 @@
     approve(g.getChangeId());
     approve(h.getChangeId());
 
-    assertMergeable(e.getChange(), true);
-    assertMergeable(f.getChange(), true);
-    assertMergeable(g.getChange(), false);
-    assertMergeable(h.getChange(), false);
+    assertMergeable(e.getChange());
+    assertMergeable(f.getChange());
+    assertNotMergeable(g.getChange());
+    assertNotMergeable(h.getChange());
 
     PushOneCommit.Result m = createChange("M", "new.txt", "Resolved conflict",
         ImmutableList.of(d.getCommit(), h.getCommit()));
@@ -103,7 +103,7 @@
 
     assertChangeSetMergeable(m.getChange(), true);
 
-    assertMergeable(m.getChange(), true);
+    assertMergeable(m.getChange());
     submit(m.getChangeId());
 
     assertMerged(e.getChangeId());
@@ -138,15 +138,15 @@
     PushOneCommit.Result g = createChange("G", "new.txt", "Conflicting line #2",
         ImmutableList.of(f.getCommit()));
 
-    assertMergeable(e.getChange(), true);
+    assertMergeable(e.getChange());
 
     approve(a.getChangeId());
     approve(b.getChangeId());
     submit(b.getChangeId());
 
-    assertMergeable(e.getChange(), false);
-    assertMergeable(f.getChange(), true);
-    assertMergeable(g.getChange(), true);
+    assertNotMergeable(e.getChange());
+    assertMergeable(f.getChange());
+    assertMergeable(g.getChange());
 
     approve(c.getChangeId());
     approve(d.getChangeId());
@@ -156,7 +156,7 @@
     approve(f.getChangeId());
     approve(g.getChangeId());
 
-    assertMergeable(g.getChange(), false);
+    assertNotMergeable(g.getChange());
     assertChangeSetMergeable(g.getChange(), false);
   }
 
@@ -278,7 +278,7 @@
     approve(c.getChangeId());
     approve(e.getChangeId());
     approve(d.getChangeId());
-    assertMergeable(d.getChange(), false);
+    assertNotMergeable(d.getChange());
     assertChangeSetMergeable(d.getChange(), false);
   }
 
@@ -289,21 +289,22 @@
         .submit();
   }
 
-  private void assertChangeSetMergeable(ChangeData change,
-      boolean expected) throws MissingObjectException,
-          IncorrectObjectTypeException, IOException, OrmException {
-    ChangeSet cs = mergeSuperSet.completeChangeSet(db, change.change());
-    if (expected) {
-      assertThat(submit.unmergeableChanges(cs)).isEmpty();
-    } else {
-      assertThat(submit.unmergeableChanges(cs)).isNotEmpty();
-    }
+  private void assertChangeSetMergeable(ChangeData change, boolean expected)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException,
+      OrmException {
+    ChangeSet cs =
+        mergeSuperSet.completeChangeSet(db, change.change(), user(admin));
+    assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
-  private void assertMergeable(ChangeData change, boolean expected)
-      throws Exception {
+  private void assertMergeable(ChangeData change) throws Exception {
     change.setMergeable(null);
-    assertThat(change.isMergeable()).isEqualTo(expected);
+    assertThat(change.isMergeable()).isTrue();
+  }
+
+  private void assertNotMergeable(ChangeData change) throws Exception {
+    change.setMergeable(null);
+    assertThat(change.isMergeable()).isFalse();
   }
 
   private void assertMerged(String changeId) throws Exception {
@@ -347,7 +348,8 @@
         "refs/for/master");
   }
 
-  private PushOneCommit.Result createChange(String subject) throws Exception {
+  @Override
+  protected PushOneCommit.Result createChange(String subject) throws Exception {
     return createChange(testRepo, subject, "", "",
         Collections.<RevCommit> emptyList(), "refs/for/master");
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 7d2930a..7fab6b1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 
 import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
@@ -22,6 +23,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
@@ -41,6 +43,7 @@
 import java.util.Arrays;
 import java.util.List;
 
+@Sandboxed
 public class SuggestReviewersIT extends AbstractDaemonTest {
   @Inject
   private CreateGroup.Factory createGroupFactory;
@@ -70,7 +73,7 @@
   }
 
   @Test
-  @GerritConfig(name = "suggest.accounts", value = "false")
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void suggestReviewersNoResult1() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers =
@@ -80,8 +83,7 @@
 
   @Test
   @GerritConfigs(
-      {@GerritConfig(name = "suggest.accounts", value = "true"),
-       @GerritConfig(name = "suggest.from", value = "1"),
+      {@GerritConfig(name = "suggest.from", value = "1"),
        @GerritConfig(name = "accounts.visibility", value = "NONE")
       })
   public void suggestReviewersNoResult2() throws Exception {
@@ -141,6 +143,18 @@
   }
 
   @Test
+  public void suggestReviewsPrivateProjectVisibility() throws Exception {
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+
+    setApiUser(user3);
+    block("read", ANONYMOUS_USERS, "refs/*");
+    allow("read", group1.getGroupUUID(), "refs/*");
+    reviewers = suggestReviewers(changeId, user2.username, 2);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void suggestReviewersViewAllAccounts() throws Exception {
     String changeId = createChange().getChangeId();
@@ -169,71 +183,58 @@
   }
 
   @Test
-  @GerritConfig(name = "suggest.fullTextSearch", value = "true")
   public void suggestReviewersFullTextSearch() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
 
-    reviewers = suggestReviewers(changeId, "first", 4);
+    reviewers = suggestReviewers(changeId, "first");
     assertThat(reviewers).hasSize(3);
 
-    reviewers = suggestReviewers(changeId, "first1", 2);
+    reviewers = suggestReviewers(changeId, "first1");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, "last", 4);
+    reviewers = suggestReviewers(changeId, "last");
     assertThat(reviewers).hasSize(3);
 
-    reviewers = suggestReviewers(changeId, "last1", 2);
+    reviewers = suggestReviewers(changeId, "last1");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, "fi la", 4);
+    reviewers = suggestReviewers(changeId, "fi la");
     assertThat(reviewers).hasSize(3);
 
-    reviewers = suggestReviewers(changeId, "la fi", 4);
+    reviewers = suggestReviewers(changeId, "la fi");
     assertThat(reviewers).hasSize(3);
 
-    reviewers = suggestReviewers(changeId, "first1 la", 2);
+    reviewers = suggestReviewers(changeId, "first1 la");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, "fi last1", 2);
+    reviewers = suggestReviewers(changeId, "fi last1");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, "first1 last2", 1);
+    reviewers = suggestReviewers(changeId, "first1 last2");
     assertThat(reviewers).hasSize(0);
 
-    reviewers = suggestReviewers(changeId, name("user"), 7);
+    reviewers = suggestReviewers(changeId, name("user"));
     assertThat(reviewers).hasSize(6);
 
-    reviewers = suggestReviewers(changeId, user1.username, 2);
+    reviewers = suggestReviewers(changeId, user1.username);
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, "example.com", 7);
+    reviewers = suggestReviewers(changeId, "example.com");
     assertThat(reviewers).hasSize(6);
 
-    reviewers = suggestReviewers(changeId, user1.email, 2);
+    reviewers = suggestReviewers(changeId, user1.email);
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, user1.username + " example", 2);
+    reviewers = suggestReviewers(changeId, user1.username + " example");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, user4.email.toLowerCase(), 2);
+    reviewers = suggestReviewers(changeId, user4.email.toLowerCase());
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.get(0).account.email).isEqualTo(user4.email);
   }
 
   @Test
-  @GerritConfigs(
-      {@GerritConfig(name = "suggest.fulltextsearch", value = "true"),
-       @GerritConfig(name = "suggest.fullTextSearchMaxMatches", value = "2")
-  })
-  public void suggestReviewersFullTextSearchLimitMaxMatches() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("user"), 2);
-    assertThat(reviewers).hasSize(2);
-  }
-
-  @Test
   public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
     String changeId = createChange().getChangeId();
     String query = user3.username;
@@ -244,6 +245,53 @@
     assertThat(suggestedReviewerInfos).hasSize(1);
   }
 
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "addreviewer.maxAllowed", value="2"),
+    @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value="1"),
+  })
+  public void suggestReviewersGroupSizeConsiderations() throws Exception {
+    AccountGroup largeGroup = group("large");
+    AccountGroup mediumGroup = group("medium");
+
+    // Both groups have Administrator as a member. Add two users to large
+    // group to push it past maxAllowed, and one to medium group to push it
+    // past maxWithoutConfirmation.
+    user("individual 0", "Test0 Last0", largeGroup, mediumGroup);
+    user("individual 1", "Test1 Last1", largeGroup);
+
+    String changeId = createChange().getChangeId();
+    List<SuggestedReviewerInfo> reviewers;
+    SuggestedReviewerInfo reviewer;
+
+    // Individual account suggestions have count of 1 and no confirm.
+    reviewers = suggestReviewers(changeId, "test", 10);
+    assertThat(reviewers).hasSize(2);
+    reviewer = reviewers.get(0);
+    assertThat(reviewer.count).isEqualTo(1);
+    assertThat(reviewer.confirm).isNull();
+
+    // Large group should never be suggested.
+    reviewers = suggestReviewers(changeId, largeGroup.getName(), 10);
+    assertThat(reviewers).isEmpty();
+
+    // Medium group should be suggested with appropriate count and confirm.
+    reviewers = suggestReviewers(changeId, mediumGroup.getName(), 10);
+    assertThat(reviewers).hasSize(1);
+    reviewer = reviewers.get(0);
+    assertThat(reviewer.group.name).isEqualTo(mediumGroup.getName());
+    assertThat(reviewer.count).isEqualTo(2);
+    assertThat(reviewer.confirm).isTrue();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
+      String query) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .suggestReviewers(query)
+        .get();
+  }
+
   private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
       String query, int n) throws Exception {
     return gApi.changes()
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
index edf55f7..f52fccd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class TopicIT extends AbstractDaemonTest {
@@ -28,16 +25,16 @@
   public void topic() throws Exception {
     Result result = createChange();
     String endpoint = "/changes/" + result.getChangeId() + "/topic";
-    RestResponse response = adminSession.put(endpoint, "topic");
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse response = adminRestSession.put(endpoint, "topic");
+    response.assertOK();
 
-    response = adminSession.delete(endpoint);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    response = adminRestSession.delete(endpoint);
+    response.assertNoContent();
 
-    response = adminSession.put(endpoint, "topic");
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    response = adminRestSession.put(endpoint, "topic");
+    response.assertOK();
 
-    response = adminSession.put(endpoint, "");
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    response = adminRestSession.put(endpoint, "");
+    response.assertNoContent();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK
index 0802e7c..d65b84a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'rest-config',
+  group = 'rest_config',
   srcs = glob(['*IT.java']),
   labels = ['rest']
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
new file mode 100644
index 0000000..b9d3ffb
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'rest_config',
+  srcs = glob(['*IT.java']),
+  labels = ['rest']
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 7e68a03..13f7070 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.PostCaches;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.Arrays;
@@ -34,83 +33,85 @@
 
   @Test
   public void flushAll() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminSession.post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r = adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
+    r.assertOK();
     r.consume();
 
-    r = adminSession.get("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
   }
 
   @Test
   public void flushAll_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH_ALL));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH_ALL)).assertForbidden();
   }
 
   @Test
   public void flushAll_BadRequest() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
+        .assertBadRequest();
   }
 
   @Test
   public void flush() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/project_list");
+    RestResponse r = adminRestSession.get("/config/server/caches/project_list");
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
 
-    r = adminSession.get("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long)1);
 
-    r = adminSession.post("/config/server/caches/",
+    r = adminRestSession.post("/config/server/caches/",
         new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
-    r = adminSession.get("/config/server/caches/project_list");
+    r = adminRestSession.get("/config/server/caches/project_list");
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
 
-    r = adminSession.get("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long)1);
   }
 
   @Test
   public void flush_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH, Arrays.asList("projects")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("projects")))
+        .assertForbidden();
   }
 
   @Test
   public void flush_BadRequest() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH))
+        .assertBadRequest();
   }
 
   @Test
   public void flush_UnprocessableEntity() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/projects");
+    RestResponse r = adminRestSession.get("/config/server/caches/projects");
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
 
-    r = adminSession.post("/config/server/caches/",
+    r = adminRestSession.post("/config/server/caches/",
         new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    r.assertUnprocessableEntity();
     r.consume();
 
-    r = adminSession.get("/config/server/caches/projects");
+    r = adminRestSession.get("/config/server/caches/projects");
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
   }
@@ -120,14 +121,15 @@
     allowGlobalCapabilities(REGISTERED_USERS,
         GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
     try {
-      RestResponse r = userSession.post("/config/server/caches/",
+      RestResponse r = userRestSession.post("/config/server/caches/",
           new PostCaches.Input(FLUSH, Arrays.asList("projects")));
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+      r.assertOK();
       r.consume();
 
-      r = userSession.post("/config/server/caches/",
-          new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")));
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+      userRestSession
+          .post("/config/server/caches/",
+              new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
+          .assertForbidden();
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS,
           GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
index e51ed08..bdcdfae 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -14,17 +14,13 @@
 
 package com.google.gerrit.acceptance.rest.config;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ConfirmEmail;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -44,31 +40,35 @@
   public void confirm() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminRestSession
+        .put("/config/server/email.confirm", in)
+        .assertNoContent();
   }
 
   @Test
   public void confirmForOtherUser_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    adminRestSession
+        .put("/config/server/email.confirm", in)
+        .assertUnprocessableEntity();
   }
 
   @Test
   public void confirmInvalidToken_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = "invalidToken";
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    adminRestSession
+        .put("/config/server/email.confirm", in)
+        .assertUnprocessableEntity();
   }
 
   @Test
   public void confirmAlreadyInUse_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(admin.getId(), user.email);
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    adminRestSession
+        .put("/config/server/email.confirm", in)
+        .assertUnprocessableEntity();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index bb63928..149d05f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -22,48 +22,51 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
 
   @Test
   public void flushCache() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/groups");
+    RestResponse r = adminRestSession.get("/config/server/caches/groups");
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isGreaterThan((long)0);
 
-    r = adminSession.post("/config/server/caches/groups/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r = adminRestSession.post("/config/server/caches/groups/flush");
+    r.assertOK();
     r.consume();
 
-    r = adminSession.get("/config/server/caches/groups");
+    r = adminRestSession.get("/config/server/caches/groups");
     result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isNull();
   }
 
   @Test
   public void flushCache_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/accounts/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .post("/config/server/caches/accounts/flush")
+        .assertForbidden();
   }
 
   @Test
   public void flushCache_NotFound() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/nonExisting/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminRestSession
+        .post("/config/server/caches/nonExisting/flush")
+        .assertNotFound();
   }
 
   @Test
   public void flushCacheWithGerritPrefix() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/gerrit-accounts/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminRestSession
+        .post("/config/server/caches/gerrit-accounts/flush")
+        .assertOK();
   }
 
   @Test
   public void flushWebSessionsCache() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/web_sessions/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminRestSession
+        .post("/config/server/caches/web_sessions/flush")
+        .assertOK();
   }
 
   @Test
@@ -71,12 +74,13 @@
     allowGlobalCapabilities(REGISTERED_USERS,
         GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
     try {
-      RestResponse r = userSession.post("/config/server/caches/accounts/flush");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+      RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
+      r.assertOK();
       r.consume();
 
-      r = userSession.post("/config/server/caches/web_sessions/flush");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+      userRestSession
+          .post("/config/server/caches/web_sessions/flush")
+          .assertForbidden();
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS,
           GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
index 02a1b73..1a1ccd90 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -21,15 +21,14 @@
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class GetCacheIT extends AbstractDaemonTest {
 
   @Test
   public void getCache() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = adminRestSession.get("/config/server/caches/accounts");
+    r.assertOK();
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
 
     assertThat(result.name).isEqualTo("accounts");
@@ -43,28 +42,31 @@
     assertThat(result.hitRatio.mem).isAtMost(100);
     assertThat(result.hitRatio.disk).isNull();
 
-    userSession.get("/config/server/version").consume();
-    r = adminSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    userRestSession.get("/config/server/version").consume();
+    r = adminRestSession.get("/config/server/caches/accounts");
+    r.assertOK();
     result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isEqualTo(2);
   }
 
   @Test
   public void getCache_Forbidden() throws Exception {
-    RestResponse r = userSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .get("/config/server/caches/accounts")
+        .assertForbidden();
   }
 
   @Test
   public void getCache_NotFound() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/nonExisting");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminRestSession
+        .get("/config/server/caches/nonExisting")
+        .assertNotFound();
   }
 
   @Test
   public void getCacheWithGerritPrefix() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/gerrit-accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminRestSession
+        .get("/config/server/caches/gerrit-accounts")
+        .assertOK();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index acd900c..1321650 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -31,8 +30,8 @@
   @Test
   public void getTask() throws Exception {
     RestResponse r =
-        adminSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+        adminRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
+    r.assertOK();
     TaskInfo info =
         newGson().fromJson(r.getReader(),
             new TypeToken<TaskInfo>() {}.getType());
@@ -44,13 +43,13 @@
 
   @Test
   public void getTask_NotFound() throws Exception {
-    RestResponse r =
-        userSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userRestSession
+        .get("/config/server/tasks/" + getLogFileCompressorTaskId())
+        .assertNotFound();
   }
 
   private String getLogFileCompressorTaskId() throws Exception {
-    RestResponse r = adminSession.get("/config/server/tasks/");
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<TaskInfo>>() {}.getType());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 11c61c6..306bb58 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -29,18 +28,18 @@
 public class KillTaskIT extends AbstractDaemonTest {
 
   private void killTask() throws Exception {
-    RestResponse r = adminSession.get("/config/server/tasks/");
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result = newGson().fromJson(r.getReader(),
         new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     int taskCount = result.size();
     assertThat(taskCount).isGreaterThan(0);
 
-    r = adminSession.delete("/config/server/tasks/" + result.get(0).id);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    r = adminRestSession.delete("/config/server/tasks/" + result.get(0).id);
+    r.assertNoContent();
     r.consume();
 
-    r = adminSession.get("/config/server/tasks/");
+    r = adminRestSession.get("/config/server/tasks/");
     result = newGson().fromJson(r.getReader(),
         new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
@@ -48,14 +47,15 @@
   }
 
   private void killTask_NotFound() throws Exception {
-    RestResponse r = adminSession.get("/config/server/tasks/");
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result = newGson().fromJson(r.getReader(),
         new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     assertThat(result.size()).isGreaterThan(0);
 
-    r = userSession.delete("/config/server/tasks/" + result.get(0).id);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userRestSession
+        .delete("/config/server/tasks/" + result.get(0).id)
+        .assertNotFound();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index fb89e1b..d0a5070 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.config.ListCaches.CacheType;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
 
@@ -36,8 +35,8 @@
 
   @Test
   public void listCaches() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
     Map<String, CacheInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<Map<String, CacheInfo>>() {}.getType());
@@ -54,9 +53,9 @@
     assertThat(accountsCacheInfo.hitRatio.mem).isAtMost(100);
     assertThat(accountsCacheInfo.hitRatio.disk).isNull();
 
-    userSession.get("/config/server/version").consume();
-    r = adminSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    userRestSession.get("/config/server/version").consume();
+    r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
     result = newGson().fromJson(r.getReader(),
         new TypeToken<Map<String, CacheInfo>>() {}.getType());
     assertThat(result.get("accounts").entries.mem).isEqualTo(2);
@@ -64,14 +63,15 @@
 
   @Test
   public void listCaches_Forbidden() throws Exception {
-    RestResponse r = userSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .get("/config/server/caches/")
+        .assertForbidden();
   }
 
   @Test
   public void listCacheNames() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/?format=LIST");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = adminRestSession.get("/config/server/caches/?format=LIST");
+    r.assertOK();
     List<String> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<String>>() {}.getType());
@@ -82,8 +82,8 @@
 
   @Test
   public void listCacheNamesTextList() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/?format=TEXT_LIST");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = adminRestSession.get("/config/server/caches/?format=TEXT_LIST");
+    r.assertOK();
     String result = new String(Base64.decode(r.getEntityContent()), UTF_8.name());
     List<String> list = Arrays.asList(result.split("\n"));
     assertThat(list).contains("accounts");
@@ -93,7 +93,8 @@
 
   @Test
   public void listCaches_BadRequest() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/?format=NONSENSE");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .get("/config/server/caches/?format=NONSENSE")
+        .assertBadRequest();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index 58f3361..c405ff2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -30,8 +29,8 @@
 
   @Test
   public void listTasks() throws Exception {
-    RestResponse r = adminSession.get("/config/server/tasks/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = adminRestSession.get("/config/server/tasks/");
+    r.assertOK();
     List<TaskInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<TaskInfo>>() {}.getType());
@@ -51,8 +50,8 @@
 
   @Test
   public void listTasksWithoutViewQueueCapability() throws Exception {
-    RestResponse r = userSession.get("/config/server/tasks/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    RestResponse r = userRestSession.get("/config/server/tasks/");
+    r.assertOK();
     List<TaskInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<TaskInfo>>() {}.getType());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index a656760..54fa74c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -29,6 +30,9 @@
 
 import org.junit.Test;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
+
 public class ServerInfoIT extends AbstractDaemonTest {
 
   @Test
@@ -70,8 +74,7 @@
     @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User"),
   })
   public void serverConfig() throws Exception {
-    RestResponse r = adminSession.get("/config/server/info/");
-    ServerInfo i = newGson().fromJson(r.getReader(), ServerInfo.class);
+    ServerInfo i = getServerConfig();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
@@ -104,8 +107,8 @@
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
     assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
 
-    // gitweb
-    assertThat(i.gitweb).isNull();
+    // plugin
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
 
     // sshd
     assertThat(i.sshd).isNotNull();
@@ -115,12 +118,31 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
+
+    // notedb
+    notesMigration.setReadChanges(true);
+    assertThat(getServerConfig().noteDbEnabled).isTrue();
+    notesMigration.setReadChanges(false);
+    assertThat(getServerConfig().noteDbEnabled).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void serverConfigWithPlugin() throws Exception {
+    Path plugins = tempSiteDir.newFolder("plugins").toPath();
+    Path jsplugin = plugins.resolve("js-plugin-1.js");
+    Files.write(jsplugin, "Gerrit.install(function(self){});\n".getBytes(UTF_8));
+    adminSshSession.exec("gerrit plugin reload");
+
+    ServerInfo i = getServerConfig();
+
+    // plugin
+    assertThat(i.plugin.jsResourcePaths).hasSize(1);
   }
 
   @Test
   public void serverConfigWithDefaults() throws Exception {
-    RestResponse r = adminSession.get("/config/server/info/");
-    ServerInfo i = newGson().fromJson(r.getReader(), ServerInfo.class);
+    ServerInfo i = getServerConfig();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
@@ -154,8 +176,8 @@
     assertThat(i.gerrit.reportBugUrl).isNull();
     assertThat(i.gerrit.reportBugText).isNull();
 
-    // gitweb
-    assertThat(i.gitweb).isNull();
+    // plugin
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
 
     // sshd
     assertThat(i.sshd).isNotNull();
@@ -166,4 +188,10 @@
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
+
+  private ServerInfo getServerConfig() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/info/");
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), ServerInfo.class);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
index 242e1ee..e37567c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
@@ -14,19 +14,15 @@
 
 package com.google.gerrit.acceptance.rest.group;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class AddMemberIT extends AbstractDaemonTest {
   @Test
   public void addNonExistingMember_NotFound() throws Exception {
-    int status =
-        adminSession.put("/groups/Administrators/members/non-existing")
-            .getStatusCode();
-    assertThat(status).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminRestSession
+        .put("/groups/Administrators/members/non-existing")
+        .assertNotFound();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK
index d991417..1947148 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'rest-group',
+  group = 'rest_group',
   srcs = glob(['*IT.java']),
   labels = ['rest']
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
new file mode 100644
index 0000000..d9a400c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
@@ -0,0 +1,8 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'rest_group',
+  srcs = glob(['*IT.java']),
+  labels = ['rest']
+)
+
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java
new file mode 100644
index 0000000..31e7382
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.group;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+
+import org.junit.Test;
+
+public class CreateGroupIT extends AbstractDaemonTest {
+
+  @Test
+  public void createGroup() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name("group");
+    gApi.groups().create(in);
+    AccountGroup accountGroup =
+        groupCache.get(new AccountGroup.NameKey(in.name));
+    assertThat(accountGroup).isNotNull();
+    assertThat(accountGroup.getName()).isEqualTo(in.name);
+  }
+
+  @Test
+  public void createGroupAlreadyExists() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name("group");
+    gApi.groups().create(in);
+    assertThat(groupCache.get(new AccountGroup.NameKey(in.name))).isNotNull();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group '" + in.name + "' already exists");
+    gApi.groups().create(in);
+  }
+
+  @Test
+  public void createGroupWithDifferentCase() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name("group");
+    gApi.groups().create(in);
+    assertThat(groupCache.get(new AccountGroup.NameKey(in.name))).isNotNull();
+
+    GroupInput inLowerCase = new GroupInput();
+    inLowerCase.name =  in.name.toUpperCase();
+    gApi.groups().create(inLowerCase);
+    assertThat(groupCache.get(new AccountGroup.NameKey(inLowerCase.name)))
+        .isNotNull();
+  }
+
+  @Test
+  public void createSystemGroupWithDifferentCase() throws Exception {
+    String registeredUsers = "Registered Users";
+    GroupInput in = new GroupInput();
+    in.name = registeredUsers.toUpperCase();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group '" + registeredUsers + "' already exists");
+    gApi.groups().create(in);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
new file mode 100644
index 0000000..c78b291
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -0,0 +1,493 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.group.SystemGroupBackend;
+
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+
+public class AccessIT extends AbstractDaemonTest {
+
+  private final String PROJECT_NAME = "newProject";
+
+  private final String REFS_ALL = Constants.R_REFS + "*";
+  private final String REFS_HEADS = Constants.R_HEADS + "*";
+
+  private final String LABEL_CODE_REVIEW = "Code-Review";
+
+  private String newProjectName;
+  private ProjectApi pApi;
+
+  @Before
+  public void setUp() throws Exception  {
+    newProjectName = createProject(PROJECT_NAME).get();
+    pApi = gApi.projects().name(newProjectName);
+  }
+
+  @Test
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi.access().inheritsFrom.name;
+    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    Project.NameKey p = new Project.NameKey(newProjectName);
+    RevCommit initialHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(p.get(), RefNames.REFS_CONFIG,
+        null, initialHead,
+        initialHead, updatedHead);
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions
+        .remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL +LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions
+        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .rules.remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL +LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS)
+        .permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Create a change to apply
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply =
+        createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not administrator");
+    gApi.projects().name(newProjectName).access(accessInput);
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    gApi.projects().name(newProjectName).access(accessInput);
+
+    assertThat(pApi.access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedAccessSectionInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    pApi.access(accessInput);
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    AccountGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators"));
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(
+        adminGroup.getGroupUUID().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH,
+        permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    AccountGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators"));
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(
+        adminGroup.getGroupUUID().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE,
+        permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedProjectAccessInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedProjectAccessInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void unknownPermissionRemainsUnchanged() throws Exception {
+    String access = "access";
+    String unknownPermission = "unknownPermission";
+    String registeredUsers = "group Registered Users";
+    String refsFor = "refs/for/*";
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo =
+        cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config = gApi.projects()
+        .name(allProjects.get())
+        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+
+    // Append and push unknown permission
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
+    config = cfg.toText();
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), allProjectsRepo, "Subject", "project.config",
+        config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    // Verify that unknownPermission is present
+    config = gApi.projects()
+        .name(allProjects.get())
+        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+    cfg.fromText(config);
+    assertThat(cfg.getString(access, refsFor, unknownPermission))
+        .isEqualTo(registeredUsers);
+
+    // Make permission change through API
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+    accessInput.add.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+    accessInput.add.clear();
+    accessInput.remove.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Verify that unknownPermission is still present
+    config = gApi.projects()
+        .name(allProjects.get())
+        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+    cfg.fromText(config);
+    assertThat(cfg.getString(access, refsFor, unknownPermission))
+        .isEqualTo(registeredUsers);
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(
+        SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL
+        + LABEL_CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(
+        SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
index c1618fb..d53e69a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
@@ -1,13 +1,13 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'rest-project',
+  group = 'rest_project',
   srcs = glob(['*IT.java']),
   deps = [
     ':branch',
     ':project',
   ],
-  labels = ['rest']
+  labels = ['rest'],
 )
 
 java_library(
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
new file mode 100644
index 0000000..579171f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
@@ -0,0 +1,37 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'rest_project',
+  srcs = glob(['*IT.java']),
+  deps = [
+    ':branch',
+    ':project',
+  ],
+  labels = ['rest'],
+)
+
+java_library(
+  name = 'branch',
+  srcs = [
+    'BranchAssert.java',
+  ],
+  deps = [
+    '//lib:truth',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+  ],
+)
+
+java_library(
+  name = 'project',
+  srcs = [
+    'ProjectAssert.java',
+  ],
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:gwtorm',
+    '//lib:truth',
+  ],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 682059c..23fe562 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -23,9 +24,8 @@
 import com.google.gerrit.server.project.BanCommit;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 public class BanCommitIT extends AbstractDaemonTest {
@@ -37,29 +37,31 @@
         .create();
 
     RestResponse r =
-        adminSession.put("/projects/" + project.get() + "/ban/",
+        adminRestSession.put("/projects/" + project.get() + "/ban/",
             BanCommit.Input.fromCommits(c.name()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.newlyBanned)).isEqualTo(c.name());
     assertThat(info.alreadyBanned).isNull();
     assertThat(info.ignored).isNull();
 
-    PushResult pushResult = pushHead(testRepo, "refs/heads/master", false);
-    assertThat(pushResult.getRemoteUpdate("refs/heads/master").getMessage())
-        .startsWith("contains banned commit");
+    RemoteRefUpdate u = pushHead(testRepo, "refs/heads/master", false)
+        .getRemoteUpdate("refs/heads/master");
+    assertThat(u).isNotNull();
+    assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
+    assertThat(u.getMessage()).startsWith("contains banned commit");
   }
 
   @Test
   public void banAlreadyBannedCommit() throws Exception {
     RestResponse r =
-        adminSession.put("/projects/" + project.get() + "/ban/",
+        adminRestSession.put("/projects/" + project.get() + "/ban/",
             BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
     r.consume();
 
-    r = adminSession.put("/projects/" + project.get() + "/ban/",
+    r = adminRestSession.put("/projects/" + project.get() + "/ban/",
         BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.alreadyBanned))
       .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
@@ -69,9 +71,9 @@
 
   @Test
   public void banCommit_Forbidden() throws Exception {
-    RestResponse r =
-        userSession.put("/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .put("/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(
+            "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
+        .assertForbidden();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
new file mode 100644
index 0000000..a094f93
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -0,0 +1,248 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.reviewdb.client.Branch;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckMergeabilityIT extends AbstractDaemonTest {
+
+  private Branch.NameKey branch;
+
+  @Before
+  public void setUp() throws Exception {
+    branch = new Branch.NameKey(project, "test");
+    gApi.projects()
+        .name(branch.getParentKey().get())
+        .branch(branch.get()).create(new BranchInput());
+  }
+
+  @Test
+  public void checkMergeableCommit() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.reset(initialHead);
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in b")
+        .add("b.txt", "b contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/test")).call();
+
+    assertMergeable("master", "test", "recursive");
+  }
+
+  @Test
+  public void checkUnMergeableCommit() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.reset(initialHead);
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a too")
+        .add("a.txt", "a contents too")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/test")).call();
+
+    assertUnMergeable("master", "test", "recursive", "a.txt");
+  }
+
+  @Test
+  public void checkOursMergeStrategy() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.reset(initialHead);
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a too")
+        .add("a.txt", "a contents too")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/test")).call();
+
+    assertMergeable("master", "test", "ours");
+  }
+
+  @Test
+  public void checkAlreadyMergedCommit() throws Exception {
+    ObjectId c0 = testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("second commit")
+        .add("b.txt", "b contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    assertCommitMerged("master", c0.getName(), "");
+  }
+
+  @Test
+  public void checkContentMergedCommit() throws Exception {
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    // create a change, and cherrypick into master
+    PushOneCommit.Result cId = createChange();
+    RevCommit commitId = cId.getCommit();
+    CherryPickInput cpi = new CherryPickInput();
+    cpi.destination = "master";
+    cpi.message = "cherry pick the commit";
+    ChangeApi orig = gApi.changes()
+        .id(cId.getChangeId());
+    ChangeApi cherry = orig.current().cherryPick(cpi);
+    cherry.current().review(ReviewInput.approve());
+    cherry.current().submit();
+
+    ObjectId remoteId = getRemoteHead();
+    assertThat(remoteId).isNotEqualTo(commitId);
+    assertContentMerged("master", commitId.getName(), "recursive");
+  }
+
+  @Test
+  public void checkInvalidSource() throws Exception {
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    assertBadRequest("master", "fdsafsdf", "recursive",
+        "Cannot resolve 'fdsafsdf' to a commit");
+  }
+
+  @Test
+  public void checkInvalidStrategy() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("first commit")
+        .add("a.txt", "a contents ")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/master")).call();
+
+    testRepo.reset(initialHead);
+    testRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change in a too")
+        .add("a.txt", "a contents too")
+        .create();
+    testRepo.git().push().setRemote("origin").setRefSpecs(
+        new RefSpec("HEAD:refs/heads/test")).call();
+
+    assertBadRequest("master", "test", "octopus",
+        "invalid merge strategy: octopus");
+  }
+
+  private void assertMergeable(String targetBranch, String source,
+      String strategy) throws Exception {
+    MergeableInfo
+        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+    assertThat(mergeableInfo.mergeable).isTrue();
+  }
+
+  private void assertUnMergeable(String targetBranch, String source,
+      String strategy, String... conflicts) throws Exception {
+    MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+    assertThat(mergeableInfo.mergeable).isFalse();
+    assertThat(mergeableInfo.conflicts).containsExactly((Object[]) conflicts);
+  }
+
+  private void assertCommitMerged(String targetBranch, String source,
+      String strategy) throws Exception {
+    MergeableInfo
+        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+    assertThat(mergeableInfo.mergeable).isTrue();
+    assertThat(mergeableInfo.commitMerged).isTrue();
+  }
+
+  private void assertContentMerged(String targetBranch, String source,
+      String strategy) throws Exception {
+    MergeableInfo
+        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+    assertThat(mergeableInfo.mergeable).isTrue();
+    assertThat(mergeableInfo.contentMerged).isTrue();
+  }
+
+  private void assertBadRequest(String targetBranch, String source,
+      String strategy, String errMsg) throws Exception {
+    String url = "/projects/" + project.get() + "/branches/" + targetBranch;
+    url += "/mergeable?source=" + source;
+    if (!Strings.isNullOrEmpty(strategy)) {
+      url += "&strategy=" + strategy;
+    }
+
+    RestResponse r = userRestSession.get(url);
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).isEqualTo(errMsg);
+  }
+
+  private MergeableInfo getMergeableInfo(String targetBranch, String source,
+      String strategy) throws Exception {
+    String url = "/projects/" + project.get() + "/branches/" + targetBranch;
+    url += "/mergeable?source=" + source;
+    if (!Strings.isNullOrEmpty(strategy)) {
+      url += "&strategy=" + strategy;
+    }
+
+    RestResponse r = userRestSession.get(url);
+    r.assertOK();
+    MergeableInfo result = newGson().fromJson(r.getReader(), MergeableInfo.class);
+    r.consume();
+    return result;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 62dd729..46f93b6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.eclipse.jgit.lib.Constants;
 import org.junit.Before;
@@ -84,9 +82,7 @@
   }
 
   private void blockCreateReference() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.CREATE, ANONYMOUS_USERS, "refs/*");
-    saveProjectConfig(project, cfg);
+    block(Permission.CREATE, ANONYMOUS_USERS, "refs/*");
   }
 
   private void grantOwner() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 1d659cc..d09eeec 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -40,9 +40,7 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
 
-import org.apache.http.HttpStatus;
 import org.apache.http.message.BasicHeader;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -50,7 +48,6 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.Test;
 
-import java.io.IOException;
 import java.util.Collections;
 import java.util.Set;
 
@@ -58,8 +55,8 @@
   @Test
   public void testCreateProjectHttp() throws Exception {
     String newProjectName = name("newProject");
-    RestResponse r = adminSession.put("/projects/" + newProjectName);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CREATED);
+    RestResponse r = adminRestSession.put("/projects/" + newProjectName);
+    r.assertCreated();
     ProjectInfo p = newGson().fromJson(r.getReader(), ProjectInfo.class);
     assertThat(p.name).isEqualTo(newProjectName);
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
@@ -71,32 +68,36 @@
   @Test
   public void testCreateProjectHttpWhenProjectAlreadyExists_Conflict()
       throws Exception {
-    RestResponse r = adminSession.put("/projects/" + allProjects.get());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    adminRestSession
+        .put("/projects/" + allProjects.get())
+        .assertConflict();
   }
 
   @Test
   public void testCreateProjectHttpWhenProjectAlreadyExists_PreconditionFailed()
       throws Exception {
-    RestResponse r = adminSession.putWithHeader("/projects/" + allProjects.get(),
-        new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_PRECONDITION_FAILED);
+    adminRestSession
+        .putWithHeader("/projects/" + allProjects.get(),
+            new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
+        .assertPreconditionFailed();
   }
 
   @Test
   @UseLocalDisk
   public void testCreateProjectHttpWithUnreasonableName_BadRequest()
       throws Exception {
-    RestResponse r = adminSession.put("/projects/" + Url.encode(name("invalid/../name")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .put("/projects/" + Url.encode(name("invalid/../name")))
+        .assertBadRequest();
   }
 
   @Test
   public void testCreateProjectHttpWithNameMismatch_BadRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("otherName");
-    RestResponse r = adminSession.put("/projects/" + name("someName"), in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .put("/projects/" + name("someName"), in)
+        .assertBadRequest();
   }
 
   @Test
@@ -104,8 +105,9 @@
       throws Exception {
     ProjectInput in = new ProjectInput();
     in.branches = Collections.singletonList(name("invalid ref name"));
-    RestResponse r = adminSession.put("/projects/" + name("newProject"), in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminRestSession
+        .put("/projects/" + name("newProject"), in)
+        .assertBadRequest();
   }
 
   @Test
@@ -299,22 +301,22 @@
   }
 
   private void assertHead(String projectName, String expectedRef)
-      throws RepositoryNotFoundException, IOException {
+      throws Exception {
     try (Repository repo =
         repoManager.openRepository(new Project.NameKey(projectName))) {
-      assertThat(repo.getRef(Constants.HEAD).getTarget().getName())
+      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName())
         .isEqualTo(expectedRef);
     }
   }
 
   private void assertEmptyCommit(String projectName, String... refs)
-      throws RepositoryNotFoundException, IOException {
+      throws Exception {
     Project.NameKey projectKey = new Project.NameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
         RevWalk rw = new RevWalk(repo);
         TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
       for (String ref : refs) {
-        RevCommit commit = rw.lookupCommit(repo.getRef(ref).getObjectId());
+        RevCommit commit = rw.lookupCommit(repo.exactRef(ref).getObjectId());
         rw.parseBody(commit);
         tw.addTree(commit.getTree());
         assertThat(tw.next()).isFalse();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index c9347cd..955e580 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -26,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -76,9 +74,7 @@
   }
 
   private void blockForcePush() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
-    saveProjectConfig(project, cfg);
+    block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
   }
 
   private void grantOwner() throws Exception {
@@ -92,7 +88,11 @@
   }
 
   private void assertDeleteSucceeds() throws Exception {
+    String branchRev = branch().get().revision;
     branch().delete();
+    eventRecorder.assertRefUpdatedEvents(project.get(), branch.get(),
+        null, branchRev,
+        branchRev, null);
     exception.expect(ResourceNotFoundException.class);
     branch().get();
   }
@@ -100,6 +100,5 @@
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
     branch().delete();
-    branch().get();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
new file mode 100644
index 0000000..af1383b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.RefNames;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.List;
+
+@NoHttpd
+public class DeleteBranchesIT extends AbstractDaemonTest {
+  private static final List<String> BRANCHES = ImmutableList.of(
+      "refs/heads/test-1", "refs/heads/test-2", "refs/heads/test-3");
+
+  @Before
+  public void setUp() throws Exception {
+    for (String name : BRANCHES) {
+      project().branch(name).create(new BranchInput());
+    }
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranches() throws Exception {
+    HashMap<String, RevCommit> initialRevisions = initialRevisions(BRANCHES);
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    project().deleteBranches(input);
+    assertBranchesDeleted();
+    assertRefUpdatedEvents(initialRevisions);
+  }
+
+  @Test
+  public void deleteBranchesForbidden() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    setApiUser(user);
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(BRANCHES));
+    }
+    setApiUser(admin);
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranchesNotFound() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList(BRANCHES);
+    branches.add("refs/heads/does-not-exist");
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(
+          ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted();
+  }
+
+  @Test
+  public void deleteBranchesNotFoundContinue() throws Exception {
+    // If it fails on the first branch in the input, it should still
+    // continue to process the remaining branches.
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
+    branches.addAll(BRANCHES);
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(
+          ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted();
+  }
+
+  private String errorMessageForBranches(List<String> branches) {
+    StringBuilder message = new StringBuilder();
+    for (String branch : branches) {
+      message.append("Cannot delete ")
+        .append(branch)
+        .append(": it doesn't exist or you do not have permission ")
+        .append("to delete it\n");
+    }
+    return message.toString();
+  }
+
+  private HashMap<String, RevCommit> initialRevisions(List<String> branches)
+      throws Exception {
+    HashMap<String, RevCommit> result = new HashMap<>();
+    for (String branch : branches) {
+      result.put(branch, getRemoteHead(project, branch));
+    }
+    return result;
+  }
+
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions)
+      throws Exception {
+    for (String branch : revisions.keySet()) {
+      RevCommit revision = revisions.get(branch);
+      eventRecorder.assertRefUpdatedEvents(project.get(), branch,
+          null, revision,
+          revision, null);
+    }
+  }
+
+  private ProjectApi project() throws Exception {
+    return gApi.projects().name(project.get());
+  }
+
+  private void assertBranches(List<String> branches) throws Exception {
+    List<String> expected = Lists.newArrayList(
+        "HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
+    expected.addAll(branches);
+    assertRefNames(expected, project().branches().get());
+  }
+
+  private void assertBranchesDeleted() throws Exception {
+    assertBranches(ImmutableList.<String>of());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
new file mode 100644
index 0000000..66d04df
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Branch;
+
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class FileBranchIT extends AbstractDaemonTest {
+
+  private Branch.NameKey branch;
+
+  @Before
+  public void setUp() throws Exception {
+    branch = new Branch.NameKey(project, "master");
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    revision(change).submit();
+  }
+
+  @Test
+  public void getFileContent() throws Exception {
+    BinaryResult content = branch().file(PushOneCommit.FILE_NAME);
+    assertThat(content.asString()).isEqualTo(PushOneCommit.FILE_CONTENT);
+  }
+
+  @Test(expected = ResourceNotFoundException.class)
+  public void getNonExistingFile() throws Exception {
+    branch().file("does-not-exist");
+  }
+
+  private BranchApi branch() throws Exception {
+    return gApi.projects()
+        .name(branch.getParentKey().get())
+        .branch(branch.get());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
index d680541..8522a4d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.RestResponse;
@@ -23,12 +21,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-
 public class GarbageCollectionIT extends AbstractDaemonTest {
 
   @Inject
@@ -43,29 +38,27 @@
 
   @Test
   public void testGcNonExistingProject_NotFound() throws Exception {
-    assertThat(POST("/projects/non-existing/gc")).isEqualTo(
-        HttpStatus.SC_NOT_FOUND);
+    POST("/projects/non-existing/gc").assertNotFound();
   }
 
   @Test
   public void testGcNotAllowed_Forbidden() throws Exception {
-    assertThat(
-        userSession.post("/projects/" + allProjects.get() + "/gc")
-            .getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userRestSession
+        .post("/projects/" + allProjects.get() + "/gc")
+        .assertForbidden();
   }
 
   @Test
   @UseLocalDisk
   public void testGcOneProject() throws Exception {
-    assertThat(POST("/projects/" + allProjects.get() + "/gc")).isEqualTo(
-        HttpStatus.SC_OK);
+    POST("/projects/" + allProjects.get() + "/gc").assertOK();
     gcAssert.assertHasPackFile(allProjects);
     gcAssert.assertHasNoPackFile(project, project2);
   }
 
-  private int POST(String endPoint) throws IOException {
-    RestResponse r = adminSession.post(endPoint);
+  private RestResponse POST(String endPoint) throws Exception {
+    RestResponse r = adminRestSession.post(endPoint);
     r.consume();
-    return r.getStatusCode();
+    return r;
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index ea3a5db..f87b921 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -71,10 +70,8 @@
 
   private void assertChildNotFound(Project.NameKey parent, String child)
       throws Exception {
-    try {
-      gApi.projects().name(parent.get()).child(child);
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).contains(child);
-    }
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(child);
+    gApi.projects().name(parent.get()).child(child).get();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 7044ad0..307d512 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.git.ProjectConfig;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -40,7 +39,7 @@
   @Before
   public void setUp() throws Exception {
     repo = GitUtil.newTestRepository(repoManager.openRepository(project));
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
   }
 
   @After
@@ -97,8 +96,8 @@
         .to("refs/for/master");
     r.assertOkStatus();
 
-    CommitInfo info = getCommit(r.getCommitId());
-    assertThat(info.commit).isEqualTo(r.getCommitId().name());
+    CommitInfo info = getCommit(r.getCommit());
+    assertThat(info.commit).isEqualTo(r.getCommit().name());
     assertThat(info.subject).isEqualTo("test commit");
     assertThat(info.message).isEqualTo(
         "test commit\n\nChange-Id: " + r.getChangeId() + "\n");
@@ -120,7 +119,7 @@
     PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), testRepo)
         .to("refs/for/master");
     r.assertOkStatus();
-    assertNotFound(r.getCommitId());
+    assertNotFound(r.getCommit());
   }
 
   private void unblockRead() throws Exception {
@@ -130,15 +129,15 @@
   }
 
   private void assertNotFound(ObjectId id) throws Exception {
-    RestResponse r = userSession.get(
-        "/projects/" + project.get() + "/commits/" + id.name());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userRestSession
+        .get("/projects/" + project.get() + "/commits/" + id.name())
+        .assertNotFound();
   }
 
   private CommitInfo getCommit(ObjectId id) throws Exception {
-    RestResponse r = userSession.get(
+    RestResponse r = userRestSession.get(
         "/projects/" + project.get() + "/commits/" + id.name());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     CommitInfo result = newGson().fromJson(r.getReader(), CommitInfo.class);
     r.consume();
     return result;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 0799d48..7c98188 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.RefNames;
 
 import org.junit.Test;
 
@@ -37,7 +38,7 @@
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).branches().get();
@@ -48,7 +49,7 @@
   public void listBranchesOfEmptyProject() throws Exception {
     assertBranches(ImmutableList.of(
           branch("HEAD", null, false),
-          branch("refs/meta/config",  null, false)),
+          branch(RefNames.REFS_CONFIG,  null, false)),
         list().get());
   }
 
@@ -58,7 +59,7 @@
     String dev = pushTo("refs/heads/dev").getCommit().name();
     assertBranches(ImmutableList.of(
           branch("HEAD", "master", false),
-          branch("refs/meta/config",  null, false),
+          branch(RefNames.REFS_CONFIG,  null, false),
           branch("refs/heads/dev", dev, true),
           branch("refs/heads/master", master, false)),
         list().get());
@@ -66,7 +67,7 @@
 
   @Test
   public void listBranchesSomeHidden() throws Exception {
-    blockRead(project, "refs/heads/dev");
+    blockRead("refs/heads/dev");
     String master = pushTo("refs/heads/master").getCommit().name();
     pushTo("refs/heads/dev");
     setApiUser(user);
@@ -79,7 +80,7 @@
 
   @Test
   public void listBranchesHeadHidden() throws Exception {
-    blockRead(project, "refs/heads/master");
+    blockRead("refs/heads/master");
     pushTo("refs/heads/master");
     String dev = pushTo("refs/heads/dev").getCommit().name();
     setApiUser(user);
@@ -98,7 +99,7 @@
     // Using only limit.
     assertRefNames(ImmutableList.of(
           "HEAD",
-          "refs/meta/config",
+          RefNames.REFS_CONFIG,
           "refs/heads/master",
           "refs/heads/someBranch1"),
         list().withLimit(4).get());
@@ -106,7 +107,7 @@
     // Limit higher than total number of branches.
     assertRefNames(ImmutableList.of(
           "HEAD",
-          "refs/meta/config",
+          RefNames.REFS_CONFIG,
           "refs/heads/master",
           "refs/heads/someBranch1",
           "refs/heads/someBranch2",
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 80ad493..78e0ba2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -29,11 +28,9 @@
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
-    try {
-      gApi.projects().name(name("non-existing")).child("children");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).contains("non-existing");
-    }
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("non-existing");
+    gApi.projects().name(name("non-existing")).child("children");
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 1e51571..e86bb29 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
@@ -197,9 +198,10 @@
         .containsExactly(allProjects, allUsers, project).inOrder();
   }
 
-  private static void assertBadRequest(ListRequest req) throws Exception {
+  private void assertBadRequest(ListRequest req) throws Exception {
     try {
       req.get();
+      fail("Expected BadRequestException");
     } catch (BadRequestException expected) {
       // Expected.
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
index da8ebed..b106e99 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.project.SetParent;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class SetParentIT extends AbstractDaemonTest {
@@ -30,9 +29,9 @@
   public void setParent_Forbidden() throws Exception {
     String parent = createProject("parent", null, true).get();
     RestResponse r =
-        userSession.put("/projects/" + project.get() + "/parent",
+        userRestSession.put("/projects/" + project.get() + "/parent",
             newParentInput(parent));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    r.assertForbidden();
     r.consume();
   }
 
@@ -40,13 +39,13 @@
   public void setParent() throws Exception {
     String parent = createProject("parent", null, true).get();
     RestResponse r =
-        adminSession.put("/projects/" + project.get() + "/parent",
+        adminRestSession.put("/projects/" + project.get() + "/parent",
             newParentInput(parent));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
-    r = adminSession.get("/projects/" + project.get() + "/parent");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r = adminRestSession.get("/projects/" + project.get() + "/parent");
+    r.assertOK();
     String newParent =
         newGson().fromJson(r.getReader(), String.class);
     assertThat(newParent).isEqualTo(parent);
@@ -54,13 +53,13 @@
 
     // When the parent name is not explicitly set, it should be
     // set to "All-Projects".
-    r = adminSession.put("/projects/" + project.get() + "/parent",
+    r = adminRestSession.put("/projects/" + project.get() + "/parent",
           newParentInput(null));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
-    r = adminSession.get("/projects/" + project.get() + "/parent");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r = adminRestSession.get("/projects/" + project.get() + "/parent");
+    r.assertOK();
     newParent = newGson().fromJson(r.getReader(), String.class);
     assertThat(newParent).isEqualTo(AllProjectsNameProvider.DEFAULT);
     r.consume();
@@ -69,39 +68,39 @@
   @Test
   public void setParentForAllProjects_Conflict() throws Exception {
     RestResponse r =
-        adminSession.put("/projects/" + allProjects.get() + "/parent",
+        adminRestSession.put("/projects/" + allProjects.get() + "/parent",
             newParentInput(project.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
   }
 
   @Test
   public void setInvalidParent_Conflict() throws Exception {
     RestResponse r =
-        adminSession.put("/projects/" + project.get() + "/parent",
+        adminRestSession.put("/projects/" + project.get() + "/parent",
             newParentInput(project.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
 
     Project.NameKey child = createProject("child", project, true);
-    r = adminSession.put("/projects/" + project.get() + "/parent",
+    r = adminRestSession.put("/projects/" + project.get() + "/parent",
            newParentInput(child.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
 
     String grandchild = createProject("grandchild", child, true).get();
-    r = adminSession.put("/projects/" + project.get() + "/parent",
+    r = adminRestSession.put("/projects/" + project.get() + "/parent",
            newParentInput(grandchild));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
   }
 
   @Test
   public void setNonExistingParent_UnprocessibleEntity() throws Exception {
     RestResponse r =
-        adminSession.put("/projects/" + project.get() + "/parent",
+        adminRestSession.put("/projects/" + project.get() + "/parent",
             newParentInput("non-existing"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    r.assertUnprocessableEntity();
     r.consume();
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
index b22d26b9..33aa726 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -15,170 +15,103 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 
-import org.apache.http.HttpStatus;
-import org.eclipse.jgit.api.PushCommand;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.junit.Test;
 
 import java.util.List;
 
+@NoHttpd
 public class TagsIT extends AbstractDaemonTest {
   private static final List<String> testTags = ImmutableList.of(
       "tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
 
-  @Test
-  public void listTagsOfNonExistingProject() throws Exception {
-    assertThat(adminSession.get("/projects/non-existing/tags").getStatusCode())
-        .isEqualTo(HttpStatus.SC_NOT_FOUND);
-  }
+  private static final String SIGNED_ANNOTATION = "annotation\n"
+      + "-----BEGIN PGP SIGNATURE-----\n"
+      + "Version: GnuPG v1\n"
+      + "\n"
+      + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+      + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+      + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+      + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+      + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+      + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+      + "=XFeC\n"
+      + "-----END PGP SIGNATURE-----";
 
   @Test
-  public void listTagsOfNonExistingProjectWithApi() throws Exception {
+  public void listTagsOfNonExistingProject() throws Exception {
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name("does-not-exist").tags().get();
   }
 
   @Test
-  public void getTagOfNonExistingProjectWithApi() throws Exception {
+  public void getTagOfNonExistingProject() throws Exception {
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name("does-not-exist").tag("tag").get();
   }
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead(project, "refs/*");
-    assertThat(
-        userSession.get("/projects/" + project.get() + "/tags").getStatusCode())
-        .isEqualTo(HttpStatus.SC_NOT_FOUND);
-  }
-
-  @Test
-  public void listTagsOfNonVisibleProjectWithApi() throws Exception {
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).tags().get();
   }
 
   @Test
-  public void getTagOfNonVisibleProjectWithApi() throws Exception {
-    blockRead(project, "refs/*");
+  public void getTagOfNonVisibleProject() throws Exception {
+    blockRead("refs/*");
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).tag("tag").get();
   }
 
   @Test
   public void listTags() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
-
-    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
-    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push1.setTag(tag1);
-    PushOneCommit.Result r1 = push1.to("refs/for/master%submit");
-    r1.assertOkStatus();
-
-    PushOneCommit.AnnotatedTag tag2 =
-        new PushOneCommit.AnnotatedTag("v2.0", "annotation", admin.getIdent());
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push2.setTag(tag2);
-    PushOneCommit.Result r2 = push2.to("refs/for/master%submit");
-    r2.assertOkStatus();
-
-    String tag3Ref = Constants.R_TAGS + "vLatest";
-    PushCommand pushCmd = testRepo.git().push();
-    pushCmd.setRefSpecs(new RefSpec(tag2.name + ":" + tag3Ref));
-    Iterable<PushResult> r = pushCmd.call();
-    assertThat(Iterables.getOnlyElement(r).getRemoteUpdate(tag3Ref).getStatus())
-        .isEqualTo(Status.OK);
-
-    List<TagInfo> result = getTags().get();
-    assertThat(result).hasSize(3);
-
-    TagInfo t = result.get(0);
-    assertThat(t.ref).isEqualTo(Constants.R_TAGS + tag1.name);
-    assertThat(t.revision).isEqualTo(r1.getCommitId().getName());
-
-    t = result.get(1);
-    assertThat(t.ref).isEqualTo(Constants.R_TAGS + tag2.name);
-    assertThat(t.object).isEqualTo(r2.getCommitId().getName());
-    assertThat(t.message).isEqualTo(tag2.message);
-    assertThat(t.tagger.name).isEqualTo(tag2.tagger.getName());
-    assertThat(t.tagger.email).isEqualTo(tag2.tagger.getEmailAddress());
-
-    t = result.get(2);
-    assertThat(t.ref).isEqualTo(tag3Ref);
-    assertThat(t.object).isEqualTo(r2.getCommitId().getName());
-    assertThat(t.message).isEqualTo(tag2.message);
-    assertThat(t.tagger.name).isEqualTo(tag2.tagger.getName());
-    assertThat(t.tagger.email).isEqualTo(tag2.tagger.getEmailAddress());
-  }
-
-  private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
-      throws Exception {
-    assertThat(actual).hasSize(expected.size());
-    for (int i = 0; i < expected.size(); i ++) {
-      assertThat(actual.get(i).ref).isEqualTo("refs/tags/" + expected.get(i));
-    }
-  }
-
-  @Test
-  public void listTagsWithoutOptions() throws Exception {
     createTags();
+
+    // No options
     List<TagInfo> result = getTags().get();
     assertTagList(FluentIterable.from(testTags), result);
-  }
 
-  @Test
-  public void listTagsWithStartOption() throws Exception {
-    createTags();
-    List<TagInfo> result = getTags().withStart(1).get();
+    // With start option
+    result = getTags().withStart(1).get();
     assertTagList(FluentIterable.from(testTags).skip(1), result);
-  }
 
-  @Test
-  public void listTagsWithLimitOption() throws Exception {
-    createTags();
+    // With limit option
     int limit = testTags.size() - 1;
-    List<TagInfo> result = getTags().withLimit(limit).get();
+    result = getTags().withLimit(limit).get();
     assertTagList(FluentIterable.from(testTags).limit(limit), result);
-  }
 
-  @Test
-  public void listTagsWithLimitAndStartOption() throws Exception {
-    createTags();
-    int limit = testTags.size() - 3;
-    List<TagInfo> result = getTags().withStart(1).withLimit(limit).get();
+    // With both start and limit
+    limit = testTags.size() - 3;
+    result = getTags().withStart(1).withLimit(limit).get();
     assertTagList(FluentIterable.from(testTags).skip(1).limit(limit), result);
-  }
 
-  @Test
-  public void listTagsWithRegexFilter() throws Exception {
-    createTags();
-    List<TagInfo> result = getTags().withRegex("^tag-[C|D]$").get();
+    // With regular expression filter
+    result = getTags().withRegex("^tag-[C|D]$").get();
     assertTagList(
         FluentIterable.from(ImmutableList.of("tag-C", "tag-D")), result);
-  }
 
-  @Test
-  public void listTagsWithSubstringFilter() throws Exception {
-    createTags();
-    List<TagInfo> result = getTags().withSubstring("tag-").get();
+    // With substring filter
+    result = getTags().withSubstring("tag-").get();
     assertTagList(FluentIterable.from(testTags), result);
     result = getTags().withSubstring("ag-B").get();
     assertTagList(FluentIterable.from(ImmutableList.of("tag-B")), result);
@@ -186,73 +119,234 @@
 
   @Test
   public void listTagsOfNonVisibleBranch() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/hidden");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grantTagPermissions();
 
-    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
     PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push1.setTag(tag1);
-    PushOneCommit.Result r1 = push1.to("refs/for/master%submit");
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
     r1.assertOkStatus();
+    TagInput tag1 = new TagInput();
+    tag1.ref = "v1.0";
+    tag1.revision = r1.getCommit().getName();
+    TagInfo result = tag(tag1.ref).create(tag1).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(result.revision).isEqualTo(tag1.revision);
 
     pushTo("refs/heads/hidden");
-    PushOneCommit.Tag tag2 = new PushOneCommit.Tag("v2.0");
     PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push2.setTag(tag2);
-    PushOneCommit.Result r2 = push2.to("refs/for/hidden%submit");
+    PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
 
-    List<TagInfo> result = getTags().get();
-    assertThat(result).hasSize(2);
-    assertThat(result.get(0).ref).isEqualTo("refs/tags/" + tag1.name);
-    assertThat(result.get(0).revision).isEqualTo(r1.getCommitId().getName());
-    assertThat(result.get(1).ref).isEqualTo("refs/tags/" + tag2.name);
-    assertThat(result.get(1).revision).isEqualTo(r2.getCommitId().getName());
+    TagInput tag2 = new TagInput();
+    tag2.ref = "v2.0";
+    tag2.revision = r2.getCommit().getName();
+    result = tag(tag2.ref).create(tag2).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(result.revision).isEqualTo(tag2.revision);
 
-    blockRead(project, "refs/heads/hidden");
-    result = getTags().get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).ref).isEqualTo("refs/tags/" + tag1.name);
-    assertThat(result.get(0).revision).isEqualTo(r1.getCommitId().getName());
+    List<TagInfo> tags = getTags().get();
+    assertThat(tags).hasSize(2);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
+    assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
+
+    blockRead("refs/heads/hidden");
+    tags = getTags().get();
+    assertThat(tags).hasSize(1);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
   }
 
   @Test
-  public void getTag() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
+  public void lightweightTag() throws Exception {
+    grantTagPermissions();
 
-    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
-    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push1.setTag(tag1);
-    PushOneCommit.Result r1 = push1.to("refs/for/master%submit");
-    r1.assertOkStatus();
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
 
-    TagInfo tagInfo = getTag(tag1.name);
-    assertThat(tagInfo.ref).isEqualTo("refs/tags/" + tag1.name);
-    assertThat(tagInfo.revision).isEqualTo(r1.getCommitId().getName());
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+
+    input.ref = "refs/tags/v2.0";
+    result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
+        null, result.revision);
+  }
+
+  @Test
+  public void annotatedTag() throws Exception {
+    grantTagPermissions();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+    input.message = "annotation message";
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.object).isEqualTo(input.revision);
+    assertThat(result.message).isEqualTo(input.message);
+    assertThat(result.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result.tagger.email).isEqualTo(admin.email);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
+        null, result.revision);
+
+    // A second tag pushed on the same ref should have the same ref
+    TagInput input2 = new TagInput();
+    input2.ref = "refs/tags/v2.0";
+    input2.revision = input.revision;
+    input2.message = "second annotation message";
+    TagInfo result2 = tag(input2.ref).create(input2).get();
+    assertThat(result2.ref).isEqualTo(input2.ref);
+    assertThat(result2.object).isEqualTo(input2.revision);
+    assertThat(result2.message).isEqualTo(input2.message);
+    assertThat(result2.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result2.tagger.email).isEqualTo(admin.email);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref,
+        null, result2.revision);
+  }
+
+  @Test
+  public void createExistingTag() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + "test");
+
+    input.ref = "refs/tags/test";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createTagNotAllowed() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+    exception.expect(AuthException.class);
+    exception.expectMessage("Cannot create tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createAnnotatedTagNotAllowed() throws Exception {
+    block(Permission.PUSH_TAG, REGISTERED_USERS, R_TAGS + "*");
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = "annotation";
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "Cannot create annotated tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createSignedTagNotSupported() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = SIGNED_ANNOTATION;
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void mismatchedInput() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("ref must match URL");
+    tag("TEST").create(input);
+  }
+
+  @Test
+  public void invalidTagName() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "refs/heads/test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidTagNameOnlySlashes() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "//";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"refs/tags/\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "abcdefg";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid base revision");
+    tag(input.ref).create(input);
+  }
+
+  private void assertTagList(FluentIterable<String> expected,
+      List<TagInfo> actual) throws Exception {
+    assertThat(actual).hasSize(expected.size());
+    for (int i = 0; i < expected.size(); i ++) {
+      assertThat(actual.get(i).ref).isEqualTo(R_TAGS + expected.get(i));
+    }
   }
 
   private void createTags() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grantTagPermissions();
+
+    String revision = pushTo("refs/heads/master").getCommit().name();
+    TagInput input = new TagInput();
+    input.revision = revision;
+
     for (String tagname : testTags) {
-      PushOneCommit.Tag tag = new PushOneCommit.Tag(tagname);
-      PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-      push.setTag(tag);
-      PushOneCommit.Result result = push.to("refs/for/master%submit");
-      result.assertOkStatus();
+      TagInfo result = tag(tagname).create(input).get();
+      assertThat(result.revision).isEqualTo(input.revision);
+      assertThat(result.ref).isEqualTo(R_TAGS + tagname);
     }
   }
 
+  private void grantTagPermissions() throws Exception {
+    grant(Permission.CREATE, project, R_TAGS + "*");
+    grant(Permission.PUSH_TAG, project, R_TAGS + "*");
+    grant(Permission.PUSH_SIGNED_TAG, project, R_TAGS + "*");
+  }
+
   private ListRefsRequest<TagInfo> getTags() throws Exception {
     return gApi.projects().name(project.get()).tags();
   }
 
-  private TagInfo getTag(String ref) throws Exception {
-    return gApi.projects().name(project.get()).tag(ref).get();
+  private TagApi tag(String tagname) throws Exception {
+    return gApi.projects().name(project.get()).tag(tagname);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK
index 94e69da..5384447 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK
@@ -1,7 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
 acceptance_tests(
-  group = 'server-change',
+  group = 'server_change',
   srcs = glob(['*IT.java']),
   labels = ['server'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
new file mode 100644
index 0000000..a5e6d36
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'server_change',
+  srcs = glob(['*IT.java']),
+  labels = ['server'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b784f05..d9f1a5c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -33,12 +33,12 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -48,17 +48,18 @@
 import org.junit.Test;
 
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 @NoHttpd
 public class CommentsIT extends AbstractDaemonTest {
-  @Inject
-  private Provider<ChangesCollection> changes;
 
   @Inject
-  private Provider<Revisions> revisions;
+  private Provider<ChangesCollection> changes;
 
   @Inject
   private Provider<PostReview> postReview;
@@ -74,17 +75,49 @@
   }
 
   @Test
+  public void getNonExistingComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    exception.expect(ResourceNotFoundException.class);
+    getPublishedComment(changeId, revId, "non-existing");
+  }
+
+  @Test
   public void createDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+    }
+  }
+
+  @Test
+  public void createDraftOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      addDraft(changeId, revId, c1);
+      addDraft(changeId, revId, c2);
+      addDraft(changeId, revId, c3);
+      addDraft(changeId, revId, c4);
+      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+      assertThat(result).hasSize(1);
+      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
+          .containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -106,41 +139,121 @@
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(
+          getPublishedComment(changeId, revId, actual.id)));
     }
   }
 
   @Test
+  public void postCommentOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      final String file = "/COMMIT_MSG";
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1");
+      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1");
+      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file)))
+          .containsExactly(c1, c2, c3, c4);
+    }
+  }
+
+  @Test
+  public void listComments() throws Exception {
+    String file = "file";
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        "first subject", file, "contents");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    assertThat(getPublishedComments(changeId, revId)).isEmpty();
+
+    List<CommentInput> expectedComments = new ArrayList<>();
+    for (Integer line : lines) {
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line);
+      expectedComments.add(comment);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+    }
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    List<CommentInfo> actualComments = result.get(file);
+    assertThat(Lists.transform(actualComments, infoToInput(file)))
+        .containsExactlyElementsIn(expectedComments);
+  }
+
+  @Test
   public void putDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
       String uuid = actual.id;
       comment.message = "updated comment 1";
       updateDraft(changeId, revId, comment, uuid);
       result = getDraftComments(changeId, revId);
       actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+      // Posting a draft comment doesn't cause lastUpdatedOn to change.
+      assertThat(r.getChange().change().getLastUpdatedOn())
+          .isEqualTo(origLastUpdated);
     }
   }
 
   @Test
+  public void listDrafts() throws Exception {
+    String file = "file";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    assertThat(getDraftComments(changeId, revId)).isEmpty();
+
+    List<DraftInput> expectedDrafts = new ArrayList<>();
+    for (Integer line : lines) {
+      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
+      expectedDrafts.add(comment);
+      addDraft(changeId, revId, comment);
+    }
+
+    Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    List<CommentInfo> actualComments = result.get(file);
+    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+        .containsExactlyElementsIn(expectedDrafts);
+  }
+
+  @Test
   public void getDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
+      String path = "file1";
       DraftInput comment = newDraft(
-          "file1", Side.REVISION, line, "comment 1");
+          path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
     }
   }
 
@@ -148,6 +261,7 @@
   public void deleteDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
@@ -155,6 +269,10 @@
       deleteDraft(changeId, revId, returned.id);
       Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
       assertThat(drafts).isEmpty();
+
+      // Deleting a draft comment doesn't cause lastUpdatedOn to change.
+      assertThat(r.getChange().change().getLastUpdatedOn())
+          .isEqualTo(origLastUpdated);
     }
   }
 
@@ -169,6 +287,8 @@
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
+      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+
       ReviewInput input = new ReviewInput();
       CommentInput comment = newComment(file, Side.REVISION, line, "comment 1");
       comment.updated = timestamp;
@@ -178,13 +298,20 @@
           changes.get().parse(TopLevelResource.INSTANCE,
               IdString.fromDecoded(changeId));
       RevisionResource revRsrc =
-          revisions.get().parse(changeRsrc, IdString.fromDecoded(revId));
+          revisions.parse(changeRsrc, IdString.fromDecoded(revId));
       postReview.get().apply(revRsrc, input, timestamp);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
-      assertThat(comment.updated).isEqualTo(timestamp);
+      CommentInput ci = infoToInput(file).apply(actual);
+      ci.updated = comment.updated;
+      assertThat(comment).isEqualTo(ci);
+      assertThat(actual.updated)
+          .isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
+
+      // Updating historic comments doesn't cause lastUpdatedOn to regress.
+      assertThat(r.getChange().change().getLastUpdatedOn())
+          .isEqualTo(origLastUpdated);
     }
   }
 
@@ -289,6 +416,20 @@
   }
 
   @Test
+  public void listChangeWithDrafts() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      DraftInput comment = newDraft(
+          "file1", Side.REVISION, line, "comment 1");
+      addDraft(changeId, revId, comment);
+      assertThat(gApi.changes().query(
+          "change:" + changeId + " has:draft").get()).hasSize(1);
+    }
+  }
+
+  @Test
   public void publishCommentsAllRevisions() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -299,10 +440,16 @@
 
     addDraft(r1.getChangeId(), r1.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+    addDraft(r1.getChangeId(), r1.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
     addDraft(r2.getChangeId(), r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
     addDraft(r2.getChangeId(), r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
@@ -334,8 +481,11 @@
         .comments();
     assertThat(ps1Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps1List = ps1Map.get(FILE_NAME);
-    assertThat(ps1List).hasSize(1);
-    assertThat(ps1List.get(0).message).isEqualTo("nit: trailing whitespace");
+    assertThat(ps1List).hasSize(2);
+    assertThat(ps1List.get(0).message).isEqualTo("what happened to this?");
+    assertThat(ps1List.get(0).side).isEqualTo(Side.PARENT);
+    assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
+    assertThat(ps1List.get(1).side).isNull();
 
     assertThat(gApi.changes()
           .id(r2.getChangeId())
@@ -348,26 +498,31 @@
         .comments();
     assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
-    assertThat(ps2List).hasSize(2);
-    assertThat(ps2List.get(0).message).isEqualTo("join lines");
-    assertThat(ps2List.get(1).message).isEqualTo("typo: content");
+    assertThat(ps2List).hasSize(4);
+    assertThat(ps2List.get(0).message).isEqualTo("comment 1 on base");
+    assertThat(ps2List.get(1).message).isEqualTo("comment 2 on base");
+    assertThat(ps2List.get(2).message).isEqualTo("join lines");
+    assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
     ImmutableList<Message> messages =
         email.getMessages(r2.getChangeId(), "comment");
     assertThat(messages).hasSize(1);
     String url = canonicalWebUrl.get();
     int c = r1.getChange().getId().get();
-    assertThat(messages.get(0).body()).contains(
-        "\n"
-        + "Patch Set 2:\n"
+    assertThat(extractComments(messages.get(0).body())).isEqualTo(
+        "Patch Set 2:\n"
         + "\n"
-        + "(3 comments)\n"
+        + "(6 comments)\n"
         + "\n"
         + "comments\n"
         + "\n"
         + url + "#/c/" + c + "/1/a.txt\n"
         + "File a.txt:\n"
         + "\n"
+        + "PS1, Line 2: \n"
+        + "what happened to this?\n"
+        + "\n"
+        + "\n"
         + "PS1, Line 1: ew\n"
         + "nit: trailing whitespace\n"
         + "\n"
@@ -375,15 +530,65 @@
         + url + "#/c/" + c + "/2/a.txt\n"
         + "File a.txt:\n"
         + "\n"
+        + "PS2, Line 1: \n"
+        + "comment 1 on base\n"
+        + "\n"
+        + "\n"
+        + "PS2, Line 2: \n"
+        + "comment 2 on base\n"
+        + "\n"
+        + "\n"
         + "PS2, Line 1: ew\n"
         + "join lines\n"
         + "\n"
         + "\n"
         + "PS2, Line 2: nten\n"
         + "typo: content\n"
-        + "\n"
-        + "\n"
-        + "-- \n");
+        + "\n");
+  }
+
+  @Test
+  public void commentTags() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CommentInput pub = new CommentInput();
+    pub.line = 1;
+    pub.message = "published comment";
+    pub.path = FILE_NAME;
+    ReviewInput rin = newInput(pub);
+    rin.tag = "tag1";
+    gApi.changes().id(r.getChangeId()).current().review(rin);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r.getChangeId()).current().commentsAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).tag).isEqualTo("tag1");
+
+    DraftInput draft = new DraftInput();
+    draft.line = 2;
+    draft.message = "draft comment";
+    draft.path = FILE_NAME;
+    draft.tag = "tag2";
+    addDraft(r.getChangeId(), r.getCommit().name(), draft);
+
+    List<CommentInfo> drafts =
+        gApi.changes().id(r.getChangeId()).current().draftsAsList();
+    assertThat(drafts).hasSize(1);
+    assertThat(drafts.get(0).tag).isEqualTo("tag2");
+  }
+
+  private static String extractComments(String msg) {
+    // Extract lines between start "....." and end "-- ".
+    Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
+    Matcher m = p.matcher(msg);
+    return m.matches() ? m.group(1) : msg;
+  }
+
+  private ReviewInput newInput(CommentInput c) {
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    return in;
   }
 
   private void addComment(PushOneCommit.Result r, String message)
@@ -397,9 +602,7 @@
     c.line = 1;
     c.message = message;
     c.path = FILE_NAME;
-    ReviewInput in = new ReviewInput();
-    in.comments = ImmutableMap.<String, List<CommentInput>> of(
-        FILE_NAME, ImmutableList.of(c));
+    ReviewInput in = newInput(c);
     in.omitDuplicateComments = omitDuplicateComments;
     gApi.changes()
         .id(r.getChangeId())
@@ -422,6 +625,11 @@
     gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
   }
 
+  private CommentInfo getPublishedComment(String changeId, String revId,
+      String uuid) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).comment(uuid).get();
+  }
+
   private Map<String, List<CommentInfo>> getPublishedComments(String changeId,
       String revId) throws Exception {
     return gApi.changes().id(changeId).revision(revId).comments();
@@ -437,45 +645,35 @@
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static void assertCommentInfo(Comment expected, CommentInfo actual) {
-    assertThat(actual.line).isEqualTo(expected.line);
-    assertThat(actual.message).isEqualTo(expected.message);
-    assertThat(actual.inReplyTo).isEqualTo(expected.inReplyTo);
-    assertCommentRange(expected.range, actual.range);
-    if (actual.side == null) {
-      assertThat(Side.REVISION).isEqualTo(expected.side);
-    }
-  }
-
-  private static void assertCommentRange(Comment.Range expected,
-      Comment.Range actual) {
-    if (expected == null) {
-      assertThat(actual).isNull();
-    } else {
-      assertThat(actual).isNotNull();
-      assertThat(actual.startLine).isEqualTo(expected.startLine);
-      assertThat(actual.startCharacter).isEqualTo(expected.startCharacter);
-      assertThat(actual.endLine).isEqualTo(expected.endLine);
-      assertThat(actual.endCharacter).isEqualTo(expected.endCharacter);
-    }
-  }
-
   private static CommentInput newComment(String path, Side side, int line,
       String message) {
     CommentInput c = new CommentInput();
-    return populate(c, path, side, line, message);
+    return populate(c, path, side, null, line, message);
+  }
+
+  private static CommentInput newCommentOnParent(String path, int parent,
+      int line, String message) {
+    CommentInput c = new CommentInput();
+    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message);
   }
 
   private DraftInput newDraft(String path, Side side, int line,
       String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, side, line, message);
+    return populate(d, path, side, null, line, message);
+  }
+
+  private DraftInput newDraftOnParent(String path, int parent, int line,
+      String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message);
   }
 
   private static <C extends Comment> C populate(C c, String path, Side side,
-      int line, String message) {
+      Integer parent, int line, String message) {
     c.path = path;
     c.side = side;
+    c.parent = parent;
     c.line = line != 0 ? line : null;
     c.message = message;
     if (line != 0) {
@@ -488,4 +686,38 @@
     }
     return c;
   }
+
+  private static Function<CommentInfo, CommentInput> infoToInput(
+      final String path) {
+    return new Function<CommentInfo, CommentInput>() {
+      @Override
+      public CommentInput apply(CommentInfo info) {
+        CommentInput ci = new CommentInput();
+        ci.path = path;
+        copy(info, ci);
+        return ci;
+      }
+    };
+  }
+
+  private static Function<CommentInfo, DraftInput> infoToDraft(
+      final String path) {
+    return new Function<CommentInfo, DraftInput>() {
+      @Override
+      public DraftInput apply(CommentInfo info) {
+        DraftInput di = new DraftInput();
+        di.path = path;
+        copy(info, di);
+        return di;
+      }
+    };
+  }
+
+  private static void copy(Comment from, Comment to) {
+    to.side = from.side == null ? Side.REVISION : from.side;
+    to.parent = from.parent;
+    to.line = from.line;
+    to.message = from.message;
+    to.range = from.range;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 27f8fc8..37e551f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -14,43 +14,94 @@
 
 package com.google.gerrit.acceptance.server.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testutil.TestChanges.newChange;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
 import static com.google.gerrit.testutil.TestChanges.newPatchSet;
 import static java.util.Collections.singleton;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 @NoHttpd
 public class ConsistencyCheckerIT extends AbstractDaemonTest {
   @Inject
+  private ChangeControl.GenericFactory changeControlFactory;
+
+  @Inject
   private Provider<ConsistencyChecker> checkerProvider;
 
+  @Inject
+  private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  private BatchUpdate.Factory updateFactory;
+
+  @Inject
+  private ChangeInserter.Factory changeInserterFactory;
+
+  @Inject
+  private PatchSetInserter.Factory patchSetInserterFactory;
+
+  @Inject
+  private ChangeNoteUtil noteUtil;
+
+  @Inject
+  @AnonymousCowardName
+  private String anonymousCowardName;
+
+  @Inject
+  private Sequences sequences;
+
   private RevCommit tip;
   private Account.Id adminId;
   private ConsistencyChecker checker;
@@ -61,69 +112,63 @@
     testRepo = new TestRepository<>(
         (InMemoryRepository) repoManager.openRepository(project));
     tip = testRepo.getRevWalk().parseCommit(
-        testRepo.getRepository().getRef("HEAD").getObjectId());
+        testRepo.getRepository().exactRef("HEAD").getObjectId());
     adminId = admin.getId();
     checker = checkerProvider.get();
   }
 
   @Test
   public void validNewChange() throws Exception {
-    Change c = insertChange();
-    insertPatchSet(c);
-    incrementPatchSet(c);
-    insertPatchSet(c);
-    assertProblems(c);
+    assertNoProblems(insertChange(), null);
   }
 
   @Test
   public void validMergedChange() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    insertPatchSet(c);
-    incrementPatchSet(c);
-
-    incrementPatchSet(c);
-    RevCommit commit2 = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps2 = newPatchSet(c.currentPatchSetId(), commit2, adminId);
-    db.patchSets().insert(singleton(ps2));
-
-    testRepo.branch(c.getDest().get()).update(commit2);
-    assertProblems(c);
+    ChangeControl ctl = mergeChange(incrementPatchSet(insertChange()));
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void missingOwner() throws Exception {
-    Change c = newChange(project, new Account.Id(2));
-    db.changes().insert(singleton(c));
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
+    TestAccount owner = accounts.create("missing");
+    ChangeControl ctl = insertChange(owner);
+    db.accounts().deleteKeys(singleton(owner.getId()));
 
-    assertProblems(c, "Missing change owner: 2");
+    assertProblems(ctl, null,
+        problem("Missing change owner: " + owner.getId()));
   }
 
   @Test
   public void missingRepo() throws Exception {
-    Change c = newChange(new Project.NameKey("otherproject"), adminId);
-    db.changes().insert(singleton(c));
-    insertMissingPatchSet(c, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertProblems(c, "Destination repository not found: otherproject");
+    // NoteDb can't have a change without a repo.
+    assume().that(notesMigration.enabled()).isFalse();
+
+    ChangeControl ctl = insertChange();
+    Project.NameKey name = ctl.getProject().getNameKey();
+    ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
+
+    assertProblems(
+        ctl, null,
+        problem("Destination repository not found: " + name));
   }
 
   @Test
   public void invalidRevision() throws Exception {
-    Change c = insertChange();
+    // NoteDb always parses the revision when inserting a patch set, so we can't
+    // create an invalid patch set.
+    assume().that(notesMigration.enabled()).isFalse();
 
-    db.patchSets().insert(singleton(newPatchSet(c.currentPatchSetId(),
-            "fooooooooooooooooooooooooooooooooooooooo", adminId)));
-    incrementPatchSet(c);
-    insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps = newPatchSet(
+        ctl.getChange().currentPatchSetId(),
+        "fooooooooooooooooooooooooooooooooooooooo",
+        adminId);
+    db.patchSets().update(singleton(ps));
 
-    assertProblems(c,
-        "Invalid revision on patch set 1:"
-        + " fooooooooooooooooooooooooooooooooooooooo");
+    assertProblems(
+        ctl, null,
+        problem("Invalid revision on patch set 1:"
+            + " fooooooooooooooooooooooooooooooooooooooo"));
   }
 
   // No test for ref existing but object missing; InMemoryRepository won't let
@@ -131,394 +176,526 @@
 
   @Test
   public void patchSetObjectAndRefMissing() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), adminId);
-    db.patchSets().insert(singleton(ps));
-
-    assertProblems(c,
-        "Ref missing: " + ps.getId().toRefName(),
-        "Object missing: patch set 1: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeControl ctl = insertChange();
+    PatchSet ps = insertMissingPatchSet(ctl, rev);
+    ctl = reload(ctl);
+    assertProblems(
+        ctl, null,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem(
+            "Object missing: patch set 2:"
+            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
   @Test
   public void patchSetObjectAndRefMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), adminId);
-    db.patchSets().insert(singleton(ps));
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeControl ctl = insertChange();
+    PatchSet ps = insertMissingPatchSet(ctl, rev);
+    ctl = reload(ctl);
 
     String refName = ps.getId().toRefName();
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + refName);
-    assertThat(p.status).isNull();
+    assertProblems(
+        ctl, new FixInput(),
+        problem("Ref missing: " + refName),
+        problem("Object missing: patch set 2: " + rev));
   }
 
   @Test
   public void patchSetRefMissing() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = insertPatchSet(c);
-    String refName = ps.getId().toRefName();
-    testRepo.update("refs/other/foo", ObjectId.fromString(ps.getRevision().get()));
+    ChangeControl ctl = insertChange();
+    testRepo.update(
+        "refs/other/foo",
+        ObjectId.fromString(
+            psUtil.current(db, ctl.getNotes()).getRevision().get()));
+    String refName = ctl.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
-    assertProblems(c, "Ref missing: " + refName);
+    assertProblems(ctl, null, problem("Ref missing: " + refName));
   }
 
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = insertPatchSet(c);
-    String refName = ps.getId().toRefName();
-    testRepo.update("refs/other/foo", ObjectId.fromString(ps.getRevision().get()));
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    String refName = ctl.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + refName);
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Repaired patch set ref");
-
-    assertThat(testRepo.getRepository().getRef(refName).getObjectId().name())
-        .isEqualTo(ps.getRevision().get());
+    assertProblems(
+        ctl, new FixInput(),
+        problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
+    assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name())
+        .isEqualTo(rev);
   }
 
   @Test
   public void patchSetObjectAndRefMissingWithDeletingPatchSet()
       throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
+    ctl = reload(ctl);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(2);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps2.getId().toRefName());
-    assertThat(p.status).isNull();
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2,
+            FIXED, "Deleted patch set"));
 
-    c = db.changes().get(c.getId());
-    assertThat(c.currentPatchSetId().get()).isEqualTo(1);
-    assertThat(db.patchSets().get(ps1.getId())).isNotNull();
-    assertThat(db.patchSets().get(ps2.getId())).isNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.get(db, ctl.getNotes(), ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps2.getId())).isNull();
   }
 
   @Test
   public void patchSetMultipleObjectsMissingWithDeletingPatchSets()
       throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
 
-    incrementPatchSet(c);
-    PatchSet ps3 = insertPatchSet(c);
+    ctl = incrementPatchSet(reload(ctl));
+    PatchSet ps3 = psUtil.current(db, ctl.getNotes());
 
-    incrementPatchSet(c);
-    PatchSet ps4 = insertMissingPatchSet(c,
-        "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee");
+    String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
+    PatchSet ps4 = insertMissingPatchSet(ctl, rev4);
+    ctl = reload(ctl);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(4);
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2,
+            FIXED, "Deleted patch set"),
+        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Object missing: patch set 4: " + rev4,
+            FIXED, "Deleted patch set"));
 
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps4.getId().toRefName());
-    assertThat(p.status).isNull();
-
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 4: c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
-
-    p = problems.get(2);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps2.getId().toRefName());
-    assertThat(p.status).isNull();
-
-    p = problems.get(3);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
-
-    c = db.changes().get(c.getId());
-    assertThat(c.currentPatchSetId().get()).isEqualTo(3);
-    assertThat(db.patchSets().get(ps1.getId())).isNotNull();
-    assertThat(db.patchSets().get(ps2.getId())).isNull();
-    assertThat(db.patchSets().get(ps3.getId())).isNotNull();
-    assertThat(db.patchSets().get(ps4.getId())).isNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(3);
+    assertThat(psUtil.get(db, ctl.getNotes(), ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps2.getId())).isNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps3.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps4.getId())).isNull();
   }
 
   @Test
   public void onlyPatchSetObjectMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    Change c = TestChanges.newChange(
+        project, admin.getId(), sequences.nextChangeId());
+    PatchSet.Id psId = c.currentPatchSetId();
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps = newPatchSet(psId, rev, adminId);
+
+    db.changes().insert(singleton(c));
+    db.patchSets().insert(singleton(ps));
+    addNoteDbCommit(
+        c.getId(),
+        "Create change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: " + c.getDest().get() + "\n"
+            + "Change-id: " + c.getKey().get() + "\n"
+            + "Subject: Bogus subject\n"
+            + "Commit: " + rev + "\n"
+            + "Groups: " + rev + "\n");
+    indexer.index(db, c.getProject(), c.getId());
+    IdentifiedUser user = userFactory.create(admin.getId());
+    ChangeControl ctl = changeControlFactory.controlFor(
+        db, c.getProject(), c.getId(), user);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(2);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps1.getId().toRefName());
-    assertThat(p.status).isNull();
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 1: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIX_FAILED);
-    assertThat(p.outcome)
-        .isEqualTo("Cannot delete patch set; no patch sets would remain");
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Object missing: patch set 1: " + rev,
+            FIX_FAILED, "Cannot delete patch set; no patch sets would remain"));
 
-    c = db.changes().get(c.getId());
-    assertThat(c.currentPatchSetId().get()).isEqualTo(1);
-    assertThat(db.patchSets().get(ps1.getId())).isNotNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.current(db, ctl.getNotes())).isNotNull();
   }
 
   @Test
   public void currentPatchSetMissing() throws Exception {
-    Change c = insertChange();
-    assertProblems(c, "Current patch set 1 not found");
+    // NoteDb can't create a change without a patch set.
+    assume().that(notesMigration.enabled()).isFalse();
+
+    ChangeControl ctl = insertChange();
+    db.patchSets().deleteKeys(singleton(ctl.getChange().currentPatchSetId()));
+    assertProblems(ctl, null, problem("Current patch set 1 not found"));
   }
 
   @Test
   public void duplicatePatchSetRevisions() throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev = ps1.getRevision().get();
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c, rev);
-    updatePatchSetRef(ps2);
 
-    assertProblems(c,
-        "Multiple patch sets pointing to " + rev + ": [1, 2]");
+    ctl = incrementPatchSet(
+        ctl, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    assertProblems(
+        ctl, null,
+        problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
   }
 
   @Test
   public void missingDestRef() throws Exception {
+    ChangeControl ctl = insertChange();
+
     String ref = "refs/heads/master";
     // Detach head so we're allowed to delete ref.
-    testRepo.reset(testRepo.getRepository().getRef(ref).getObjectId());
+    testRepo.reset(testRepo.getRepository().exactRef(ref).getObjectId());
     RefUpdate ru = testRepo.getRepository().updateRef(ref);
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    Change c = insertChange();
-    RevCommit commit = testRepo.commit().create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps);
-    db.patchSets().insert(singleton(ps));
 
-    assertProblems(c, "Destination ref not found (may be new branch): " + ref);
+    assertProblems(
+        ctl, null,
+        problem("Destination ref not found (may be new branch): " + ref));
   }
 
   @Test
   public void mergedChangeIsNotMerged() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    String rev = ps.getRevision().get();
+    ChangeControl ctl = insertChange();
 
-    assertProblems(c,
-        "Patch set 1 (" + rev + ") is not merged into destination ref"
-        + " refs/heads/master (" + tip.name()
-        + "), but change status is MERGED");
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          ctx.getChange().setStatus(Change.Status.MERGED);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .fixStatus(Change.Status.MERGED);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    ctl = reload(ctl);
+
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ObjectId tip = getDestRef(ctl);
+    assertProblems(
+        ctl, null,
+        problem(
+            "Patch set 1 (" + rev + ") is not merged into destination ref"
+                + " refs/heads/master (" + tip.name()
+                + "), but change status is MERGED"));
   }
 
   @Test
   public void newChangeIsMerged() throws Exception {
-    Change c = insertChange();
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
-    testRepo.branch(c.getDest().get()).update(commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    assertProblems(c,
-        "Patch set 1 (" + commit.name() + ") is merged into destination ref"
-        + " refs/heads/master (" + commit.name()
-        + "), but change status is NEW");
+    assertProblems(
+        ctl, null,
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev
+                + "), but change status is NEW"));
   }
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
-    Change c = insertChange();
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
-    testRepo.branch(c.getDest().get()).update(commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Patch set 1 (" + commit.name() + ") is merged into destination ref"
-        + " refs/heads/master (" + commit.name()
-        + "), but change status is NEW");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Marked change as merged");
+    assertProblems(
+        ctl, new FixInput(),
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev
+                + "), but change status is NEW",
+            FIXED, "Marked change as merged"));
 
-    c = db.changes().get(c.getId());
-    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
-    assertProblems(c);
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(ctl, null);
+  }
+
+  @Test
+  public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    ChangeInfo info = gApi.changes()
+        .id(ctl.getId().get())
+        .info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    info = gApi.changes()
+        .id(ctl.getId().get())
+        .check(new FixInput());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
-    testRepo.update(c.getDest().get(), commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = commit.name();
-    assertThat(checker.check(c, fix).problems()).isEmpty();
+    fix.expectMergedAs = rev;
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev + "), but change status is NEW",
+            FIXED, "Marked change as merged"));
+
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
-    testRepo.update(c.getDest().get(), commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(ctl.getChange().getDest().get()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other =
         testRepo.commit().message(commit.getFullMessage()).create();
     fix.expectMergedAs = other.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + other.name()
-        + " is not merged into destination ref refs/heads/master"
-        + " (" + commit.name() + ")");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Expected merged commit " + other.name()
+                + " is not merged into destination ref refs/heads/master"
+                + " (" + commit.name() + ")"));
   }
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(parent)
+    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
         .message(commit.getShortMessage()).create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
-    testRepo.update(c.getDest().get(), mergedAs);
+    testRepo.update(dest, mergedAs);
 
-    assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
-        + " destination ref refs/heads/master (" + mergedAs.name()
-        + "), but change status is MERGED");
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "No patch set found for merged commit " + mergedAs.name());
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED, "Marked change as merged"),
+        problem(
+            "Expected merged commit " + mergedAs.name()
+                + " has no associated patch set",
+            FIXED, "Inserted as patch set 2"));
 
-    c = db.changes().get(c.getId());
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
-    assertThat(c.currentPatchSetId()).isEqualTo(psId2);
-    assertThat(db.patchSets().get(psId2).getRevision().get())
+    ctl = reload(ctl);
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
         .isEqualTo(mergedAs.name());
 
-    assertProblems(c);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(parent)
+    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
         .message(commit.getShortMessage() + "\n"
             + "\n"
-            + "Change-Id: " + c.getKey().get() + "\n").create();
+            + "Change-Id: " + ctl.getChange().getKey().get() + "\n").create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
-        .containsExactly(c.getKey().get());
-    testRepo.update(c.getDest().get(), mergedAs);
+        .containsExactly(ctl.getChange().getKey().get());
+    testRepo.update(dest, mergedAs);
 
-    assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
-        + " destination ref refs/heads/master (" + mergedAs.name()
-        + "), but change status is MERGED");
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "No patch set found for merged commit " + mergedAs.name());
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED, "Marked change as merged"),
+        problem(
+            "Expected merged commit " + mergedAs.name()
+                + " has no associated patch set",
+            FIXED, "Inserted as patch set 2"));
 
-    c = db.changes().get(c.getId());
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
-    assertThat(c.currentPatchSetId()).isEqualTo(psId2);
-    assertThat(db.patchSets().get(psId2).getRevision().get())
+    ctl = reload(ctl);
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
         .isEqualTo(mergedAs.name());
 
-    assertProblems(c);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev1 = ps1.getRevision().get();
-    incrementPatchSet(c);
-    PatchSet ps2 = insertPatchSet(c);
-    testRepo.branch(c.getDest().get()).update(parseCommit(ps1));
+    ctl = incrementPatchSet(ctl);
+    PatchSet ps2 = psUtil.current(db, ctl.getNotes());
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev1;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + rev1 + " corresponds to patch set "
-        + ps1.getId() + ", which is not the current patch set " + ps2.getId());
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + rev1,
+            FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit " + rev1 + " corresponds to patch set 1,"
+                + " not the current patch set 2",
+            FIXED, "Deleted patch set"),
+        problem(
+            "Expected merge commit " + rev1 + " corresponds to patch set 1,"
+                + " not the current patch set 2",
+            FIXED, "Inserted as patch set 3"));
+
+    ctl = reload(ctl);
+    PatchSet.Id psId3 = new PatchSet.Id(ctl.getId(), 3);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId3);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
+        .containsExactly(ps2.getId(), psId3);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId3).getRevision().get())
+        .isEqualTo(rev1);
+  }
+
+  @Test
+  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent()
+      throws Exception {
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+
+    // Create dangling ref so next ID in the database becomes 3.
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    RevCommit commit2 = patchSetCommit(psId2);
+    String rev2 = commit2.name();
+    testRepo.branch(psId2.toRefName()).update(commit2);
+
+    ctl = incrementPatchSet(ctl);
+    PatchSet ps3 = psUtil.current(db, ctl.getNotes());
+    assertThat(ps3.getId().get()).isEqualTo(3);
+
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev2;
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + rev2,
+            FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+                + " not the current patch set 3",
+            FIXED, "Deleted patch set"),
+        problem(
+            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+                + " not the current patch set 3",
+            FIXED, "Inserted as patch set 4"));
+
+    ctl = reload(ctl);
+    PatchSet.Id psId4 = new PatchSet.Id(ctl.getId(), 4);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId4);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
+        .containsExactly(ps1.getId(), ps3.getId(), psId4);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId4).getRevision().get())
+        .isEqualTo(rev2);
+  }
+
+  @Test
+  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent()
+      throws Exception {
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+
+    // Create dangling ref with no patch set.
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    RevCommit commit2 = patchSetCommit(psId2);
+    String rev2 = commit2.name();
+    testRepo.branch(psId2.toRefName()).update(commit2);
+
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
+
+    FixInput fix = new FixInput();
+    fix.expectMergedAs = rev2;
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + rev2,
+            FIXED, "Marked change as merged"),
+        problem(
+            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+                + " not the current patch set 1",
+            FIXED, "Inserted as patch set 2"));
+
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
+        .containsExactly(ps1.getId(), psId2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
+        .isEqualTo(rev2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
     RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+        testRepo.branch(dest).commit().message("parent").create();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
     RevCommit mergedAs = testRepo.commit().parent(parent)
@@ -527,77 +704,141 @@
             + "Change-Id: " + badId + "\n")
         .create();
     testRepo.getRevWalk().parseBody(mergedAs);
-    testRepo.update(c.getDest().get(), mergedAs);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
+        .containsExactly(badId);
+    testRepo.update(dest, mergedAs);
+
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + mergedAs.name() + " has Change-Id: "
-        + badId + ", but expected " + c.getKey().get());
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Expected merged commit " + mergedAs.name() + " has Change-Id: "
+                + badId + ", but expected " + ctl.getChange().getKey().get()));
   }
 
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets()
       throws Exception {
-    Change c1 = insertChange();
-    c1.setStatus(Change.Status.MERGED);
-    insertPatchSet(c1);
+    ChangeControl ctl1 = insertChange();
+    PatchSet.Id psId1 = psUtil.current(db, ctl1.getNotes()).getId();
+    String dest = ctl1.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl1.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
 
-    RevCommit commit = testRepo.branch(c1.getDest().get()).commit().create();
-    Change c2 = insertChange();
-    PatchSet ps2 = newPatchSet(c2.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps2);
-    db.patchSets().insert(singleton(ps2));
+    ChangeControl ctl2 = insertChange();
+    ctl2 = incrementPatchSet(ctl2, commit);
+    PatchSet.Id psId2 = psUtil.current(db, ctl2.getNotes()).getId();
 
-    Change c3 = insertChange();
-    PatchSet ps3 = newPatchSet(c3.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps3);
-    db.patchSets().insert(singleton(ps3));
+    ChangeControl ctl3 = insertChange();
+    ctl3 = incrementPatchSet(ctl3, commit);
+    PatchSet.Id psId3 = psUtil.current(db, ctl3.getNotes()).getId();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
-    List<ProblemInfo> problems = checker.check(c1, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Multiple patch sets for expected merged commit " + commit.name()
-        + ": [" + ps2 + ", " + ps3 + "]");
+    assertProblems(
+        ctl1, fix,
+        problem(
+            "Multiple patch sets for expected merged commit " + commit.name()
+                + ": [" + psId1 + ", " + psId2 + ", " + psId3 + "]"));
   }
 
-  private Change insertChange() throws Exception {
-    Change c = newChange(project, adminId);
-    db.changes().insert(singleton(c));
-    return c;
+  private BatchUpdate newUpdate(Account.Id owner) {
+    return updateFactory.create(
+        db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
-  private void incrementPatchSet(Change c) throws Exception {
-    TestChanges.incrementPatchSet(c);
-    db.changes().upsert(singleton(c));
+  private ChangeControl insertChange() throws Exception {
+    return insertChange(admin);
   }
 
-  private PatchSet insertPatchSet(Change c) throws Exception {
-    db.changes().upsert(singleton(c));
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).message("Change " + c.getId().get()).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps);
+
+  private ChangeControl insertChange(TestAccount owner) throws Exception {
+    return insertChange(owner, "refs/heads/master");
+  }
+
+  private ChangeControl insertChange(TestAccount owner, String dest)
+      throws Exception {
+    Change.Id id = new Change.Id(sequences.nextChangeId());
+    ChangeInserter ins;
+    try (BatchUpdate bu = newUpdate(owner.getId())) {
+      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+      ins = changeInserterFactory
+          .create(id, commit, dest)
+          .setValidatePolicy(CommitValidators.Policy.NONE)
+          .setNotify(NotifyHandling.NONE)
+          .setFireRevisionCreated(false)
+          .setSendMail(false);
+      bu.insertChange(ins).execute();
+    }
+    // Return control for admin regardless of owner.
+    return changeControlFactory.controlFor(
+        db, ins.getChange(), userFactory.create(adminId));
+  }
+
+  private PatchSet.Id nextPatchSetId(ChangeControl ctl) throws Exception {
+    return ChangeUtil.nextPatchSetId(
+        testRepo.getRepository(), ctl.getChange().currentPatchSetId());
+  }
+
+  private ChangeControl incrementPatchSet(ChangeControl ctl) throws Exception {
+    return incrementPatchSet(ctl, patchSetCommit(nextPatchSetId(ctl)));
+  }
+
+  private ChangeControl incrementPatchSet(ChangeControl ctl,
+      RevCommit commit) throws Exception {
+    PatchSetInserter ins;
+    try (BatchUpdate bu = newUpdate(ctl.getChange().getOwner())) {
+      ins = patchSetInserterFactory.create(ctl, nextPatchSetId(ctl), commit)
+          .setValidatePolicy(CommitValidators.Policy.NONE)
+          .setFireRevisionCreated(false)
+          .setSendMail(false);
+      bu.addOp(ctl.getId(), ins).execute();
+    }
+    return reload(ctl);
+  }
+
+  private ChangeControl reload(ChangeControl ctl) throws Exception {
+    return changeControlFactory.controlFor(
+        db, ctl.getChange().getProject(), ctl.getId(), ctl.getUser());
+  }
+
+  private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
+    RevCommit c = testRepo
+        .commit()
+        .parent(tip)
+        .message("Change " + psId)
+        .create();
+    return testRepo.parseBody(c);
+  }
+
+  private PatchSet insertMissingPatchSet(ChangeControl ctl, String rev)
+      throws Exception {
+    // Don't use BatchUpdate since we're manually updating the meta ref rather
+    // than using ChangeUpdate.
+    String subject = "Subject for missing commit";
+    Change c = new Change(ctl.getChange());
+    PatchSet.Id psId = nextPatchSetId(ctl);
+    c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+
+    PatchSet ps = newPatchSet(psId, rev, adminId);
     db.patchSets().insert(singleton(ps));
-    return ps;
-  }
+    db.changes().update(singleton(c));
 
-  private PatchSet insertMissingPatchSet(Change c, String id) throws Exception {
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString(id), adminId);
-    db.patchSets().insert(singleton(ps));
-    return ps;
-  }
+    addNoteDbCommit(
+        c.getId(),
+        "Update patch set " + psId.get() + "\n"
+            + "\n"
+            + "Patch-set: " + psId.get() + "\n"
+            + "Commit: " + rev + "\n"
+            + "Subject: " + subject + "\n");
+    indexer.index(db, c.getProject(), c.getId());
 
-  private void updatePatchSetRef(PatchSet ps) throws Exception {
-    testRepo.update(ps.getId().toRefName(),
-        ObjectId.fromString(ps.getRevision().get()));
+    return ps;
   }
 
   private void deleteRef(String refName) throws Exception {
@@ -606,22 +847,82 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  private RevCommit parseCommit(PatchSet ps) throws Exception {
-    RevCommit commit = testRepo.getRevWalk()
-        .parseCommit(ObjectId.fromString(ps.getRevision().get()));
-    testRepo.getRevWalk().parseBody(commit);
-    return commit;
+  private void addNoteDbCommit(Change.Id id, String commitMessage)
+      throws Exception {
+    if (!notesMigration.commitChangeWrites()) {
+      return;
+    }
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = noteUtil.newIdent(
+        accountCache.get(admin.getId()).getAccount(),
+        committer.getWhen(),
+        committer,
+        anonymousCowardName);
+    testRepo.branch(RefNames.changeMetaRef(id))
+        .commit()
+        .author(author)
+        .committer(committer)
+        .message(commitMessage)
+        .create();
   }
 
-  private void assertProblems(Change c, String... expected) {
-    assertThat(Lists.transform(checker.check(c).problems(),
-          new Function<ProblemInfo, String>() {
-            @Override
-            public String apply(ProblemInfo in) {
-              checkArgument(in.status == null,
-                  "Status is not null: " + in.message);
-              return in.message;
-            }
-          })).containsExactly((Object[]) expected);
+  private ObjectId getDestRef(ChangeControl ctl) throws Exception {
+    return testRepo.getRepository()
+        .exactRef(ctl.getChange().getDest().get())
+        .getObjectId();
+  }
+
+  private ChangeControl mergeChange(ChangeControl ctl) throws Exception {
+    final ObjectId oldId = getDestRef(ctl);
+    final ObjectId newId = ObjectId.fromString(
+        psUtil.current(db, ctl.getNotes()).getRevision().get());
+    final String dest = ctl.getChange().getDest().get();
+
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+        @Override
+        public void updateRepo(RepoContext ctx) throws IOException {
+          ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+        }
+
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          ctx.getChange().setStatus(Change.Status.MERGED);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .fixStatus(Change.Status.MERGED);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    return reload(ctl);
+  }
+
+  private static ProblemInfo problem(String message) {
+    ProblemInfo p = new ProblemInfo();
+    p.message = message;
+    return p;
+  }
+
+  private static ProblemInfo problem(String message,
+      ProblemInfo.Status status, String outcome) {
+    ProblemInfo p = problem(message);
+    p.status = checkNotNull(status);
+    p.outcome = checkNotNull(outcome);
+    return p;
+  }
+
+  private void assertProblems(ChangeControl ctl, @Nullable FixInput fix,
+      ProblemInfo first, ProblemInfo... rest) {
+    List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
+    expected.add(first);
+    expected.addAll(Arrays.asList(rest));
+    assertThat(checker.check(ctl, fix).problems())
+        .containsExactlyElementsIn(expected)
+        .inOrder();
+  }
+
+  private void assertNoProblems(ChangeControl ctl, @Nullable FixInput fix) {
+    assertThat(checker.check(ctl, fix).problems()).isEmpty();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 2f110de..40ea296 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -16,31 +16,52 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.GetRelated.ChangeAndCommit;
 import com.google.gerrit.server.change.GetRelated.RelatedInfo;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
 import java.util.List;
 
 public class GetRelatedIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
   @Inject
   private ChangeEditUtil editUtil;
 
@@ -48,7 +69,10 @@
   private ChangeEditModifier editModifier;
 
   @Inject
-  private ChangeData.Factory changeDataFactory;
+  private BatchUpdate.Factory updateFactory;
+
+  @Inject
+  private ChangesCollection changes;
 
   @Test
   public void getRelatedNoResult() throws Exception {
@@ -79,6 +103,38 @@
   }
 
   @Test
+  public void getRelatedLinearSeparatePushes() throws Exception {
+    // 1,1---2,1
+    RevCommit c1_1 = commitBuilder()
+        .add("a.txt", "1")
+        .message("subject: 1")
+        .create();
+    RevCommit c2_1 = commitBuilder()
+        .add("b.txt", "2")
+        .message("subject: 2")
+        .create();
+
+    testRepo.reset(c1_1);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+    String oldETag = changes.parse(ps1_1.getParentKey()).getETag();
+
+    testRepo.reset(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    // Push of change 2 should not affect groups (or anything else) of change 1.
+    assertThat(changes.parse(ps1_1.getParentKey()).getETag())
+        .isEqualTo(oldETag);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps,
+          changeAndCommit(ps2_1, c2_1, 1),
+          changeAndCommit(ps1_1, c1_1, 1));
+    }
+  }
+
+  @Test
   public void getRelatedReorder() throws Exception {
     // 1,1---2,1
     //
@@ -163,7 +219,7 @@
     // 2,2---1,2---3,1
 
     // Create two commits and push.
-    ObjectId initial = repo().getRef("HEAD").getObjectId();
+    ObjectId initial = repo().exactRef("HEAD").getObjectId();
     RevCommit c1_1 = commitBuilder()
         .add("a.txt", "1")
         .message("subject: 1")
@@ -521,9 +577,9 @@
     pushHead(testRepo, "refs/for/master", false);
 
     Change ch2 = getChange(c2_1).change();
-    editModifier.createEdit(ch2, getPatchSet(ch2));
+    editModifier.createEdit(ch2, getPatchSet(ch2.currentPatchSetId()));
     editModifier.modifyFile(editUtil.byChange(ch2).get(), "a.txt",
-        RestSession.newRawInput(new byte[] {'a'}));
+        RawInputUtil.create(new byte[] {'a'}));
     ObjectId editRev =
         ObjectId.fromString(editUtil.byChange(ch2).get().getRevision().get());
 
@@ -569,15 +625,12 @@
     }
 
     // Pretend PS1,1 was pushed before the groups field was added.
-    PatchSet ps1_1 = db.patchSets().get(psId1_1);
-    ps1_1.setGroups(null);
-    db.patchSets().update(ImmutableList.of(ps1_1));
-    indexer.index(changeDataFactory.create(db, psId1_1.getParentKey()));
+    clearGroups(psId1_1);
+    indexer.index(
+        changeDataFactory.create(db, project, psId1_1.getParentKey()));
 
-    if (!cfg.getBoolean("change", null, "getRelatedByAncestors", false)) {
-      // PS1,1 has no groups, so disappeared from related changes.
-      assertRelated(psId2_1);
-    }
+    // PS1,1 has no groups, so disappeared from related changes.
+    assertRelated(psId2_1);
 
     RevCommit c2_2 = testRepo.amend(c2_1)
         .add("c.txt", "2")
@@ -593,32 +646,28 @@
         changeAndCommit(psId1_1, c1_1, 1));
   }
 
-  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws IOException {
+  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
     return getRelated(ps.getParentKey(), ps.get());
   }
 
   private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps)
-      throws IOException {
+      throws Exception {
     String url = String.format("/changes/%d/revisions/%d/related",
         changeId.get(), ps);
-    return newGson().fromJson(adminSession.get(url).getReader(),
+    return newGson().fromJson(adminRestSession.get(url).getReader(),
         RelatedInfo.class).changes;
   }
 
-  private RevCommit parseBody(RevCommit c) throws IOException {
+  private RevCommit parseBody(RevCommit c) throws Exception {
     testRepo.getRevWalk().parseBody(c);
     return c;
   }
 
-  private PatchSet.Id getPatchSetId(ObjectId c) throws OrmException {
+  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
     return getChange(c).change().currentPatchSetId();
   }
 
-  private PatchSet getPatchSet(Change c) throws OrmException {
-    return db.patchSets().get(c.currentPatchSetId());
-  }
-
-  private ChangeData getChange(ObjectId c) throws OrmException {
+  private ChangeData getChange(ObjectId c) throws Exception {
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
@@ -634,6 +683,23 @@
     return result;
   }
 
+  private void clearGroups(final PatchSet.Id psId) throws Exception {
+    try (BatchUpdate bu = updateFactory.create(
+        db, project, user(user), TimeUtil.nowTs())) {
+      bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+          psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps,
+              ImmutableList.<String> of());
+          ctx.bumpLastUpdatedOn(false);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+  }
+
   private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected)
       throws Exception {
     List<ChangeAndCommit> actual = getRelated(psId);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index c4a07f2..3b4b6a8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -21,13 +21,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -235,14 +233,14 @@
   }
 
   private List<PatchListEntry> getCurrentPatches(String changeId)
-      throws PatchListNotAvailableException, RestApiException {
+      throws Exception {
     return patchListCache
         .get(getKey(null, getCurrentRevisionId(changeId)), project)
         .getPatches();
   }
 
   private List<PatchListEntry> getPatches(ObjectId revisionIdA, ObjectId revisionIdB)
-      throws PatchListNotAvailableException {
+      throws Exception {
     return patchListCache.get(getKey(revisionIdA, revisionIdB), project)
         .getPatches();
   }
@@ -251,7 +249,7 @@
     return new PatchListKey(revisionIdA, revisionIdB, Whitespace.IGNORE_NONE);
   }
 
-  private ObjectId getCurrentRevisionId(String changeId) throws RestApiException {
+  private ObjectId getCurrentRevisionId(String changeId) throws Exception {
     return ObjectId.fromString(gApi.changes().id(changeId).get().currentRevision);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 5a8a44c..06170d0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -16,23 +16,32 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
-import java.io.IOException;
+import java.util.EnumSet;
+import java.util.List;
 
 public class SubmittedTogetherIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
 
   @Test
   public void returnsAncestors() throws Exception {
@@ -54,6 +63,17 @@
   }
 
   @Test
+  public void anonymousAncestors() throws Exception {
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    setApiUserAnonymous();
+    assertSubmittedTogether(getChangeId(a));
+    assertSubmittedTogether(getChangeId(b), getChangeId(b), getChangeId(a));
+  }
+
+  @Test
   public void respectsWholeTopicAndAncestors() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
@@ -82,6 +102,144 @@
   }
 
   @Test
+  public void anonymousWholeTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id2 = getChangeId(b);
+
+    setApiUserAnonymous();
+    if (isSubmitWholeTopicEnabled()) {
+      assertSubmittedTogether(id1, id2, id1);
+      assertSubmittedTogether(id2, id2, id1);
+    } else {
+      assertSubmittedTogether(id1);
+      assertSubmittedTogether(id2);
+    }
+  }
+
+  @Test
+  public void hiddenDraftInTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    commitBuilder().add("b", "2").message("invisible change").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    setApiUser(user);
+    SubmittedTogetherInfo result = gApi.changes()
+        .id(id1)
+        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(result.changes).hasSize(1);
+      assertThat(result.changes.get(0).changeId).isEqualTo(id1);
+      assertThat(result.nonVisibleChanges).isEqualTo(1);
+    } else {
+      assertThat(result.changes).hasSize(0);
+      assertThat(result.nonVisibleChanges).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void hiddenDraftInTopicOldApi() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    commitBuilder().add("b", "2").message("invisible change").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    setApiUser(user);
+    if (isSubmitWholeTopicEnabled()) {
+      exception.expect(AuthException.class);
+      exception.expectMessage(
+          "change would be submitted with a change that you cannot see");
+      gApi.changes().id(id1).submittedTogether();
+    } else {
+      List<ChangeInfo> result = gApi.changes().id(id1).submittedTogether();
+      assertThat(result).hasSize(0);
+    }
+  }
+
+  @Test
+  public void draftPatchSetInTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a1 = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a1);
+
+    testRepo.reset(initialHead);
+    RevCommit parent = commitBuilder().message("parent").create();
+    pushHead(testRepo, "refs/for/master", false);
+    String parentId = getChangeId(parent);
+
+    // TODO(jrn): use insertChangeId(id1) once jgit TestRepository accepts
+    // the leading "I".
+    commitBuilder()
+        .insertChangeId(id1.substring(1))
+        .add("a", "2")
+        .message("draft patch set on change 1")
+        .create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit b = commitBuilder().message("change with same topic").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id2 = getChangeId(b);
+
+    if (isSubmitWholeTopicEnabled()) {
+      setApiUser(user);
+      assertSubmittedTogether(id2, id2, id1);
+      setApiUser(admin);
+      assertSubmittedTogether(id2, id2, id1, parentId);
+    } else {
+      setApiUser(user);
+      assertSubmittedTogether(id2);
+      setApiUser(admin);
+      assertSubmittedTogether(id2);
+    }
+  }
+
+  @Test
+  public void doNotRevealVisibleAncestorOfHiddenDraft() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    commitBuilder().message("parent").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    commitBuilder().message("draft").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit change = commitBuilder().message("same topic").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id = getChangeId(change);
+
+    setApiUser(user);
+    SubmittedTogetherInfo result = gApi.changes()
+        .id(id)
+        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(result.changes).hasSize(1);
+      assertThat(result.changes.get(0).changeId).isEqualTo(id);
+      assertThat(result.nonVisibleChanges).isEqualTo(2);
+    } else {
+      assertThat(result.changes).isEmpty();
+      assertThat(result.nonVisibleChanges).isEqualTo(0);
+    }
+  }
+
+  @Test
   public void testTopicChaining() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
@@ -191,13 +349,6 @@
     assertSubmittedTogether(id2, id2, id1);
   }
 
-  private RevCommit getRemoteHead() throws IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.getRef("refs/heads/master").getObjectId());
-    }
-  }
-
   private String getChangeId(RevCommit c) throws Exception {
     return GitUtil.getChangeId(testRepo, c).get();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK
new file mode 100644
index 0000000..4fbc977
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK
@@ -0,0 +1,7 @@
+include_defs('//gerrit-acceptance-tests/tests.defs')
+
+acceptance_tests(
+  group = 'server_event',
+  srcs = glob(['*IT.java']),
+  labels = ['server'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
new file mode 100644
index 0000000..ff0c51b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'server_event',
+  srcs = glob(['*IT.java']),
+  labels = ['server'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
new file mode 100644
index 0000000..40ec9da
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -0,0 +1,271 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.event;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+import com.google.inject.Inject;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentAddedEventIT extends AbstractDaemonTest {
+
+  @Inject
+  private DynamicSet<CommentAddedListener> source;
+
+  private final LabelType label = category("CustomLabel",
+      value(1, "Positive"),
+      value(0, "No score"),
+      value(-1, "Negative"));
+
+  private final LabelType pLabel = category("CustomLabel2",
+      value(1, "Positive"),
+      value(0, "No score"));
+
+  private RegistrationHandle eventListenerRegistration;
+  private CommentAddedListener.Event lastCommentAddedEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anonymousUsers =
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers,
+        "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers,
+        "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    eventListenerRegistration = source.add(new CommentAddedListener() {
+      @Override
+      public void onCommentAdded(Event event) {
+        lastCommentAddedEvent = event;
+      }
+    });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+  }
+
+  private void saveLabelConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(label.getName(), label);
+    cfg.getLabelSections().put(pLabel.getName(), pLabel);
+    saveProjectConfig(project, cfg);
+  }
+
+  /* Need to lookup info for the label under test since there can be multiple
+   * labels defined.  By default Gerrit already has a Code-Review label.
+   */
+  private ApprovalValues getApprovalValues(LabelType label) {
+    ApprovalValues res = new ApprovalValues();
+    ApprovalInfo info =
+        lastCommentAddedEvent.getApprovals().get(label.getName());
+    if (info != null) {
+      res.value = info.value;
+    }
+    info = lastCommentAddedEvent.getOldApprovals().get(label.getName());
+    if (info != null) {
+      res.oldValue = info.value;
+    }
+    return res;
+  }
+
+  @Test
+  public void newChangeWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change with -1 vote
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(
+        label.getName(), (short)-1);
+    revision(r).review(reviewInput);
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+  }
+
+  @Test
+  public void newPatchSetWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+
+    // push a new revision with +1 vote
+    ChangeInfo c = get(r.getChangeId());
+    r = amendChange(c.changeId);
+    reviewInput = new ReviewInput().label(
+        label.getName(), (short)1);
+    revision(r).review(reviewInput);
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 2: %s+1", label.getName()));
+  }
+
+  @Test
+  public void reviewChange() throws Exception {
+    saveLabelConfig();
+
+    // push a change
+    PushOneCommit.Result r = createChange();
+
+    // review with message only, do not apply votes
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    // reply message only so vote is shown as 0
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isNull();
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1:\n\n%s", label.getName()));
+
+    // transition from un-voted to -1 vote
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+
+    // transition vote from -1 to 0
+    reviewInput = new ReviewInput().label(label.getName(), 0);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(-1);
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: -%s", label.getName()));
+
+    // transition vote from 0 to 1
+    reviewInput = new ReviewInput().label(label.getName(), 1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s+1", label.getName()));
+
+    // transition vote from 1 to -1
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(1);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+
+    // review with message only, do not apply votes
+    reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isNull();  // no vote change so not included
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1:\n\n%s", label.getName()));
+  }
+
+  @Test
+  public void reviewChange_MultipleVotes() throws Exception {
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
+    reviewInput.message = label.getName();
+    revision(r).review(reviewInput);
+
+    ChangeInfo c = get(r.getChangeId());
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    ApprovalValues labelAttr = getApprovalValues(label);
+    assertThat(labelAttr.oldValue).isEqualTo(0);
+    assertThat(labelAttr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s-1\n\n%s",
+            label.getName(), label.getName()));
+
+    // there should be 3 approval labels (label, pLabel, and CRVV)
+    assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
+
+    // check the approvals that were not voted on
+    ApprovalValues pLabelAttr = getApprovalValues(pLabel);
+    assertThat(pLabelAttr.oldValue).isNull();
+    assertThat(pLabelAttr.value).isEqualTo(0);
+
+    LabelType crLabel = LabelType.withDefaultValues("Code-Review");
+    ApprovalValues crlAttr = getApprovalValues(crLabel);
+    assertThat(crlAttr.oldValue).isNull();
+    assertThat(crlAttr.value).isEqualTo(0);
+
+    // update pLabel approval
+    reviewInput = new ReviewInput().label(pLabel.getName(), 1);
+    reviewInput.message = pLabel.getName();
+    revision(r).review(reviewInput);
+
+    c = get(r.getChangeId());
+    q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    pLabelAttr = getApprovalValues(pLabel);
+    assertThat(pLabelAttr.oldValue).isEqualTo(0);
+    assertThat(pLabelAttr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        String.format("Patch Set 1: %s+1\n\n%s",
+            pLabel.getName(), pLabel.getName()));
+
+    // check the approvals that were not voted on
+    labelAttr = getApprovalValues(label);
+    assertThat(labelAttr.oldValue).isNull();
+    assertThat(labelAttr.value).isEqualTo(-1);
+
+    crlAttr = getApprovalValues(crLabel);
+    assertThat(crlAttr.oldValue).isNull();
+    assertThat(crlAttr.value).isEqualTo(0);
+  }
+
+  private static class ApprovalValues {
+    Integer value;
+    Integer oldValue;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK
new file mode 100644
index 0000000..d9976e5
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK
@@ -0,0 +1,7 @@
+include_defs('//gerrit-acceptance-tests/tests.defs')
+
+acceptance_tests(
+  group = 'server_notedb',
+  srcs = glob(['*IT.java']),
+  labels = ['notedb', 'server'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
new file mode 100644
index 0000000..17c4cdc
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -0,0 +1,7 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'server_notedb',
+  srcs = glob(['*IT.java']),
+  labels = ['notedb', 'server'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
new file mode 100644
index 0000000..b443e66
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -0,0 +1,1089 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.PostReview;
+import com.google.gerrit.server.change.Rebuild;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.NoteDbChecker;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+public class ChangeRebuilderIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+    return cfg;
+  }
+
+  @Inject
+  private AllUsersName allUsers;
+
+  @Inject
+  private NoteDbChecker checker;
+
+  @Inject
+  private Rebuild rebuildHandler;
+
+  @Inject
+  private Provider<ReviewDb> dbProvider;
+
+  @Inject
+  private PatchLineCommentsUtil plcUtil;
+
+  @Inject
+  private Provider<PostReview> postReview;
+
+  @Inject
+  private TestChangeRebuilderWrapper rebuilderWrapper;
+
+  @Inject
+  private BatchUpdate.Factory batchUpdateFactory;
+
+  @Inject
+  private Sequences seq;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.readWrite()).isFalse();
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    setNotesMigration(false, false);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @SuppressWarnings("deprecation")
+  private void setNotesMigration(boolean writeChanges, boolean readChanges)
+      throws Exception {
+    notesMigration.setWriteChanges(writeChanges);
+    notesMigration.setReadChanges(readChanges);
+    db = atrScope.reopenDb().getReviewDbProvider().get();
+
+    if (notesMigration.readChangeSequence()) {
+      // Copy next ReviewDb ID to NoteDb.
+      seq.getChangeIdRepoSequence().set(db.nextChangeId());
+    } else {
+      // Copy next NoteDb ID to ReviewDb.
+      while (db.nextChangeId() < seq.getChangeIdRepoSequence().next()) {}
+    }
+  }
+
+  @Test
+  public void changeFields() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void patchSets() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    r = amendChange(r.getChangeId());
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishedComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putComment(user, id, 1, "comment");
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void patchSetWithNullGroups() throws Exception {
+    Timestamp ts = TimeUtil.nowTs();
+    Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
+    c.setCreatedOn(ts);
+    c.setLastUpdatedOn(ts);
+    PatchSet ps = TestChanges.newPatchSet(
+        c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+        user.getId());
+    ps.setCreatedOn(ts);
+    db.changes().insert(Collections.singleton(c));
+    db.patchSets().insert(Collections.singleton(ps));
+
+    assertThat(ps.getGroups()).isEmpty();
+    checker.rebuildAndCheckChanges(c.getId());
+  }
+
+  @Test
+  public void draftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment");
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void draftAndPublishedComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "draft comment");
+    putComment(user, id, 1, "published comment");
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishDraftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "draft comment");
+    publishDrafts(user, id);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void nullAccountId() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    // Events need to be otherwise identical for the account ID to be compared.
+    ChangeMessage msg1 =
+        insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
+    insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2");
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void nullPatchSetId() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+
+    // Events need to be otherwise identical for the PatchSet.ID to be compared.
+    ChangeMessage msg1 =
+        insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
+    insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2");
+
+    PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId();
+
+    ChangeMessage msg3 =
+        insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
+    insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4");
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    Map<String, PatchSet.Id> psIds = new HashMap<>();
+    for (ChangeMessage msg : notes.getChangeMessages()) {
+      PatchSet.Id psId = msg.getPatchSetId();
+      assertThat(psId).named("patchset for " + msg).isNotNull();
+      psIds.put(msg.getMessage(), psId);
+    }
+    // Patch set IDs were replaced during conversion process.
+    assertThat(psIds).containsEntry("message 1", psId1);
+    assertThat(psIds).containsEntry("message 2", psId1);
+    assertThat(psIds).containsEntry("message 3", psId2);
+    assertThat(psIds).containsEntry("message 4", psId2);
+  }
+
+  @Test
+  public void noWriteToNewRef() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    checker.assertNoChangeRef(project, id);
+
+    setNotesMigration(true, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+
+    // First write doesn't create the ref, but rebuilding works.
+    checker.assertNoChangeRef(project, id);
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull();
+    checker.rebuildAndCheckChanges(id);
+
+    // Now that there is a ref, writes are "turned on" for this change, and
+    // NoteDb stays up to date without explicit rebuilding.
+    gApi.changes().id(id.get()).topic(name("new-topic"));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull();
+    checker.checkChanges(id);
+  }
+
+  @Test
+  public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    exception.expect(ResourceNotFoundException.class);
+    rebuildHandler.apply(
+        parseChangeResource(r.getChangeId()),
+        new Rebuild.Input());
+  }
+
+  @Test
+  public void rebuildViaRestApi() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    setNotesMigration(true, false);
+
+    checker.assertNoChangeRef(project, id);
+    rebuildHandler.apply(
+        parseChangeResource(r.getChangeId()),
+        new Rebuild.Input());
+    checker.checkChanges(id);
+  }
+
+  @Test
+  public void writeToNewRefForNewChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    Change.Id id1 = r1.getPatchSetId().getParentKey();
+
+    setNotesMigration(true, false);
+    gApi.changes().id(id1.get()).topic(name("a-topic"));
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id2 = r2.getPatchSetId().getParentKey();
+
+    // Second change was created after NoteDb writes were turned on, so it was
+    // allowed to write to a new ref.
+    checker.checkChanges(id2);
+
+    // First change was created before NoteDb writes were turned on, so its meta
+    // ref doesn't exist until a manual rebuild.
+    checker.assertNoChangeRef(project, id1);
+    checker.rebuildAndCheckChanges(id1);
+  }
+
+  @Test
+  public void noteDbChangeState() throws Exception {
+    setNotesMigration(true, true);
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+
+    ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
+        changeMetaId.name());
+
+    putDraft(user, id, 1, "comment by user");
+    ObjectId userDraftsId = getMetaRef(
+        allUsers, refsDraftComments(id, user.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
+        changeMetaId.name()
+        + "," + user.getId() + "=" + userDraftsId.name());
+
+    putDraft(admin, id, 2, "comment by admin");
+    ObjectId adminDraftsId = getMetaRef(
+        allUsers, refsDraftComments(id, admin.getId()));
+    assertThat(admin.getId().get()).isLessThan(user.getId().get());
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
+        changeMetaId.name()
+        + "," + admin.getId() + "=" + adminDraftsId.name()
+        + "," + user.getId() + "=" + userDraftsId.name());
+
+    putDraft(admin, id, 2, "revised comment by admin");
+    adminDraftsId = getMetaRef(
+        allUsers, refsDraftComments(id, admin.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
+        changeMetaId.name()
+        + "," + admin.getId() + "=" + adminDraftsId.name()
+        + "," + user.getId() + "=" + userDraftsId.name());
+  }
+
+  @Test
+  public void rebuildAutomaticallyWhenChangeOutOfDate() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // On next NoteDb read, the change is transparently rebuilt.
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeBundle actual = ChangeBundle.fromNotes(
+        plcUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+  }
+
+  @Test
+  public void rebuildAutomaticallyWithinBatchUpdate() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    final Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // Next NoteDb read comes inside the transaction started by BatchUpdate. In
+    // reality this could be caused by a failed update happening between when
+    // the change is parsed by ChangesCollection and when the BatchUpdate
+    // executes. We simulate it here by using BatchUpdate directly and not going
+    // through an API handler.
+    setNotesMigration(true, true);
+    final String msg = "message from BatchUpdate";
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project,
+          identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(id, new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+          ChangeMessage cm = new ChangeMessage(
+              new ChangeMessage.Key(id, ChangeUtil.messageUUID(ctx.getDb())),
+                  ctx.getAccountId(), ctx.getWhen(), psId);
+          cm.setMessage(msg);
+          ctx.getDb().changeMessages().insert(Collections.singleton(cm));
+          ctx.getUpdate(psId).setChangeMessage(msg);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    // As an implementation detail, change wasn't actually rebuilt inside the
+    // BatchUpdate transaction, but it was rebuilt during read for the
+    // subsequent reindex. Thus it's impossible to actually observe an
+    // out-of-date state in the caller.
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+    assertThat(
+            Iterables.transform(
+                notes.getChangeMessages(),
+                new Function<ChangeMessage, String>() {
+                  @Override
+                  public String apply(ChangeMessage in) {
+                    return in.getMessage();
+                  }
+                }))
+        .contains(msg);
+  }
+
+  @Test
+  public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // Force the next rebuild attempt to fail but also rebuild the change in the
+    // background.
+    rebuilderWrapper.stealNextUpdate();
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeBundle actual = ChangeBundle.fromNotes(
+        plcUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+  }
+
+  @Test
+  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id));
+
+    // Make a ReviewDb change behind NoteDb's back.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail.
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+    assertChangeUpToDate(false, id);
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId);
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user");
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId =
+        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user");
+    setInvalidNoteDbState(id);
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in ChangeNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user");
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId =
+        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user");
+
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    // Leave change meta ID alone so DraftCommentNotes does the rebuild.
+    NoteDbChangeState bogusState = new NoteDbChangeState(
+        id, NoteDbChangeState.parse(c).getChangeMetaId(),
+        ImmutableMap.<Account.Id, ObjectId>of(
+            user.getId(),
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")));
+    c.setNoteDbState(bogusState.toString());
+    db.changes().update(Collections.singleton(c));
+
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in DraftCommentNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id)
+        .getDraftComments(user.getId());
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception {
+    setNotesMigration(true, true);
+    setApiUser(user);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment");
+    assertDraftsUpToDate(true, id, user);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "comment");
+    setInvalidNoteDbState(id);
+    assertDraftsUpToDate(false, id, user);
+
+    // On next NoteDb read, the drafts are transparently rebuilt.
+    setNotesMigration(true, true);
+    assertThat(gApi.changes().id(id.get()).current().drafts())
+        .containsKey(PushOneCommit.FILE_NAME);
+    assertDraftsUpToDate(true, id, user);
+  }
+
+  @Test
+  public void pushCert() throws Exception {
+    // We don't have the code in our test harness to do signed pushes, so just
+    // use a hard-coded cert. This cert was actually generated by C git 2.2.0
+    // (albeit not for sending to Gerrit).
+    String cert = "certificate version 0.1\n"
+        + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
+        + "pushee git://localhost/repo.git\n"
+        + "nonce 1433954361-bde756572d665bba81d8\n"
+        + "\n"
+        + "0000000000000000000000000000000000000000"
+        + "b981a177396fb47345b7df3e4d3f854c6bea7"
+        + "s/heads/master\n"
+        + "-----BEGIN PGP SIGNATURE-----\n"
+        + "Version: GnuPG v1\n"
+        + "\n"
+        + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+        + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+        + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+        + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+        + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+        + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+        + "=XFeC\n"
+        + "-----END PGP SIGNATURE-----\n";
+
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    PatchSet ps = db.patchSets().get(psId);
+    ps.setPushCertificate(cert);
+    db.patchSets().update(Collections.singleton(ps));
+    indexer.index(db, project, id);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void emptyTopic() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    Change c = db.changes().get(id);
+    assertThat(c.getTopic()).isNull();
+    c.setTopic("");
+    db.changes().update(Collections.singleton(c));
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+
+    // Rebuild and check was successful, but NoteDb doesn't support storing an
+    // empty topic, so it comes out as null.
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getTopic()).isNull();
+  }
+
+  @Test
+  public void commentBeforeFirstPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    Change c = db.changes().get(id);
+    c.setCreatedOn(new Timestamp(c.getCreatedOn().getTime() - 5000));
+    db.changes().update(Collections.singleton(c));
+    indexer.index(db, project, id);
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "comment";
+
+    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
+    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
+    postReview.get().apply(revRsrc, rin, ts);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void commentPredatingChangeBySomeoneOtherThanOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+    Change c = db.changes().get(id);
+
+    ReviewInput rin = new ReviewInput();
+    rin.message = "comment";
+
+    Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
+    RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
+    setApiUser(user);
+    postReview.get().apply(revRsrc, rin, ts);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    String orig = r.getChange().change().getSubject();
+    r = pushFactory.create(
+            db, admin.getIdent(), testRepo, orig + " v2",
+            PushOneCommit.FILE_NAME, "new contents", r.getChangeId())
+        .to("refs/heads/master");
+    r.assertOkStatus();
+
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+    Change c = db.changes().get(id);
+
+    c.setCurrentPatchSet(psId, c.getSubject(), "Bogus original subject");
+    db.changes().update(Collections.singleton(c));
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    Change nc = notes.getChange();
+    assertThat(nc.getSubject()).isEqualTo(c.getSubject());
+    assertThat(nc.getSubject()).isEqualTo(orig + " v2");
+    assertThat(nc.getOriginalSubject()).isNotEqualTo(c.getOriginalSubject());
+    assertThat(nc.getOriginalSubject()).isEqualTo(orig);
+  }
+
+  @Test
+  public void deleteDraftPS1WithNoOtherEntities() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/drafts/master");
+    push = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId());
+    r = push.to("refs/drafts/master");
+    PatchSet.Id psId = r.getPatchSetId();
+    Change.Id id = psId.getParentKey();
+
+    gApi.changes().id(r.getChangeId()).revision(1).delete();
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getPatchSets().keySet()).containsExactly(psId);
+  }
+
+  @Test
+  public void ignorePatchLineCommentsOnPatchSet0() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change change = r.getChange().change();
+    Change.Id id = change.getId();
+
+    PatchLineComment comment = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME),
+            "uuid"),
+        0, user.getId(), null, TimeUtil.nowTs());
+    comment.setSide((short) 1);
+    comment.setMessage("message");
+    comment.setStatus(PatchLineComment.Status.PUBLISHED);
+    db.patchComments().insert(Collections.singleton(comment));
+    indexer.index(db, change.getProject(), id);
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getComments()).isEmpty();
+  }
+
+  @Test
+  public void skipPatchSetsGreaterThanCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change change = r.getChange().change();
+    Change.Id id = change.getId();
+
+    PatchSet badPs =
+        new PatchSet(new PatchSet.Id(id, change.currentPatchSetId().get() + 1));
+    badPs.setCreatedOn(TimeUtil.nowTs());
+    badPs.setUploader(new Account.Id(12345));
+    badPs.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    db.patchSets().insert(Collections.singleton(badPs));
+    indexer.index(db, change.getProject(), id);
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getPatchSets().keySet())
+        .containsExactly(change.currentPatchSetId());
+  }
+
+  @Test
+  public void leadingSpacesInSubject() throws Exception {
+    String subj = "   " + PushOneCommit.SUBJECT;
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        subj, PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    Change change = r.getChange().change();
+    assertThat(change.getSubject()).isEqualTo(subj);
+    Change.Id id = r.getPatchSetId().getParentKey();
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getSubject()).isNotEqualTo(subj);
+    assertThat(notes.getChange().getSubject()).isEqualTo(PushOneCommit.SUBJECT);
+  }
+
+  @Test
+  public void createWithAutoRebuildingDisabled() throws Exception {
+    ReviewDb oldDb = db;
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    ChangeNotes oldNotes = notesFactory.create(db, project, id);
+
+    // Make a ReviewDb change behind NoteDb's back.
+    Change c = oldDb.changes().get(id);
+    assertThat(c.getTopic()).isNull();
+    String topic = name("a-topic");
+    c.setTopic(topic);
+    oldDb.changes().update(Collections.singleton(c));
+
+    c = oldDb.changes().get(c.getId());
+    ChangeNotes newNotes =
+        notesFactory.createWithAutoRebuildingDisabled(c, null);
+    assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic);
+    assertThat(newNotes.getChange().getTopic())
+        .isEqualTo(oldNotes.getChange().getTopic());
+  }
+
+  @Test
+  public void rebuildDeletesOldDraftRefs() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment");
+
+    Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
+    String otherDraftRef = refsDraftComments(id, otherAccountId);
+
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8));
+      ins.flush();
+      RefUpdate ru = repo.updateRef(otherDraftRef);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(sha);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    checker.rebuildAndCheckChanges(id);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(otherDraftRef)).isNull();
+    }
+  }
+
+  @Test
+  public void failWhenWritesDisabled() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+
+    // Turning off writes causes failure.
+    setNotesMigration(false, true);
+    try {
+      gApi.changes().id(id.get()).topic(name("a-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // On next NoteDb read, change is rebuilt in-memory but not stored.
+    setNotesMigration(false, true);
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+
+    // Attempting to write directly causes failure.
+    try {
+      gApi.changes().id(id.get()).topic(name("other-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+  }
+
+  @Test
+  public void rebuildChangeWithNoPatchSets() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    db.changes().beginTransaction(id);
+    try {
+      db.patchSets().delete(db.patchSets().byChange(id));
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+
+    exception.expect(NoPatchSetsException.class);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  private void assertChangesReadOnly(RestApiException e) throws Exception {
+    Throwable cause = e.getCause();
+    assertThat(cause).isInstanceOf(UpdateException.class);
+    assertThat(cause.getCause()).isInstanceOf(OrmException.class);
+    assertThat(cause.getCause())
+        .hasMessage(NoteDbUpdateManager.CHANGES_READ_ONLY);
+  }
+
+  private void setInvalidNoteDbState(Change.Id id) throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    // In reality we would have NoteDb writes enabled, which would write a real
+    // state into this field. For tests however, we turn NoteDb writes off, so
+    // just use a dummy state to force ChangeNotes to view the notes as
+    // out-of-date.
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+  }
+
+  private void assertChangeUpToDate(boolean expected, Change.Id id)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Change c = getUnwrappedDb().changes().get(id);
+      assertThat(c).isNotNull();
+      assertThat(c.getNoteDbState()).isNotNull();
+      assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(
+              new RepoRefCache(repo)))
+          .isEqualTo(expected);
+    }
+  }
+
+  private void assertDraftsUpToDate(boolean expected, Change.Id changeId,
+      TestAccount account) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Change c = getUnwrappedDb().changes().get(changeId);
+      assertThat(c).isNotNull();
+      assertThat(c.getNoteDbState()).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state.areDraftsUpToDate(
+              new RepoRefCache(repo), account.getId()))
+          .isEqualTo(expected);
+    }
+  }
+
+  private ObjectId getMetaRef(Project.NameKey p, String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(p)) {
+      Ref ref = repo.exactRef(name);
+      return ref != null ? ref.getObjectId() : null;
+    }
+  }
+
+  private void putDraft(TestAccount account, Change.Id id, int line, String msg)
+      throws Exception {
+    DraftInput in = new DraftInput();
+    in.line = line;
+    in.message = msg;
+    in.path = PushOneCommit.FILE_NAME;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().createDraft(in);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private void putComment(TestAccount account, Change.Id id, int line, String msg)
+      throws Exception {
+    CommentInput in = new CommentInput();
+    in.line = line;
+    in.message = msg;
+    ReviewInput rin = new ReviewInput();
+    rin.comments = new HashMap<>();
+    rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in));
+    rin.drafts = ReviewInput.DraftHandling.KEEP;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().review(rin);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private void publishDrafts(TestAccount account, Change.Id id)
+      throws Exception {
+    ReviewInput rin = new ReviewInput();
+    rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS;
+    AcceptanceTestRequestScope.Context old = setApiUser(account);
+    try {
+      gApi.changes().id(id.get()).current().review(rin);
+    } finally {
+      atrScope.set(old);
+    }
+  }
+
+  private ChangeMessage insertMessage(Change.Id id, PatchSet.Id psId,
+      Account.Id author, Timestamp ts, String message) throws Exception {
+    ChangeMessage msg = new ChangeMessage(
+        new ChangeMessage.Key(id, ChangeUtil.messageUUID(db)),
+        author, ts, psId);
+    msg.setMessage(message);
+    db.changeMessages().insert(Collections.singleton(msg));
+
+    Change c = db.changes().get(id);
+    if (ts.compareTo(c.getLastUpdatedOn()) > 0) {
+      c.setLastUpdatedOn(ts);
+      db.changes().update(Collections.singleton(c));
+    }
+
+    return msg;
+  }
+
+  private ReviewDb getUnwrappedDb() {
+    ReviewDb db = dbProvider.get();
+    return ReviewDbUtil.unwrapDb(db);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK
index ad7d597..013115d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK
@@ -1,15 +1,7 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
-FLAKY_TEST_CASES=['ProjectWatchIT.java']
-
 acceptance_tests(
-  group = 'server-project',
-  srcs = glob(['*IT.java'], excludes=FLAKY_TEST_CASES),
+  group = 'server_project',
+  srcs = glob(['*IT.java']),
   labels = ['server'],
 )
-
-acceptance_tests(
-  group = 'server-project-flaky',
-  srcs = FLAKY_TEST_CASES,
-  labels = ['server', 'flaky'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
new file mode 100644
index 0000000..bcf9c9f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
@@ -0,0 +1,16 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+FLAKY_TEST_CASES=['ProjectWatchIT.java']
+
+acceptance_tests(
+  group = 'server_project',
+  srcs = glob(['*IT.java'], exclude=FLAKY_TEST_CASES),
+  labels = ['server'],
+)
+
+acceptance_tests(
+  group = 'server_project_flaky',
+  flaky = 1,
+  srcs = FLAKY_TEST_CASES,
+  labels = ['server', 'flaky'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 357f268..6f4cc45 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -28,17 +28,25 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
+import com.google.inject.Inject;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
 
+  @Inject
+  private DynamicSet<CommentAddedListener> source;
+
   private final LabelType label = category("CustomLabel",
       value(1, "Positive"),
       value(0, "No score"),
@@ -48,6 +56,9 @@
       value(1, "Positive"),
       value(0, "No score"));
 
+  private RegistrationHandle eventListenerRegistration;
+  private CommentAddedListener.Event lastCommentAddedEvent;
+
   @Before
   public void setUp() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
@@ -58,6 +69,19 @@
     Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers,
         "refs/heads/*");
     saveProjectConfig(project, cfg);
+
+    eventListenerRegistration = source.add(new CommentAddedListener() {
+      @Override
+      public void onCommentAdded(Event event) {
+        lastCommentAddedEvent = event;
+      }
+    });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+    db.close();
   }
 
   @Test
@@ -124,13 +148,18 @@
         .id(r.getChangeId())
         .addReviewer(in);
 
-    revision(r).review(new ReviewInput().label(P.getName(), 0));
+    ReviewInput input = new ReviewInput().label(P.getName(), 0);
+    input.message = "foo";
+
+    revision(r).review(input);
     ChangeInfo c = get(r.getChangeId());
     LabelInfo q = c.labels.get(P.getName());
     assertThat(q.all).hasSize(2);
     assertThat(q.disliked).isNull();
     assertThat(q.rejected).isNull();
     assertThat(q.blocking).isNull();
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
+        "Patch Set 1:\n\n" + input.message);
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java
deleted file mode 100644
index 363a7e4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java
+++ /dev/null
@@ -1,350 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.project;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class LabelTypeIT extends AbstractDaemonTest {
-  private LabelType codeReview;
-
-  @Before
-  public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    codeReview = Util.codeReview();
-    codeReview.setDefaultValue((short)-1);
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
-    saveProjectConfig(cfg);
-  }
-
-  @Test
-  public void noCopyMinScoreOnRework() throws Exception {
-    codeReview.setCopyMinScore(false);
-    saveLabelConfig();
-
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.reject());
-    assertApproval(r, -2);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, 0);
-  }
-
-  @Test
-  public void copyMinScoreOnRework() throws Exception {
-    codeReview.setCopyMinScore(true);
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.reject());
-    assertApproval(r, -2);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, -2);
-  }
-
-  @Test
-  public void noCopyMaxScoreOnRework() throws Exception {
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.approve());
-    assertApproval(r, 2);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, 0);
-  }
-
-  @Test
-  public void copyMaxScoreOnRework() throws Exception {
-    codeReview.setCopyMaxScore(true);
-    saveLabelConfig();
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.approve());
-    assertApproval(r, 2);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, 2);
-  }
-
-  @Test
-  public void noCopyNonMaxScoreOnRework() throws Exception {
-    codeReview.setCopyMinScore(true);
-    codeReview.setCopyMaxScore(true);
-    saveLabelConfig();
-
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.recommend());
-    assertApproval(r, 1);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, 0);
-  }
-
-  @Test
-  public void noCopyNonMinScoreOnRework() throws Exception {
-    codeReview.setCopyMinScore(true);
-    codeReview.setCopyMaxScore(true);
-    saveLabelConfig();
-
-    PushOneCommit.Result r = createChange();
-    revision(r).review(ReviewInput.dislike());
-    assertApproval(r, -1);
-    r = amendChange(r.getChangeId());
-    assertApproval(r, 0);
-  }
-
-  @Test
-  public void noCopyAllScoresIfNoChange() throws Exception {
-    codeReview.setCopyAllScoresIfNoChange(false);
-    saveLabelConfig();
-    PushOneCommit.Result patchSet = readyPatchSetForNoChangeRebase();
-    rebase(patchSet);
-    assertApproval(patchSet, 0);
-  }
-
-  @Test
-  public void copyAllScoresIfNoChange() throws Exception {
-    PushOneCommit.Result patchSet = readyPatchSetForNoChangeRebase();
-    rebase(patchSet);
-    assertApproval(patchSet, 1);
-  }
-
-  @Test
-  public void noCopyAllScoresIfNoCodeChange() throws Exception {
-    String file = "a.txt";
-    String contents = "contents";
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "first subject", file, contents);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    revision(r).review(ReviewInput.recommend());
-    assertApproval(r, 1);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "second subject", file, contents, r.getChangeId());
-    r = push.to("refs/for/master");
-    assertApproval(r, 0);
-  }
-
-  @Test
-  public void copyAllScoresIfNoCodeChange() throws Exception {
-    String file = "a.txt";
-    String contents = "contents";
-    codeReview.setCopyAllScoresIfNoCodeChange(true);
-    saveLabelConfig();
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "first subject", file, contents);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    revision(r).review(ReviewInput.recommend());
-    assertApproval(r, 1);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "second subject", file, contents, r.getChangeId());
-    r = push.to("refs/for/master");
-    assertApproval(r, 1);
-  }
-
-  @Test
-  public void noCopyAllScoresOnTrivialRebase() throws Exception {
-    String subject = "test commit";
-    String file = "a.txt";
-    String contents = "contents";
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    merge(r1);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "non-conflicting", "b.txt", "other contents");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    merge(r2);
-
-    testRepo.reset(r1.getCommit());
-    push = pushFactory.create(db, admin.getIdent(), testRepo, subject, file, contents);
-    PushOneCommit.Result r3 = push.to("refs/for/master");
-    revision(r3).review(ReviewInput.recommend());
-    assertApproval(r3, 1);
-
-    rebase(r3);
-    assertApproval(r3, 0);
-  }
-
-  @Test
-  public void copyAllScoresOnTrivialRebase() throws Exception {
-    String subject = "test commit";
-    String file = "a.txt";
-    String contents = "contents";
-    codeReview.setCopyAllScoresOnTrivialRebase(true);
-    saveLabelConfig();
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    PushOneCommit.Result r1 = push.to("refs/for/master");
-    merge(r1);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "non-conflicting", "b.txt", "other contents");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    merge(r2);
-
-    testRepo.reset(r1.getCommit());
-    push = pushFactory.create(db, admin.getIdent(), testRepo, subject, file, contents);
-    PushOneCommit.Result r3 = push.to("refs/for/master");
-    revision(r3).review(ReviewInput.recommend());
-    assertApproval(r3, 1);
-
-    rebase(r3);
-    assertApproval(r3, 1);
-  }
-
-  @Test
-  public void copyAllScoresOnTrivialRebaseAndCherryPick() throws Exception {
-    codeReview.setCopyAllScoresOnTrivialRebase(true);
-    saveLabelConfig();
-
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset(r1.getCommit());
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "other contents");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-
-    revision(r2).review(ReviewInput.recommend());
-
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s",
-        PushOneCommit.SUBJECT,
-        r2.getChangeId());
-
-    doAssertApproval(1,
-        gApi.changes()
-            .id(r2.getChangeId())
-            .revision(r2.getCommit().name())
-            .cherryPick(in)
-            .get());
-  }
-
-  @Test
-  public void copyNoScoresOnReworkAndCherryPick()
-      throws Exception {
-    codeReview.setCopyAllScoresOnTrivialRebase(true);
-    saveLabelConfig();
-
-    PushOneCommit.Result r1 = createChange();
-
-    testRepo.reset(r1.getCommit());
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "other contents");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-
-    revision(r2).review(ReviewInput.recommend());
-
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("Cherry pick\n\nChange-Id: %s",
-        r2.getChangeId());
-
-    doAssertApproval(0,
-        gApi.changes()
-            .id(r2.getChangeId())
-            .revision(r2.getCommit().name())
-            .cherryPick(in)
-            .get());
-  }
-
-  private PushOneCommit.Result readyPatchSetForNoChangeRebase()
-      throws Exception {
-    String file = "a.txt";
-    String contents = "contents";
-
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, file, contents);
-    PushOneCommit.Result base = push.to("refs/for/master");
-    merge(base);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, file, contents + "M");
-    PushOneCommit.Result basePlusM = push.to("refs/for/master");
-    merge(basePlusM);
-
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, file, contents);
-    PushOneCommit.Result basePlusMMinusM = push.to("refs/for/master");
-    merge(basePlusMMinusM);
-
-    testRepo.reset(base.getCommit());
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, file, contents + "MM");
-    PushOneCommit.Result patchSet = push.to("refs/for/master");
-    revision(patchSet).review(ReviewInput.recommend());
-    return patchSet;
-  }
-
-  private void saveLabelConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().clear();
-    cfg.getLabelSections().put(codeReview.getName(), codeReview);
-    saveProjectConfig(cfg);
-  }
-
-  private void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    MetaDataUpdate md = metaDataUpdateFactory.create(project);
-    try {
-      cfg.commit(md);
-    } finally {
-      md.close();
-    }
-  }
-
-  private void merge(PushOneCommit.Result r) throws Exception {
-    revision(r).review(ReviewInput.approve());
-    revision(r).submit();
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.getRef("refs/heads/master").getObjectId()).isEqualTo(
-          r.getCommitId());
-    }
-  }
-
-  private void rebase(PushOneCommit.Result r) throws Exception {
-    revision(r).rebase();
-  }
-
-  private void assertApproval(PushOneCommit.Result r, int expected)
-      throws Exception {
-    // Don't use asserts from PushOneCommit so we can test the round-trip
-    // through JSON instead of querying the DB directly.
-    ChangeInfo c = get(r.getChangeId());
-    doAssertApproval(expected, c);
-  }
-
-  private void doAssertApproval(int expected, ChangeInfo c) {
-    LabelInfo cr = c.labels.get("Code-Review");
-    assertThat((int) cr.defaultValue).isEqualTo(-1);
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
-    assertThat(cr.all.get(0).value).isEqualTo(expected);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 0b86ad9..0b56f43 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -16,85 +16,41 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.inject.Inject;
 
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
-import java.util.EnumSet;
-import java.util.List;
+import java.util.ArrayList;
 
 @NoHttpd
 public class ProjectWatchIT extends AbstractDaemonTest {
-  @Inject
-  private FakeEmailSender sender;
+  @Inject private WatchConfig.Accessor watchConfig;
 
-  /**
-   * Tests message project watches on new patch sets
-   * <p>
-   * As of 2015-06-21 this test is marked flaky for triggering race
-   * conditions between indexing and project watches filters as
-   * of 2015-06-21.
-   * <p>
-   * The test $SOMETIMES fails, stating that 2 emails instead of only
-   * 1 got sent. The root issue is the inserting of two patch sets
-   * (one shortly after the other), where the first patch set would
-   * not match a user's filter while the second one would.
-   * <p>
-   * The test basically:
-   * <ol>
-   *   <li>Sets up a watch on the text 'sekret' in the commit message.</li>
-   *   <li>Pushes a change without sekret in the commit message (no
-   *     email is expected). (We'll refer to this as PS1)</li>
-   *   <li>Push another patch set to the same change with sekret in the
-   *     commit message (1 email is expected). (We'll refer to this as PS2)</li>
-   *   <li>[...]</li>
-   * </ol>
-   * <p>The expected flow of actions for step 2+3 is:
-   * <pre>
-   *    (i) Write PS1 to the index
-   *   (ii) Send out emails for PS1 after checking project watches from
-   *        fresh ChangeData
-   *  (iii) Write PS2 to the index
-   *   (iv) Send out emails for PS2 after checking project watches from
-   *        fresh ChangeData
-   * </pre>
-   * <p>
-   * But as step (ii) and step (iv) happen on separate threads, steps
-   * (ii) and (iii) might get turned around and become:
-   * <pre>
-   *   * Write PS1 to the index
-   *   * Write PS2 to the index
-   *   * Send out emails for PS1 after checking project watches from
-   *     fresh ChangeData
-   *   * Send out emails for PS2 after checking project watches from
-   *     fresh ChangeData
-   * </pre>
-   * <p>
-   * Hence, the filters for project watches for the emails for PS1 query
-   * the index after PS2 has already been written there. Hence, the
-   * filters for PS1 use the commit message of PS2 when filtering on
-   * 'message:sekret'.
-   * <p>
-   * Since in the ProjectWatchIT test, PS2 contains 'sekret', the filters
-   * for sending out emails for PS1 see a commit message containing
-   * 'sekret', and the watches match for both PS1 and PS2, although they
-   * should only match for PS2.
-   * <p>
-   * This explains why the test is only failing sometimes, and also why it
-   * is more likely to occur when the system is under load.
-   * <p>
-   * A demo exposing the race condition is available at
-   * <a href="https://gerrit-review.googlesource.com/#/c/68719/1">https://gerrit-review.googlesource.com/#/c/68719/1</a>.
-   */
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
     Address addr = new Address("Watcher", "watcher@example.com");
@@ -132,5 +88,131 @@
     assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
   }
 
-  // TODO(anybody reading this): More tests.
+  @Test
+  public void watchProject() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a change to watched project -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+        .to("refs/for/master");
+    r.assertOkStatus();
+
+    // push a change to non-watched project -> should not trigger email
+    // notification
+    String notWatchedProject = createProject("otherProject").get();
+    TestRepository<InMemoryRepository> notWatchedRepo =
+        cloneProject(new Project.NameKey(notWatchedProject), admin);
+    r = pushFactory.create(db, admin.getIdent(), notWatchedRepo,
+        "DONT_TRIGGER", "a", "a1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchFile() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+    String otherWatchedProject = createProject("otherWatchedProject").get();
+    setApiUser(user);
+
+    // watch file in project as user
+    watch(watchedProject, "file:a.txt");
+
+    // watch other project as user
+    watch(otherWatchedProject, null);
+
+    // push a change to watched file -> should trigger email notification for
+    // user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
+        .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(watchedProject, null);
+
+    // push a change to non-watched file -> should not trigger email
+    // notification for user, only for user2
+    r = pushFactory.create(db, admin.getIdent(), watchedRepo,
+        "TRIGGER_USER2", "b.txt", "b1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  private void watch(String project, String filter)
+      throws RestApiException {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project;
+    pwi.filter = filter;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+  }
+
+  @Test
+  public void deleteAllProjectWatches() throws Exception {
+    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
+    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
+    watchConfig.upsertProjectWatches(admin.getId(), watches);
+    assertThat(watchConfig.getProjectWatches(admin.getId())).isNotEmpty();
+
+    watchConfig.deleteAllProjectWatches(admin.getId());
+    assertThat(watchConfig.getProjectWatches(admin.getId())).isEmpty();
+  }
+
+  @Test
+  public void deleteAllProjectWatchesIfWatchConfigIsTheOnlyFileInUserBranch() throws Exception {
+    // Create account that has no files in its refs/users/ branch.
+    Account.Id id = new Account.Id(db.nextAccountId());
+    Account a = new Account(id, TimeUtil.nowTs());
+    db.accounts().insert(Collections.singleton(a));
+
+    // Add a project watch so that a watch.config file in the refs/users/ branch is created.
+    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
+    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
+    watchConfig.upsertProjectWatches(id, watches);
+    assertThat(watchConfig.getProjectWatches(id)).isNotEmpty();
+
+    // Delete all project watches so that the watch.config file in the refs/users/ branch is
+    // deleted.
+    watchConfig.deleteAllProjectWatches(id);
+    assertThat(watchConfig.getProjectWatches(id)).isEmpty();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
index e07405f..56a56ee 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
@@ -66,10 +66,10 @@
     if (message != null) {
       command.append(" --message ").append(message);
     }
-    String response = sshSession.exec(command.toString());
+    String response = adminSshSession.exec(command.toString());
     assert_()
-      .withFailureMessage(sshSession.getError())
-      .that(sshSession.hasError())
+      .withFailureMessage(adminSshSession.getError())
+      .that(adminSshSession.hasError())
       .isFalse();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
   }
@@ -86,4 +86,4 @@
     }
     assertThat(actual).containsExactlyElementsIn(expected);
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
new file mode 100644
index 0000000..3c91aa1
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
@@ -0,0 +1,8 @@
+load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+
+acceptance_tests(
+  group = 'ssh',
+  srcs = glob(['*IT.java']),
+  deps = ['//lib/commons:compress'],
+  labels = ['ssh'],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
index 3bef84b..025fcfa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
@@ -17,12 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 import java.util.Locale;
@@ -37,13 +38,15 @@
         .create();
 
     String response =
-        sshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
-    assert_().withFailureMessage(sshSession.getError())
-        .that(sshSession.hasError()).isFalse();
+        adminSshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
+    assert_().withFailureMessage(adminSshSession.getError())
+        .that(adminSshSession.hasError()).isFalse();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
 
-    PushResult pushResult = pushHead(testRepo, "refs/heads/master", false);
-    assertThat(pushResult.getRemoteUpdate("refs/heads/master").getMessage())
-        .startsWith("contains banned commit");
+    RemoteRefUpdate u = pushHead(testRepo, "refs/heads/master", false)
+        .getRemoteUpdate("refs/heads/master");
+    assertThat(u).isNotNull();
+    assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
+    assertThat(u.getMessage()).startsWith("contains banned commit");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index a779136..85d460e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -28,12 +28,12 @@
   @Test
   public void withValidGroupName() throws Exception {
     String newGroupName = "newGroup";
-    adminSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName);
     String newProjectName = "newProject";
-    sshSession.exec("gerrit create-project --branch master --owner "
+    adminSshSession.exec("gerrit create-project --branch master --owner "
         + newGroupName + " " + newProjectName);
-    assert_().withFailureMessage(sshSession.getError())
-        .that(sshSession.hasError()).isFalse();
+    assert_().withFailureMessage(adminSshSession.getError())
+        .that(adminSshSession.hasError()).isFalse();
     ProjectState projectState =
         projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNotNull();
@@ -42,13 +42,13 @@
   @Test
   public void withInvalidGroupName() throws Exception {
     String newGroupName = "newGroup";
-    adminSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName);
     String wrongGroupName = "newG";
     String newProjectName = "newProject";
-    sshSession.exec("gerrit create-project --branch master --owner "
+    adminSshSession.exec("gerrit create-project --branch master --owner "
         + wrongGroupName + " " + newProjectName);
-    assert_().withFailureMessage(sshSession.getError())
-        .that(sshSession.hasError()).isTrue();
+    assert_().withFailureMessage(adminSshSession.getError())
+        .that(adminSshSession.hasError()).isTrue();
     ProjectState projectState =
         projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index b2c4df8..7176254 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.reviewdb.client.Project;
@@ -59,10 +58,10 @@
   @UseLocalDisk
   public void testGc() throws Exception {
     String response =
-        sshSession.exec("gerrit gc \"" + project.get() + "\" \""
+        adminSshSession.exec("gerrit gc \"" + project.get() + "\" \""
             + project2.get() + "\"");
-    assert_().withFailureMessage(sshSession.getError())
-        .that(sshSession.hasError()).isFalse();
+    assert_().withFailureMessage(adminSshSession.getError())
+        .that(adminSshSession.hasError()).isFalse();
     assertNoError(response);
     gcAssert.assertHasPackFile(project, project2);
     gcAssert.assertHasNoPackFile(allProjects, project3);
@@ -71,20 +70,21 @@
   @Test
   @UseLocalDisk
   public void testGcAll() throws Exception {
-    String response = sshSession.exec("gerrit gc --all");
-    assert_().withFailureMessage(sshSession.getError())
-        .that(sshSession.hasError()).isFalse();
+    String response = adminSshSession.exec("gerrit gc --all");
+    assert_().withFailureMessage(adminSshSession.getError())
+        .that(adminSshSession.hasError()).isFalse();
     assertNoError(response);
     gcAssert.assertHasPackFile(allProjects, project, project2, project3);
   }
 
   @Test
   public void testGcWithoutCapability_Error() throws Exception {
-    SshSession s = new SshSession(server, user);
-    s.exec("gerrit gc --all");
+    userSshSession.exec("gerrit gc --all");
+    assertThat(userSshSession.hasError()).isTrue();
+    String error = userSshSession.getError();
+    assertThat(error).isNotNull();
     assertError("One of the following capabilities is required to access this"
-        + " resource: [runGC, maintainServer]", s.getError());
-    s.close();
+        + " resource: [runGC, maintainServer]", error);
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/JschVerifyFalseBugIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/JschVerifyFalseBugIT.java
deleted file mode 100644
index 7fb99b6..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/JschVerifyFalseBugIT.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.ssh;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.reviewdb.client.Project;
-
-import org.junit.Ignore;
-import org.junit.Test;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-
-@NoHttpd
-// To see this test failing with 'verify: false', at least in the Jcsh 0.1.51
-// remove bouncycastle libs from the classpath, and run:
-// buck test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh:JschVerifyFalseBugIT
-public class JschVerifyFalseBugIT extends AbstractDaemonTest {
-
-  @Test
-  @Ignore// we know it works now, so let's not clone a project 500 times ;-)
-  public void test() throws Exception {
-    test(5);
-  }
-
-  private void test(int threads) throws InterruptedException,
-      ExecutionException {
-    Callable<Void> task = new Callable<Void>() {
-      @Override
-      public Void call() throws Exception {
-        for (int i = 1; i < 100; i++) {
-          String p = "p" + i;
-          createProject(p);
-          GitUtil.cloneProject(new Project.NameKey(p), sshSession);
-        }
-        return null;
-      }
-    };
-    List<Callable<Void>> nCopies = Collections.nCopies(threads, task);
-    List<Future<Void>> futures = Executors.newFixedThreadPool(threads)
-        .invokeAll(nCopies);
-    for (Future<Void> future : futures) {
-      future.get();
-    }
-    assertThat(futures).hasSize(threads);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
index 0e381c1..2865ff87 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -152,9 +152,9 @@
   public void shouldFailWithFilesWithoutPatchSetsOrCurrentPatchSetsOption()
       throws Exception {
     String changeId = createChange().getChangeId();
-    sshSession.exec("gerrit query --files " + changeId);
-    assertThat(sshSession.hasError()).isTrue();
-    assertThat(sshSession.getError()).contains(
+    adminSshSession.exec("gerrit query --files " + changeId);
+    assertThat(adminSshSession.hasError()).isTrue();
+    assertThat(adminSshSession.getError()).contains(
         "needs --patch-sets or --current-patch-set");
   }
 
@@ -334,7 +334,7 @@
 
   private List<ChangeAttribute> executeSuccessfulQuery(String params)
       throws Exception {
-    return executeSuccessfulQuery(params, sshSession);
+    return executeSuccessfulQuery(params, adminSshSession);
   }
 
   private static List<ChangeAttribute> getChanges(String rawResponse) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index 88821ce..32a0175 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
@@ -22,17 +23,18 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.testutil.NoteDbMode;
 
 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
 import org.eclipse.jgit.transport.PacketLineIn;
 import org.eclipse.jgit.transport.PacketLineOut;
 import org.eclipse.jgit.util.IO;
+import org.junit.Before;
 import org.junit.Test;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
-import java.io.IOException;
 import java.io.InputStream;
 import java.util.Set;
 import java.util.TreeSet;
@@ -40,6 +42,13 @@
 @NoHttpd
 public class UploadArchiveIT extends AbstractDaemonTest {
 
+  @Before
+  public void setUp() {
+    // There is some Guice request scoping problem preventing this test from
+    // passing in CHECK mode.
+    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
+  }
+
   @Test
   @GerritConfig(name = "download.archive", value = "off")
   public void archiveFeatureOff() throws Exception {
@@ -55,11 +64,11 @@
   @Test
   public void zipFormat() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommitId().abbreviate(8).name();
+    String abbreviated = r.getCommit().abbreviate(8).name();
     String c = command(r, abbreviated);
 
     InputStream out =
-        sshSession.exec2("git-upload-archive " + project.get(),
+        adminSshSession.exec2("git-upload-archive " + project.get(),
             argumentsToInputStream(c));
 
     // Wrap with PacketLineIn to read ACK bytes from output stream
@@ -98,11 +107,11 @@
 
   private void archiveNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
-    String abbreviated = r.getCommitId().abbreviate(8).name();
+    String abbreviated = r.getCommit().abbreviate(8).name();
     String c = command(r, abbreviated);
 
     InputStream out =
-        sshSession.exec2("git-upload-archive " + project.get(),
+        adminSshSession.exec2("git-upload-archive " + project.get(),
             argumentsToInputStream(c));
 
     // Wrap with PacketLineIn to read ACK bytes from output stream
@@ -115,7 +124,7 @@
     assertThat(tmp).isEqualTo("fatal: upload-archive not permitted");
   }
 
-  private InputStream argumentsToInputStream(String c) throws IOException {
+  private InputStream argumentsToInputStream(String c) throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     PacketLineOut pctOut = new PacketLineOut(out);
     for (String arg : Splitter.on(' ').split(c)) {
diff --git a/gerrit-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
new file mode 100644
index 0000000..ff2562d
--- /dev/null
+++ b/gerrit-acceptance-tests/tests.bzl
@@ -0,0 +1,28 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+BOUNCYCASTLE = [
+  '//lib/bouncycastle:bcpkix-without-neverlink',
+  '//lib/bouncycastle:bcpg-without-neverlink',
+]
+
+def acceptance_tests(
+    group,
+    srcs,
+    flaky = 0,
+    deps = [],
+    labels = [],
+    source_under_test = [], #unused
+    vm_args = ['-Xmx256m']):
+  junit_tests(
+    name = group,
+    srcs = srcs,
+    flaky = flaky,
+    deps = deps + BOUNCYCASTLE + [
+      '//gerrit-acceptance-tests:lib',
+    ],
+    tags = labels + [
+      'acceptance',
+      'slow',
+    ],
+    jvm_flags = vm_args,
+  )
diff --git a/gerrit-acceptance-tests/tests.defs b/gerrit-acceptance-tests/tests.defs
index 940b1cc..85cc78b 100644
--- a/gerrit-acceptance-tests/tests.defs
+++ b/gerrit-acceptance-tests/tests.defs
@@ -1,4 +1,3 @@
-# These are needed as workaround for the 'verify: false' bug in Jcraft SSH library
 BOUNCYCASTLE = [
   '//lib/bouncycastle:bcpkix',
   '//lib/bouncycastle:bcpg',
@@ -11,16 +10,16 @@
     labels = [],
     source_under_test = [],
     vm_args = ['-Xmx256m']):
-  from os import environ, path
-  if not environ.get('NO_BOUNCYCASTLE'):
-    deps = BOUNCYCASTLE + deps
+  from os import path
   if path.exists('/dev/urandom'):
     vm_args = vm_args + ['-Djava.security.egd=file:/dev/./urandom']
 
   java_test(
     name = group,
     srcs = srcs,
-    deps = ['//gerrit-acceptance-tests:lib'] + deps,
+    deps = deps + BOUNCYCASTLE + [
+      '//gerrit-acceptance-tests:lib'
+    ],
     source_under_test = [
       '//gerrit-httpd:httpd',
       '//gerrit-sshd:sshd',
diff --git a/gerrit-antlr/BUILD b/gerrit-antlr/BUILD
new file mode 100644
index 0000000..6c39106
--- /dev/null
+++ b/gerrit-antlr/BUILD
@@ -0,0 +1,32 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+java_library(
+    name = "query_exception",
+    srcs = ["src/main/java/com/google/gerrit/server/query/QueryParseException.java"],
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "query_antlr",
+    srcs = ["src/main/antlr3/com/google/gerrit/server/query/Query.g"],
+    out = "query_antlr.srcjar",
+    cmd = " && ".join([
+        "$(location //lib/antlr:antlr-tool) -o $$TMP $<",
+        "cd $$TMP",
+        "zip -q $$ROOT/$@ $$(find . -type f )",
+    ]),
+    tools = [
+        "//lib/antlr:antlr-tool",
+        "@bazel_tools//tools/zip:zipper",
+    ],
+)
+
+java_library(
+    name = "query_parser",
+    srcs = [":query_antlr"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":query_exception",
+        "//lib/antlr:java_runtime",
+    ],
+)
diff --git a/gerrit-cache-h2/BUCK b/gerrit-cache-h2/BUCK
index 37c8b96..0bc1cb12 100644
--- a/gerrit-cache-h2/BUCK
+++ b/gerrit-cache-h2/BUCK
@@ -8,7 +8,7 @@
     '//lib:guava',
     '//lib:h2',
     '//lib/guice:guice',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/log:api',
   ],
   visibility = ['PUBLIC'],
diff --git a/gerrit-cache-h2/BUILD b/gerrit-cache-h2/BUILD
new file mode 100644
index 0000000..a70393d
--- /dev/null
+++ b/gerrit-cache-h2/BUILD
@@ -0,0 +1,30 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+java_library(
+  name = 'cache-h2',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//lib:guava',
+    '//lib:h2',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/log:api',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':cache-h2',
+    '//gerrit-server:server',
+    '//lib:guava',
+    '//lib:h2',
+    '//lib/guice:guice',
+    '//lib:junit',
+  ],
+)
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index fcacf78..5009771 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -17,7 +17,6 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Lists;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -40,6 +39,7 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -49,7 +49,8 @@
 
 @Singleton
 class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
-  static final Logger log = LoggerFactory.getLogger(H2CacheFactory.class);
+  private static final Logger log =
+      LoggerFactory.getLogger(H2CacheFactory.class);
 
   private final DefaultCacheFactory defaultFactory;
   private final Config config;
@@ -58,6 +59,8 @@
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
   private final ScheduledExecutorService cleanup;
+  private final long h2CacheSize;
+  private final boolean h2AutoServer;
 
   @Inject
   H2CacheFactory(
@@ -68,7 +71,9 @@
     defaultFactory = defaultCacheFactory;
     config = cfg;
     cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
-    caches = Lists.newLinkedList();
+    h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
+    h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
+    caches = new LinkedList<>();
     this.cacheMap = cacheMap;
 
     if (cacheDir != null) {
@@ -220,8 +225,17 @@
       TypeLiteral<K> keyType,
       long maxSize,
       Long expireAfterWrite) {
-    String url = "jdbc:h2:" + cacheDir.resolve(name).toUri();
-    return new SqlStore<>(url, keyType, maxSize,
+    StringBuilder url = new StringBuilder();
+    url.append("jdbc:h2:").append(cacheDir.resolve(name).toUri());
+    if (h2CacheSize >= 0) {
+      url.append(";CACHE_SIZE=");
+      // H2 CACHE_SIZE is always given in KB
+      url.append(h2CacheSize / 1024);
+    }
+    if (h2AutoServer) {
+      url.append(";AUTO_SERVER=TRUE");
+    }
+    return new SqlStore<>(url.toString(), keyType, maxSize,
         expireAfterWrite == null ? 0 : expireAfterWrite.longValue());
   }
 }
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 289c8cc..838f42c 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.cache.h2;
 
+import com.google.common.base.Throwables;
 import com.google.common.cache.AbstractLoadingCache;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.CacheStats;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.hash.BloomFilter;
 import com.google.common.hash.Funnel;
 import com.google.common.hash.Funnels;
@@ -81,6 +83,9 @@
     PersistentCache {
   private static final Logger log = LoggerFactory.getLogger(H2CacheImpl.class);
 
+  private static final ImmutableSet<String> OLD_CLASS_NAMES = ImmutableSet.of(
+      "com.google.gerrit.server.change.ChangeKind");
+
   private final Executor executor;
   private final SqlStore<K, V> store;
   private final TypeLiteral<K> keyType;
@@ -472,7 +477,9 @@
           c.get.clearParameters();
         }
       } catch (SQLException e) {
-        log.warn("Cannot read cache " + url + " for " + key, e);
+        if (!isOldClassNameError(e)) {
+          log.warn("Cannot read cache " + url + " for " + key, e);
+        }
         c = close(c);
         return null;
       } finally {
@@ -480,6 +487,16 @@
       }
     }
 
+    private static boolean isOldClassNameError(Throwable t) {
+      for (Throwable c : Throwables.getCausalChain(t)) {
+        if (c instanceof ClassNotFoundException
+            && OLD_CLASS_NAMES.contains(c.getMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+
     private boolean expired(Timestamp created) {
       if (expireAfterWrite == 0) {
         return false;
@@ -490,7 +507,7 @@
 
     private void touch(SqlHandle c, K key) throws SQLException {
       if (c.touch == null) {
-        c.touch =c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=?");
+        c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=?");
       }
       try {
         c.touch.setTimestamp(1, TimeUtil.nowTs());
diff --git a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 3b7e436..c999d71 100644
--- a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -79,4 +79,4 @@
     }));
     assertFalse("did not invoke Callable", called.get());
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-common/BUCK b/gerrit-common/BUCK
index a36a9b7..847fd25 100644
--- a/gerrit-common/BUCK
+++ b/gerrit-common/BUCK
@@ -1,17 +1,11 @@
 SRC = 'src/main/java/com/google/gerrit/'
 
 ANNOTATIONS = [
-  SRC + 'common/Nullable.java',
-  SRC + 'common/audit/Audit.java',
-  SRC + 'common/auth/SignInRequired.java',
-]
-
-EXCLUDES = [
-  SRC + 'common/SiteLibraryLoaderUtil.java',
-  SRC + 'common/PluginData.java',
-  SRC + 'common/FileUtil.java',
-  SRC + 'common/IoUtil.java',
-  SRC + 'common/TimeUtil.java',
+  SRC + x for x in [
+    'common/Nullable.java',
+    'common/audit/Audit.java',
+    'common/auth/SignInRequired.java',
+  ]
 ]
 
 java_library(
@@ -22,13 +16,18 @@
 
 gwt_module(
   name = 'client',
-  srcs = glob([SRC + 'common/**/*.java'], excludes = EXCLUDES),
+  srcs = glob([SRC + 'common/**/*.java']),
   gwt_xml = SRC + 'Common.gwt.xml',
   exported_deps = [
-    '//gerrit-extension-api:client',
+    '//gerrit-extension-api:api',
     '//gerrit-prettify:client',
+    '//lib:guava',
     '//lib:gwtorm_client',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
   ],
+  provided_deps = ['//lib:servlet-api-3_1'],
   visibility = ['PUBLIC'],
 )
 
@@ -41,13 +40,14 @@
     '//gerrit-patch-jgit:server',
     '//gerrit-prettify:server',
     '//gerrit-reviewdb:server',
+    '//lib:guava',
     '//lib:gwtjsonrpc',
     '//lib:gwtorm',
-    '//lib:guava',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/joda:joda-time',
     '//lib/log:api',
   ],
+  provided_deps = ['//lib:servlet-api-3_1'],
   visibility = ['PUBLIC'],
 )
 
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
new file mode 100644
index 0000000..86ba087
--- /dev/null
+++ b/gerrit-common/BUILD
@@ -0,0 +1,77 @@
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRC = 'src/main/java/com/google/gerrit/'
+
+ANNOTATIONS = [
+  SRC + x for x in [
+    'common/Nullable.java',
+    'common/audit/Audit.java',
+    'common/auth/SignInRequired.java',
+  ]
+]
+
+java_library(
+  name = 'annotations',
+  srcs = ANNOTATIONS,
+  visibility = ['//visibility:public'],
+)
+
+gwt_module(
+  name = 'client',
+  srcs = glob([SRC + 'common/**/*.java']),
+  gwt_xml = SRC + 'Common.gwt.xml',
+  exported_deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-prettify:client',
+    '//lib:guava',
+    '//lib:gwtorm_client',
+    '//lib:servlet-api-3_1',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  srcs = glob([SRC + 'common/**/*.java'], exclude = ANNOTATIONS),
+  deps = [
+    ':annotations',
+    '//gerrit-extension-api:api',
+    '//gerrit-patch-jgit:server',
+    '//gerrit-prettify:server',
+    '//gerrit-reviewdb:server',
+    '//lib:guava',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm',
+    '//lib:servlet-api-3_1',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+TEST = 'src/test/java/com/google/gerrit/common/'
+AUTO_VALUE_TEST_SRCS = [TEST + 'AutoValueTest.java']
+
+junit_tests(
+  name = 'client_tests',
+  srcs = glob(['src/test/java/**/*.java'], exclude = AUTO_VALUE_TEST_SRCS),
+  deps = [
+    ':client',
+    '//lib:guava',
+    '//lib:junit',
+  ],
+)
+
+junit_tests(
+  name = 'auto_value_tests',
+  srcs = AUTO_VALUE_TEST_SRCS,
+  deps = [
+    '//lib:truth',
+    '//lib/auto:auto-value',
+  ],
+)
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
index ed28ec0..83dc4d8 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common;
 
+import com.google.common.annotations.GwtIncompatible;
+
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.IO;
@@ -25,6 +27,7 @@
 import java.nio.file.Path;
 import java.util.Arrays;
 
+@GwtIncompatible("Unemulated classes in java.io, java.nio and JGit")
 public class FileUtil {
   public static boolean modified(FileBasedConfig cfg) throws IOException {
     byte[] curVers;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
index 9bc2ea5..3422a78 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.Sets;
 
 import java.io.IOException;
@@ -29,6 +30,7 @@
 import java.util.Collections;
 import java.util.Set;
 
+@GwtIncompatible("Unemulated methods in Class and OutputStream")
 public final class IoUtil {
   public static void copyWithThread(final InputStream src,
       final OutputStream dst) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index 28e0d24..43d4441 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import com.google.gerrit.common.data.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
@@ -30,6 +29,7 @@
   public static final String SETTINGS_SSHKEYS = "/settings/ssh-keys";
   public static final String SETTINGS_GPGKEYS = "/settings/gpg-keys";
   public static final String SETTINGS_HTTP_PASSWORD = "/settings/http-password";
+  public static final String SETTINGS_OAUTH_TOKEN = "/settings/oauth-token";
   public static final String SETTINGS_WEBIDENT = "/settings/web-identities";
   public static final String SETTINGS_MYGROUPS = "/settings/group-memberships";
   public static final String SETTINGS_AGREEMENTS = "/settings/agreements";
@@ -49,10 +49,7 @@
   public static final String ADMIN_CREATE_PROJECT = "/admin/create-project/";
   public static final String ADMIN_PLUGINS = "/admin/plugins/";
   public static final String MY_GROUPS = "/groups/self";
-
-  public static String toChange(final ChangeInfo c) {
-    return toChange(c.getId());
-  }
+  public static final String DOCUMENTATION = "/Documentation/";
 
   public static String toChangeInEditMode(Change.Id c) {
     return "/c/" + c + ",edit/";
@@ -111,11 +108,6 @@
     return QUERY + KeyUtil.encode(query);
   }
 
-  public static String toChangeQuery(String query, String start) {
-    int s = Integer.parseInt(start);
-    return QUERY + KeyUtil.encode(query) + (s > 0 ? "," + s : "");
-  }
-
   public static String toProjectDashboard(Project.NameKey name, String id) {
     return PROJECTS + name.get() + DASHBOARDS + id;
   }
@@ -132,6 +124,20 @@
       return status(status) + " " + op("project", proj.get());
   }
 
+  public static String topicQuery(Status status, String topic) {
+    switch (status) {
+      case ABANDONED:
+        return toChangeQuery(status(status) + " " + op("topic", topic));
+      case DRAFT:
+      case MERGED:
+      case NEW:
+        return toChangeQuery(op("topic", topic) + " (" +
+            status(Status.NEW) + " OR " +
+            status(Status.MERGED) + ")");
+    }
+    return toChangeQuery(status(status) + " " + op("topic", topic));
+}
+
   public static String toGroup(AccountGroup.UUID uuid) {
     return ADMIN_GROUPS + "uuid-" + uuid;
   }
@@ -140,12 +146,17 @@
     return SETTINGS_EXTENSION + pluginName + "/" + path;
   }
 
+  public static String toDocumentationQuery(String query) {
+    return DOCUMENTATION + KeyUtil.encode(query);
+  }
+
   private static String status(Status status) {
     switch (status) {
       case ABANDONED:
         return "status:abandoned";
       case MERGED:
         return "status:merged";
+      case DRAFT:
       case NEW:
       default:
         return "status:open";
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java b/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
index 27dc639..4645158 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.common;
 
+import com.google.common.annotations.GwtIncompatible;
+
 import java.nio.file.Path;
 import java.util.Objects;
 
+@GwtIncompatible("Unemulated java.nio.file.Path")
 public class PluginData {
   public final String name;
   public final String version;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
new file mode 100644
index 0000000..edcd111
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.GwtIncompatible;
+import com.google.common.base.Preconditions;
+import com.google.gerrit.extensions.restapi.RawInput;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.servlet.http.HttpServletRequest;
+
+@GwtIncompatible("Unemulated classes in java.io and javax.servlet")
+public class RawInputUtil {
+  public static RawInput create(String content) {
+    return create(content.getBytes(UTF_8));
+  }
+
+  public static RawInput create(final byte[] bytes, final String contentType) {
+    Preconditions.checkNotNull(bytes);
+    Preconditions.checkArgument(bytes.length > 0);
+    return new RawInput() {
+      @Override
+      public InputStream getInputStream() throws IOException {
+        return new ByteArrayInputStream(bytes);
+      }
+
+      @Override
+      public String getContentType() {
+        return contentType;
+      }
+
+      @Override
+      public long getContentLength() {
+        return bytes.length;
+      }
+    };
+  }
+
+  public static RawInput create(final byte[] bytes) {
+    return create(bytes, "application/octet-stream");
+  }
+
+  public static RawInput create(final HttpServletRequest req) {
+    return new RawInput() {
+      @Override
+      public String getContentType() {
+        return req.getContentType();
+      }
+
+      @Override
+      public long getContentLength() {
+        return req.getContentLength();
+      }
+
+      @Override
+      public InputStream getInputStream() throws IOException {
+        return req.getInputStream();
+      }
+    };
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index 2511a51..bf87d7b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.common.FileUtil.lastModified;
 
+import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Ordering;
@@ -30,6 +31,7 @@
 import java.nio.file.Path;
 import java.util.List;
 
+@GwtIncompatible("Unemulated classes in java.nio and Guava")
 public final class SiteLibraryLoaderUtil {
   private static final Logger log =
       LoggerFactory.getLogger(SiteLibraryLoaderUtil.class);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
index 4274b5a..ec91a81 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.common;
 
+import com.google.common.annotations.GwtIncompatible;
+
 import org.joda.time.DateTimeUtils;
 
 import java.sql.Timestamp;
 
 /** Static utility methods for dealing with dates and times. */
+@GwtIncompatible("Unemulated org.joda.time.DateTimeUtils")
 public class TimeUtil {
   public static long nowMs() {
     return DateTimeUtils.currentTimeMillis();
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java b/gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
index 79c17f4..713fd4d 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/auth/openid/OpenIdUrls.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common.auth.openid;
 
 public class OpenIdUrls {
-  public static final String OPENID_IDENTIFIER = "openid_identifier";
   public static final String LASTID_COOKIE = "gerrit.last_openid";
 
   public static final String URL_LAUNCHPAD = "https://login.launchpad.net/+openid";
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
index f21f894..e8d1a3b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
@@ -70,9 +70,13 @@
       Permission p = new Permission(name);
       permissions.add(p);
       return p;
-    } else {
-      return null;
     }
+
+    return null;
+  }
+
+  public void addPermission(Permission p) {
+    getPermissions().add(p);
   }
 
   public void remove(Permission permission) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
index 8614be5..e8f9fd5 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
@@ -76,51 +76,4 @@
   public String getUsername() {
     return username;
   }
-
-  /**
-   * Formats an account name.
-   * <p>
-   * If the account has a full name, it returns only the full name. Otherwise it
-   * returns a longer form that includes the email address.
-   */
-  public String getName(String anonymousCowardName) {
-    if (getFullName() != null) {
-      return getFullName();
-    }
-    if (getPreferredEmail() != null) {
-      return getPreferredEmail();
-    }
-    return getNameEmail(anonymousCowardName);
-  }
-
-  /**
-   * Formats an account as a name and an email address.
-   * <p>
-   * Example output:
-   * <ul>
-   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
-   * <li>{@code A U. Thor (12)}: missing email address</li>
-   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
-   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
-   * </ul>
-   */
-  public String getNameEmail(String anonymousCowardName) {
-    String name = getFullName();
-    if (name == null) {
-      name = anonymousCowardName;
-    }
-
-    final StringBuilder b = new StringBuilder();
-    b.append(name);
-    if (getPreferredEmail() != null) {
-      b.append(" <");
-      b.append(getPreferredEmail());
-      b.append(">");
-    } else if (getId() != null) {
-      b.append(" (");
-      b.append(getId().get());
-      b.append(")");
-    }
-    return b.toString();
-  }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountProjectWatchInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountProjectWatchInfo.java
deleted file mode 100644
index 581a09b..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountProjectWatchInfo.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Project;
-
-public final class AccountProjectWatchInfo {
-  protected AccountProjectWatch watch;
-  protected Project project;
-
-  protected AccountProjectWatchInfo() {
-  }
-
-  public AccountProjectWatchInfo(final AccountProjectWatch w, final Project p) {
-    watch = w;
-    project = p;
-  }
-
-  public AccountProjectWatch getWatch() {
-    return watch;
-  }
-
-  public Project getProject() {
-    return project;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
index 533dfa2..22482c7 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
@@ -14,48 +14,14 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.common.audit.Audit;
 import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.RemoteJsonService;
 import com.google.gwtjsonrpc.common.RpcImpl;
 import com.google.gwtjsonrpc.common.RpcImpl.Version;
-import com.google.gwtjsonrpc.common.VoidResult;
-
-import java.util.List;
-import java.util.Set;
 
 @RpcImpl(version = Version.V2_0)
 public interface AccountService extends RemoteJsonService {
   @SignInRequired
-  void myAccount(AsyncCallback<Account> callback);
-
-  @Audit
-  @SignInRequired
-  void changeDiffPreferences(DiffPreferencesInfo diffPref,
-      AsyncCallback<VoidResult> callback);
-
-  @SignInRequired
-  void myProjectWatch(AsyncCallback<List<AccountProjectWatchInfo>> callback);
-
-  @Audit
-  @SignInRequired
-  void addProjectWatch(String projectName, String filter,
-      AsyncCallback<AccountProjectWatchInfo> callback);
-
-  @Audit
-  @SignInRequired
-  void updateProjectWatch(AccountProjectWatch watch,
-      AsyncCallback<VoidResult> callback);
-
-  @Audit
-  @SignInRequired
-  void deleteProjectWatches(Set<AccountProjectWatch.Key> keys,
-      AsyncCallback<VoidResult> callback);
-
-  @SignInRequired
   void myAgreements(AsyncCallback<AgreementInfo> callback);
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeDetailService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeDetailService.java
deleted file mode 100644
index 6d9c2cd..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeDetailService.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-public interface ChangeDetailService extends RemoteJsonService {
-  @Audit
-  void patchSetDetail(PatchSet.Id key, AsyncCallback<PatchSetDetail> callback);
-
-  @Audit
-  void patchSetDetail2(PatchSet.Id baseId, PatchSet.Id key,
-      DiffPreferencesInfo diffPrefs, AsyncCallback<PatchSetDetail> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeInfo.java
deleted file mode 100644
index d0f8cd3..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeInfo.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-
-import java.sql.Timestamp;
-
-public class ChangeInfo {
-  protected Change.Id id;
-  protected Change.Key key;
-  protected Account.Id owner;
-  protected String subject;
-  protected Change.Status status;
-  protected ProjectInfo project;
-  protected String branch;
-  protected String topic;
-  protected boolean starred;
-  protected Timestamp lastUpdatedOn;
-  protected PatchSet.Id patchSetId;
-  protected boolean latest;
-
-  public ChangeInfo() {
-  }
-
-  public ChangeInfo(final Change c, final PatchSet.Id patchId) {
-    set(c, patchId);
-  }
-
-  public void set(final Change c, final PatchSet.Id patchId) {
-    id = c.getId();
-    key = c.getKey();
-    owner = c.getOwner();
-    subject = c.getSubject();
-    status = c.getStatus();
-    project = new ProjectInfo(c.getProject());
-    branch = c.getDest().getShortName();
-    topic = c.getTopic();
-    lastUpdatedOn = c.getLastUpdatedOn();
-    patchSetId = patchId;
-    latest = patchSetId == null || patchSetId.equals(c.currentPatchSetId());
-  }
-
-  public ChangeInfo(final Change c) {
-    this(c, null);
-  }
-
-  public Change.Id getId() {
-    return id;
-  }
-
-  public Change.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getOwner() {
-    return owner;
-  }
-
-  public String getSubject() {
-    return subject;
-  }
-
-  public Change.Status getStatus() {
-    return status;
-  }
-
-  public ProjectInfo getProject() {
-    return project;
-  }
-
-  public String getBranch() {
-    return branch;
-  }
-
-  public String getTopic() {
-    return topic;
-  }
-
-  public boolean isStarred() {
-    return starred;
-  }
-
-  public void setStarred(final boolean s) {
-    starred = s;
-  }
-
-  public PatchSet.Id getPatchSetId() {
-    return patchSetId;
-  }
-
-  public boolean isLatest() {
-    return latest;
-  }
-
-  public Timestamp getLastUpdatedOn() {
-    return lastUpdatedOn;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
index a052f4f..0156b7d 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
@@ -39,7 +39,7 @@
   }
 
   public static class Error {
-    public static enum Type {
+    public enum Type {
       /** Git garbage collection was already scheduled for this project */
       GC_ALREADY_SCHEDULED,
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
index 7cdec2f..0ec7701 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
@@ -26,7 +26,6 @@
   private String rootTree;
 
   private char pathSeparator = '/';
-  private boolean linkDrafts = true;
   private boolean urlEncode = true;
 
   /** @return name displayed in links. */
@@ -141,20 +140,6 @@
     this.pathSeparator = separator;
   }
 
-  /** @return whether to generate links to draft patch sets. */
-  public boolean getLinkDrafts() {
-    return linkDrafts;
-  }
-
-  /**
-   * Set whether to generate links to draft patch sets.
-   *
-   * @param linkDrafts new value.
-   */
-  public void setLinkDrafts(boolean linkDrafts) {
-    this.linkDrafts = linkDrafts;
-  }
-
   /** @return whether to URL encode path segments. */
   public boolean getUrlEncode() {
     return urlEncode;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfoCache.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfoCache.java
deleted file mode 100644
index b700853..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfoCache.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-
-/** In-memory table of {@link GroupInfo}, indexed by {@code AccountGroup.Id}. */
-public class GroupInfoCache {
-  private static final GroupInfoCache EMPTY;
-  static {
-    EMPTY = new GroupInfoCache();
-    EMPTY.groups = Collections.emptyMap();
-  }
-
-  /** Obtain an empty cache singleton. */
-  public static GroupInfoCache empty() {
-    return EMPTY;
-  }
-
-  protected Map<AccountGroup.UUID, GroupInfo> groups;
-
-  protected GroupInfoCache() {
-  }
-
-  public GroupInfoCache(final Iterable<GroupInfo> list) {
-    groups = new HashMap<>();
-    for (final GroupInfo gi : list) {
-      groups.put(gi.getId(), gi);
-    }
-  }
-
-  /**
-   * Lookup the group summary
-   * <p>
-   * The return value can take on one of three forms:
-   * <ul>
-   * <li>{@code null}, if {@code id == null}.</li>
-   * <li>a valid info block, if {@code id} was loaded.</li>
-   * <li>an anonymous info block, if {@code id} was not loaded.</li>
-   * </ul>
-   *
-   * @param uuid the id desired.
-   * @return info block for the group.
-   */
-  public GroupInfo get(final AccountGroup.UUID uuid) {
-    if (uuid == null) {
-      return null;
-    }
-
-    GroupInfo r = groups.get(uuid);
-    if (r == null) {
-      r = new GroupInfo(uuid);
-      groups.put(uuid, r);
-    }
-    return r;
-  }
-
-  /** Merge the information from another cache into this one. */
-  public void merge(final GroupInfoCache other) {
-    assert this != EMPTY;
-    groups.putAll(other.groups);
-  }
-}
\ No newline at end of file
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
index 16e7e61..04dcec4 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
@@ -40,6 +40,7 @@
   public List<Message> messages;
   public Integer pluginsLoadTimeout;
   public boolean isNoteDbEnabled;
+  public boolean canLoadInIFrame;
 
   public static class Theme {
     public String backgroundColor;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/IncludedInDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/IncludedInDetail.java
deleted file mode 100644
index 9365db8..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/IncludedInDetail.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import java.util.Collections;
-import java.util.List;
-
-public class IncludedInDetail {
-  private List<String> branches;
-  private List<String> tags;
-
-  public IncludedInDetail() {
-  }
-
-  public void setBranches(final List<String> b) {
-    Collections.sort(b);
-    branches = b;
-  }
-
-  public List<String> getBranches() {
-    return branches;
-  }
-
-  public void setTags(final List<String> t) {
-    Collections.sort(t);
-    tags = t;
-  }
-
-  public List<String> getTags() {
-    return tags;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
index cf6f756..b1e1243 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
@@ -29,6 +29,7 @@
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
 
@@ -99,6 +100,7 @@
   protected String functionName;
   protected boolean copyMinScore;
   protected boolean copyMaxScore;
+  protected boolean copyAllScoresOnMergeFirstParentUpdate;
   protected boolean copyAllScoresOnTrivialRebase;
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
@@ -138,6 +140,8 @@
     setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
     setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
     setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setCopyAllScoresOnMergeFirstParentUpdate(
+        DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
   }
@@ -217,6 +221,16 @@
     this.copyMaxScore = copyMaxScore;
   }
 
+  public boolean isCopyAllScoresOnMergeFirstParentUpdate() {
+    return copyAllScoresOnMergeFirstParentUpdate;
+  }
+
+  public void setCopyAllScoresOnMergeFirstParentUpdate(
+      boolean copyAllScoresOnMergeFirstParentUpdate) {
+    this.copyAllScoresOnMergeFirstParentUpdate =
+        copyAllScoresOnMergeFirstParentUpdate;
+  }
+
   public boolean isCopyAllScoresOnTrivialRebase() {
     return copyAllScoresOnTrivialRebase;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchDetailService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchDetailService.java
deleted file mode 100644
index d9601f0..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchDetailService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-public interface PatchDetailService extends RemoteJsonService {
-  @Audit
-  void patchScript(Patch.Key key, PatchSet.Id a, PatchSet.Id b,
-      DiffPreferencesInfo diffPrefs, AsyncCallback<PatchScript> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
index f23afb1..5777396 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
@@ -27,38 +27,38 @@
 import java.util.List;
 
 public class PatchScript {
-  public static enum DisplayMethod {
+  public enum DisplayMethod {
     NONE, DIFF, IMG
   }
 
-  public static enum FileMode {
+  public enum FileMode {
     FILE, SYMLINK, GITLINK
   }
 
-  protected Change.Key changeId;
-  protected ChangeType changeType;
-  protected String oldName;
-  protected String newName;
-  protected FileMode oldMode;
-  protected FileMode newMode;
-  protected List<String> header;
-  protected DiffPreferencesInfo diffPrefs;
-  protected SparseFileContent a;
-  protected SparseFileContent b;
-  protected List<Edit> edits;
-  protected DisplayMethod displayMethodA;
-  protected DisplayMethod displayMethodB;
-  protected transient String mimeTypeA;
-  protected transient String mimeTypeB;
-  protected CommentDetail comments;
-  protected List<Patch> history;
-  protected boolean hugeFile;
-  protected boolean intralineDifference;
-  protected boolean intralineFailure;
-  protected boolean intralineTimeout;
-  protected boolean binary;
-  protected transient String commitIdA;
-  protected transient String commitIdB;
+  private Change.Key changeId;
+  private ChangeType changeType;
+  private String oldName;
+  private String newName;
+  private FileMode oldMode;
+  private FileMode newMode;
+  private List<String> header;
+  private DiffPreferencesInfo diffPrefs;
+  private SparseFileContent a;
+  private SparseFileContent b;
+  private List<Edit> edits;
+  private DisplayMethod displayMethodA;
+  private DisplayMethod displayMethodB;
+  private transient String mimeTypeA;
+  private transient String mimeTypeB;
+  private CommentDetail comments;
+  private List<Patch> history;
+  private boolean hugeFile;
+  private boolean intralineDifference;
+  private boolean intralineFailure;
+  private boolean intralineTimeout;
+  private boolean binary;
+  private transient String commitIdA;
+  private transient String commitIdB;
 
   public PatchScript(final Change.Key ck, final ChangeType ct, final String on,
       final String nn, final FileMode om, final FileMode nm,
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java
deleted file mode 100644
index 39f5cb0..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-
-import java.util.List;
-
-public class PatchSetDetail {
-  protected PatchSet patchSet;
-  protected PatchSetInfo info;
-  protected List<Patch> patches;
-  protected Project.NameKey project;
-
-  public PatchSetDetail() {
-  }
-
-  public PatchSet getPatchSet() {
-    return patchSet;
-  }
-
-  public void setPatchSet(final PatchSet ps) {
-    patchSet = ps;
-  }
-
-  public PatchSetInfo getInfo() {
-    return info;
-  }
-
-  public void setInfo(final PatchSetInfo i) {
-    info = i;
-  }
-
-  public List<Patch> getPatches() {
-    return patches;
-  }
-
-  public void setPatches(final List<Patch> p) {
-    patches = p;
-  }
-
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  public void setProject(final Project.NameKey p) {
-    project = p;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index a041dc6..97f11b4 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -22,6 +22,7 @@
 /** A single permission within an {@link AccessSection} of a project. */
 public class Permission implements Comparable<Permission> {
   public static final String ABANDON = "abandon";
+  public static final String ADD_PATCH_SET = "addPatchSet";
   public static final String CREATE = "create";
   public static final String DELETE_DRAFTS = "deleteDrafts";
   public static final String EDIT_HASHTAGS = "editHashtags";
@@ -53,6 +54,7 @@
     NAMES_LC.add(OWNER.toLowerCase());
     NAMES_LC.add(READ.toLowerCase());
     NAMES_LC.add(ABANDON.toLowerCase());
+    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
     NAMES_LC.add(CREATE.toLowerCase());
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
     NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
@@ -201,9 +203,8 @@
       PermissionRule r = new PermissionRule(group);
       rules.add(r);
       return r;
-    } else {
-      return null;
     }
+    return null;
   }
 
   void mergeFrom(Permission src) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
index ec5ca06..6511d69 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
@@ -17,7 +17,7 @@
 public class PermissionRule implements Comparable<PermissionRule> {
   public static final String FORCE_PUSH = "Force Push";
   public static final String FORCE_EDIT = "Force Edit";
-  public static enum Action {
+  public enum Action {
     ALLOW, DENY, BLOCK,
 
     INTERACTIVE, BATCH
@@ -139,6 +139,10 @@
     switch (a.getAction()) {
       case DENY:
         return 0;
+      case ALLOW:
+      case BATCH:
+      case BLOCK:
+      case INTERACTIVE:
       default:
         return 1 + a.getAction().ordinal();
     }
@@ -260,6 +264,11 @@
     return rule;
   }
 
+  public boolean hasRange() {
+    return (!(getMin() == null || getMin() == 0))
+      || (!(getMax() == null || getMax() == 0));
+  }
+
   public static int parseInt(String value) {
     if (value.startsWith("+")) {
       value = value.substring(1);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectInfo.java
deleted file mode 100644
index dfef806..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectInfo.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Project;
-
-public class ProjectInfo {
-  protected Project.NameKey key;
-
-  protected ProjectInfo() {
-  }
-
-  public ProjectInfo(final Project.NameKey key) {
-    this.key = key;
-  }
-
-  public Project.NameKey getKey() {
-    return key;
-  }
-
-  public String getName() {
-    return key.get();
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ReviewResult.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ReviewResult.java
deleted file mode 100644
index 76785d8..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ReviewResult.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Change;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Result from performing a review (comment, abandon, etc.)
- */
-public class ReviewResult {
-  protected List<Error> errors;
-  protected Change.Id changeId;
-
-  public ReviewResult() {
-    errors = new ArrayList<>();
-  }
-
-  public void addError(final Error e) {
-    errors.add(e);
-  }
-
-  public List<Error> getErrors() {
-    return errors;
-  }
-
-  public Change.Id getChangeId() {
-    return changeId;
-  }
-
-  public void setChangeId(Change.Id changeId) {
-    this.changeId = changeId;
-  }
-
-  public static class Error {
-    public static enum Type {
-      /** Not permitted to abandon this change. */
-      ABANDON_NOT_PERMITTED,
-
-      /** Not permitted to restore this change. */
-      RESTORE_NOT_PERMITTED,
-
-      /** Not permitted to submit this change. */
-      SUBMIT_NOT_PERMITTED,
-
-      /** Approvals or dependencies are lacking for submission. */
-      SUBMIT_NOT_READY,
-
-      /** Review operation invalid because change is closed. */
-      CHANGE_IS_CLOSED,
-
-      /** Review operation invalid because change is not abandoned. */
-      CHANGE_NOT_ABANDONED,
-
-      /** Not permitted to publish this draft patch set */
-      PUBLISH_NOT_PERMITTED,
-
-      /** Not permitted to delete this draft patch set */
-      DELETE_NOT_PERMITTED,
-
-      /** Review operation not permitted by rule. */
-      RULE_ERROR,
-
-      /** Review operation invalid because patch set is not a draft. */
-      NOT_A_DRAFT,
-
-      /** Error writing change to git repository */
-      GIT_ERROR,
-
-      /** The destination branch does not exist */
-      DEST_BRANCH_NOT_FOUND,
-
-      /** Not permitted to edit the topic name */
-      EDIT_TOPIC_NAME_NOT_PERMITTED,
-
-      /** Not permitted to edit the hashtags */
-      EDIT_HASHTAGS_NOT_PERMITTED
-    }
-
-    protected Type type;
-    protected String message;
-
-    protected Error() {
-    }
-
-    public Error(final Type type) {
-      this.type = type;
-      this.message = null;
-    }
-
-    public Error(final Type type, final String message) {
-      this.type = type;
-      this.message = message;
-    }
-
-    public Type getType() {
-      return type;
-    }
-
-    public String getMessage() {
-      return message;
-    }
-
-    public String getMessageOrType() {
-      if (message != null) {
-        return message;
-      }
-      return "" + type;
-    }
-
-    @Override
-    public String toString() {
-      String ret = type + "";
-      if (message != null) {
-        ret += " " + message;
-      }
-      return ret;
-    }
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
index f4fe963..9dccf0c 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -23,7 +23,7 @@
  * Describes the state required to submit a change.
  */
 public class SubmitRecord {
-  public static enum Status {
+  public enum Status {
     /** The change is ready for submission. */
     OK,
 
@@ -33,6 +33,9 @@
     /** The change has been closed. */
     CLOSED,
 
+    /** The change was submitted bypassing submit rules. */
+    FORCED,
+
     /**
      * An internal server error occurred preventing computation.
      * <p>
@@ -46,7 +49,7 @@
   public String errorMessage;
 
   public static class Label {
-    public static enum Status {
+    public enum Status {
       /**
        * This label provides what is necessary for submission.
        * <p>
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
index 5a79d08..b6ce797 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
@@ -20,7 +20,7 @@
  * Describes the submit type for a change.
  */
 public class SubmitTypeRecord {
-  public static enum Status {
+  public enum Status {
     /** The type was computed successfully */
     OK,
 
@@ -32,15 +32,33 @@
   }
 
   public static SubmitTypeRecord OK(SubmitType type) {
-    SubmitTypeRecord r = new SubmitTypeRecord();
-    r.status = Status.OK;
-    r.type = type;
-    return r;
+    return new SubmitTypeRecord(Status.OK, type, null);
   }
 
-  public Status status;
-  public SubmitType type;
-  public String errorMessage;
+  public static SubmitTypeRecord error(String err) {
+    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
+  }
+
+  /** Status enum value of the record. */
+  public final Status status;
+
+  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
+  public final SubmitType type;
+
+  /**
+   * Submit type of the record; always null if {@link #status} is {@code OK}.
+   */
+  public final String errorMessage;
+
+  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
+    this.status = status;
+    this.type = type;
+    this.errorMessage = errorMessage;
+  }
+
+  public boolean isOk() {
+    return status == Status.OK;
+  }
 
   @Override
   public String toString() {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
new file mode 100644
index 0000000..3fdc331
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.common.annotations.GwtIncompatible;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+
+import org.eclipse.jgit.transport.RefSpec;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/** Portion of a {@link Project} describing superproject subscription rules. */
+@GwtIncompatible("Unemulated org.eclipse.jgit.transport.RefSpec")
+public class SubscribeSection {
+
+  private final List<RefSpec> multiMatchRefSpecs;
+  private final List<RefSpec> matchingRefSpecs;
+  private final Project.NameKey project;
+
+  public SubscribeSection(Project.NameKey p) {
+    project = p;
+    matchingRefSpecs = new ArrayList<>();
+    multiMatchRefSpecs = new ArrayList<>();
+  }
+
+  public void addMatchingRefSpec(RefSpec spec) {
+    matchingRefSpecs.add(spec);
+  }
+
+  public void addMatchingRefSpec(String spec) {
+    RefSpec r = new RefSpec(spec);
+    matchingRefSpecs.add(r);
+  }
+
+  public void addMultiMatchRefSpec(String spec) {
+    RefSpec r = new RefSpec(spec, RefSpec.WildcardMode.ALLOW_MISMATCH);
+    multiMatchRefSpecs.add(r);
+  }
+
+  public Project.NameKey getProject() {
+    return project;
+  }
+
+  /**
+   * Determines if the <code>branch</code> could trigger a
+   * superproject update as allowed via this subscribe section.
+   *
+   * @param branch the branch to check
+   * @return if the branch could trigger a superproject update
+   */
+  public boolean appliesTo(Branch.NameKey branch) {
+    for (RefSpec r : matchingRefSpecs) {
+      if (r.matchSource(branch.get())) {
+        return true;
+      }
+    }
+    for (RefSpec r : multiMatchRefSpecs) {
+      if (r.matchSource(branch.get())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public Collection<RefSpec> getMatchingRefSpecs() {
+    return Collections.unmodifiableCollection(matchingRefSpecs);
+  }
+
+  public Collection<RefSpec> getMultiMatchRefSpecs() {
+    return Collections.unmodifiableCollection(multiMatchRefSpecs);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder ret = new StringBuilder();
+    ret.append("[SubscribeSection, project=");
+    ret.append(project);
+    if (!matchingRefSpecs.isEmpty()) {
+      ret.append(", matching=[");
+      for (RefSpec r : matchingRefSpecs) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    if (!multiMatchRefSpecs.isEmpty()) {
+      ret.append(", all=[");
+      for (RefSpec r : multiMatchRefSpecs) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    ret.append("]");
+    return ret.toString();
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java
deleted file mode 100644
index 7b25a23..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-import java.util.List;
-
-@RpcImpl(version = Version.V2_0)
-public interface SuggestService extends RemoteJsonService {
-  void suggestAccountGroupForProject(Project.NameKey project, String query,
-      int limit, AsyncCallback<List<GroupReference>> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/CorruptEntityException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/CorruptEntityException.java
deleted file mode 100644
index f2b3e98..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/CorruptEntityException.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-import com.google.gwtorm.client.Key;
-
-/** Error indicating the entity's database records are invalid. */
-public class CorruptEntityException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE_PREFIX = "Corrupt Database Entity: ";
-
-  public CorruptEntityException(final Key<?> key) {
-    super(MESSAGE_PREFIX + key);
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InactiveAccountException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InactiveAccountException.java
deleted file mode 100644
index 6ae5eb6..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InactiveAccountException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the account is currently inactive. */
-public class InactiveAccountException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Account Inactive: ";
-
-  public InactiveAccountException(String who) {
-    super(MESSAGE + who);
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java
index 53792e4..4a66a416a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidQueryException.java
@@ -18,10 +18,6 @@
 public class InvalidQueryException extends Exception {
   private static final long serialVersionUID = 1L;
 
-  public InvalidQueryException(String message) {
-    super(message);
-  }
-
   public InvalidQueryException(String message, String query) {
     super("Invalid query: " + query + "\n\n" + message);
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidRevisionException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidRevisionException.java
deleted file mode 100644
index b4b54c1..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidRevisionException.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-/** Error indicating the revision is invalid as supplied. */
-public class InvalidRevisionException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Invalid Revision";
-
-  public InvalidRevisionException() {
-    super(MESSAGE);
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidUserNameException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidUserNameException.java
deleted file mode 100644
index f1c35a8..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/InvalidUserNameException.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.errors;
-
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Error indicating the SSH user name does not match {@link Account#USER_NAME_PATTERN} pattern. */
-public class InvalidUserNameException extends Exception {
-
-  private static final long serialVersionUID = 1L;
-
-  public static final String MESSAGE = "Invalid user name.";
-
-  public InvalidUserNameException() {
-    super(MESSAGE);
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java
index ade0d98..8b740c3 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/errors/NoSuchGroupException.java
@@ -38,10 +38,6 @@
     super(MESSAGE + key.toString(), why);
   }
 
-  public NoSuchGroupException(final AccountGroup.NameKey k) {
-    this(k, null);
-  }
-
   public NoSuchGroupException(final AccountGroup.NameKey k, final Throwable why) {
     super(MESSAGE + k.toString(), why);
   }
diff --git a/gerrit-extension-api/BUCK b/gerrit-extension-api/BUCK
index 67bfdc1..61cd406 100644
--- a/gerrit-extension-api/BUCK
+++ b/gerrit-extension-api/BUCK
@@ -1,3 +1,6 @@
+include_defs('//lib/JGIT_VERSION')
+include_defs('//lib/GUAVA_VERSION')
+
 SRC = 'src/main/java/com/google/gerrit/extensions/'
 SRCS = glob([SRC + '**/*.java'])
 
@@ -27,6 +30,7 @@
   name = 'lib',
   exported_deps = [
     ':api',
+    '//lib:guava',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
@@ -38,7 +42,11 @@
 java_library(
   name = 'api',
   srcs = glob([SRC + '**/*.java']),
+  deps = [
+    '//gerrit-common:annotations',
+  ],
   provided_deps = [
+    '//lib:guava',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
   ],
@@ -69,9 +77,13 @@
   paths = ['src/main/java'],
   srcs = SRCS,
   deps = [
+    '//lib:guava',
     '//lib/guice:javax-inject',
     '//lib/guice:guice_library',
     '//lib/guice:guice-assistedinject',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//gerrit-common:annotations',
   ],
   visibility = ['PUBLIC'],
+  external_docs = [JGIT_DOC_URL, GUAVA_DOC_URL],
 )
diff --git a/gerrit-extension-api/BUILD b/gerrit-extension-api/BUILD
new file mode 100644
index 0000000..4a5cfe3
--- /dev/null
+++ b/gerrit-extension-api/BUILD
@@ -0,0 +1,46 @@
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+
+SRC = 'src/main/java/com/google/gerrit/extensions/'
+SRCS = glob([SRC + '**/*.java'])
+
+EXT_API_SRCS = glob([SRC + 'client/*.java'])
+
+gwt_module(
+  name = 'client',
+  srcs = EXT_API_SRCS,
+  gwt_xml = SRC + 'Extensions.gwt.xml',
+  visibility = ['//visibility:public'],
+)
+
+java_binary(
+  name = 'extension-api',
+  main_class = 'Dummy',
+  runtime_deps = [':lib'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lib',
+  exports = [
+    ':api',
+    '//lib:guava',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+    '//lib:servlet-api-3_1',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+#TODO(davido): There is no provided_deps argument to java_library rule
+java_library(
+  name = 'api',
+  srcs = glob([SRC + '**/*.java']),
+  deps = [
+    '//gerrit-common:annotations',
+    '//lib:guava',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 8b54e2b..144031b 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -23,15 +23,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
index 8b9f520..c2e3787 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
@@ -22,17 +22,17 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 
 public interface GerritApi {
-  public Accounts accounts();
-  public Changes changes();
-  public Config config();
-  public Groups groups();
-  public Projects projects();
+  Accounts accounts();
+  Changes changes();
+  Config config();
+  Groups groups();
+  Projects projects();
 
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements GerritApi {
+  class NotImplemented implements GerritApi {
     @Override
     public Accounts accounts() {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
new file mode 100644
index 0000000..7c47ec5
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.access;
+
+import java.util.Map;
+import java.util.Objects;
+
+public class AccessSectionInfo {
+
+  public Map<String, PermissionInfo> permissions;
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof AccessSectionInfo) {
+      return Objects.equals(permissions, ((AccessSectionInfo) obj).permissions);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(permissions);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
new file mode 100644
index 0000000..c4808a5
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.access;
+
+import java.util.Map;
+import java.util.Objects;
+
+public class PermissionInfo {
+  public String label;
+  public Boolean exclusive;
+  public Map<String, PermissionRuleInfo> rules;
+
+  public PermissionInfo(String label, Boolean exclusive) {
+    this.label = label;
+    this.exclusive = exclusive;
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof PermissionInfo) {
+      PermissionInfo p = (PermissionInfo) obj;
+      return Objects.equals(label, p.label)
+          && Objects.equals(exclusive, p.exclusive)
+          && Objects.equals(rules, p.rules);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(label, exclusive, rules);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
new file mode 100644
index 0000000..f979039
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.access;
+
+import java.util.Objects;
+
+public class PermissionRuleInfo {
+  public enum Action {
+    ALLOW,
+    DENY,
+    BLOCK,
+    INTERACTIVE,
+    BATCH
+  }
+
+  public Action action;
+  public Boolean force;
+  public Integer min;
+  public Integer max;
+
+  public PermissionRuleInfo(Action action, Boolean force) {
+    this.action = action;
+    this.force = force;
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof PermissionRuleInfo) {
+      PermissionRuleInfo p = (PermissionRuleInfo) obj;
+      return Objects.equals(action, p.action)
+          && Objects.equals(force, p.force)
+          && Objects.equals(min, p.min)
+          && Objects.equals(max, p.max);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(action, force, min, max);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
new file mode 100644
index 0000000..3274313a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.access;
+
+import com.google.gerrit.extensions.common.ProjectInfo;
+
+import java.util.Map;
+import java.util.Set;
+
+public class ProjectAccessInfo {
+  public String revision;
+  public ProjectInfo inheritsFrom;
+  public Map<String, AccessSectionInfo> local;
+  public Boolean isOwner;
+  public Set<String> ownerOf;
+  public Boolean canUpload;
+  public Boolean canAdd;
+  public Boolean configVisible;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
new file mode 100644
index 0000000..39a5209
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.access;
+
+import java.util.Map;
+
+public class ProjectAccessInput {
+  public Map<String, AccessSectionInfo> remove;
+  public Map<String, AccessSectionInfo> add;
+  public String parent;
+  public String message;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index a356ab6..8f7f93c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -14,43 +14,157 @@
 
 package com.google.gerrit.extensions.api.accounts;
 
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 import java.util.List;
 import java.util.Map;
+import java.util.SortedSet;
 
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
 
-  void starChange(String id) throws RestApiException;
-  void unstarChange(String id) throws RestApiException;
+  String getAvatarUrl(int size) throws RestApiException;
+
+  GeneralPreferencesInfo getPreferences() throws RestApiException;
+  GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in)
+      throws RestApiException;
+
+  DiffPreferencesInfo getDiffPreferences() throws RestApiException;
+  DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
+      throws RestApiException;
+
+  EditPreferencesInfo getEditPreferences() throws RestApiException;
+  EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
+      throws RestApiException;
+
+  List<ProjectWatchInfo> getWatchedProjects() throws RestApiException;
+  List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
+      throws RestApiException;
+  void deleteWatchedProjects(List<ProjectWatchInfo> in)
+      throws RestApiException;
+
+  void starChange(String changeId) throws RestApiException;
+  void unstarChange(String changeId) throws RestApiException;
+  void setStars(String changeId, StarsInput input) throws RestApiException;
+  SortedSet<String> getStars(String changeId) throws RestApiException;
+  List<ChangeInfo> getStarredChanges() throws RestApiException;
+
   void addEmail(EmailInput input) throws RestApiException;
 
+  List<SshKeyInfo> listSshKeys() throws RestApiException;
+  SshKeyInfo addSshKey(String key) throws RestApiException;
+  void deleteSshKey(int seq) throws RestApiException;
+
   Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException;
   Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove)
       throws RestApiException;
   GpgKeyApi gpgKey(String id) throws RestApiException;
 
+  List<AgreementInfo> listAgreements() throws RestApiException;
+  void signAgreement(String agreementName) throws RestApiException;
+
+  void index() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements AccountApi {
+  class NotImplemented implements AccountApi {
     @Override
     public AccountInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void starChange(String id) throws RestApiException {
+    public String getAvatarUrl(int size) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void unstarChange(String id) throws RestApiException {
+    public GeneralPreferencesInfo getPreferences() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public EditPreferencesInfo getEditPreferences() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectWatchInfo> getWatchedProjects()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectWatchInfo> setWatchedProjects(
+        List<ProjectWatchInfo> in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteWatchedProjects(List<ProjectWatchInfo> in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void starChange(String changeId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void unstarChange(String changeId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setStars(String changeId, StarsInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SortedSet<String> getStars(String changeId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ChangeInfo> getStarredChanges() throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -60,6 +174,21 @@
     }
 
     @Override
+    public List<SshKeyInfo> listSshKeys() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SshKeyInfo addSshKey(String key) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteSshKey(int seq) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, GpgKeyInfo> putGpgKeys(List<String> add,
         List<String> remove) throws RestApiException {
       throw new NotImplementedException();
@@ -74,5 +203,20 @@
     public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<AgreementInfo> listAgreements() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void signAgreement(String agreementName) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void index() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
new file mode 100644
index 0000000..33baf93
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.accounts;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+import java.util.List;
+
+public class AccountInput {
+  @DefaultInput
+  public String username;
+  public String name;
+  public String email;
+  public String sshKey;
+  public String httpPassword;
+  public List<String> groups;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index 32f8488..a697091 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.api.accounts;
 
+import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
+import java.util.Arrays;
+import java.util.EnumSet;
 import java.util.List;
 
 public interface Accounts {
@@ -38,12 +41,23 @@
   AccountApi id(String id) throws RestApiException;
 
   /**
+   * @see #id(String)
+   */
+  AccountApi id(int id) throws RestApiException;
+
+  /**
    * Look up the account of the current in-scope user.
    *
    * @see #id(String)
    */
   AccountApi self() throws RestApiException;
 
+  /** Create a new account with the given username and default options. */
+  AccountApi create(String username) throws RestApiException;
+
+  /** Create a new account. */
+  AccountApi create(AccountInput input) throws RestApiException;
+
   /**
    * Suggest users for a given query.
    * <p>
@@ -65,12 +79,31 @@
     throws RestApiException;
 
   /**
+   * Queries users.
+   * <p>
+   * Example code:
+   * {@code query().withQuery("name:John email:example.com").withLimit(5).get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query() throws RestApiException;
+
+  /**
+   * Queries users.
+   * <p>
+   * Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query) throws RestApiException;
+
+  /**
    * API for setting parameters and getting result.
    * Used for {@code suggestAccounts()}.
    *
    * @see #suggestAccounts()
    */
-  public abstract class SuggestAccountsRequest {
+  abstract class SuggestAccountsRequest {
     private String query;
     private int limit;
 
@@ -108,21 +141,114 @@
   }
 
   /**
+   * API for setting parameters and getting result.
+   * Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+    private EnumSet<ListAccountsOption> options =
+        EnumSet.noneOf(ListAccountsOption.class);
+
+    /**
+     * Executes query and returns a list of accounts.
+     */
+    public abstract List<AccountInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of accounts.
+     * Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /**
+     * Set number of accounts to skip.
+     * Optional; no accounts are skipped when not provided.
+     */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public QueryRequest withOption(ListAccountsOption options) {
+      this.options.add(options);
+      return this;
+    }
+
+    public QueryRequest withOptions(ListAccountsOption... options) {
+      this.options.addAll(Arrays.asList(options));
+      return this;
+    }
+
+    public QueryRequest withOptions(EnumSet<ListAccountsOption> options) {
+      this.options = options;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public EnumSet<ListAccountsOption> getOptions() {
+      return options;
+    }
+  }
+
+  /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements Accounts {
+  class NotImplemented implements Accounts {
     @Override
     public AccountApi id(String id) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public AccountApi id(int id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AccountApi self() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public AccountApi create(String username) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountApi create(AccountInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SuggestAccountsRequest suggestAccounts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -132,5 +258,15 @@
       throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public QueryRequest query() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
index ffdcf87..6f87e8b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
@@ -26,7 +26,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    */
-  public class NotImplemented implements GpgKeyApi {
+  class NotImplemented implements GpgKeyApi {
     @Override
     public GpgKeyInfo get() throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
index 04a7bc7..34726a8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
@@ -19,5 +19,6 @@
 public class AbandonInput {
   @DefaultInput
   public String message;
+  public NotifyHandling notify = NotifyHandling.ALL;
 }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
index 30a23bf..4c535d4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
@@ -14,15 +14,23 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class AddReviewerInput {
   @DefaultInput
   public String reviewer;
   public Boolean confirmed;
+  public ReviewerState state;
+  public NotifyHandling notify;
 
   public boolean confirmed() {
     return (confirmed != null) ? confirmed : false;
   }
-}
 
+  public ReviewerState state() {
+    return (state != null) ? state : REVIEWER;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
new file mode 100644
index 0000000..10f74ff84
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.List;
+
+/**
+ * Result object representing the outcome of a request to add a reviewer.
+ */
+public class AddReviewerResult {
+  /**
+   * The identifier of an account or group that was to be added as a reviewer.
+   */
+  public String input;
+
+  /**
+   * If non-null, a string describing why the reviewer could not be added.
+   */
+  @Nullable
+  public String error;
+
+  /**
+   * Non-null and true if the reviewer cannot be added without explicit
+   * confirmation. This may be the case for groups of a certain size.
+   */
+  @Nullable
+  public Boolean confirm;
+
+  /**
+   * List of individual reviewers added to the change. The size of this
+   * list may be greater than one (e.g. when a group is added). Null if no
+   * reviewers were added.
+   */
+  @Nullable
+  public List<ReviewerInfo> reviewers;
+
+  /**
+   * List of accounts CCed on the change. The size of this list may be
+   * greater than one (e.g. when a group is CCed). Null if no accounts were CCed
+   * or if reviewers is non-null.
+   */
+  @Nullable
+  public List<AccountInfo> ccs;
+
+  /**
+   * Constructs a partially initialized result for the given reviewer.
+   *
+   * @param input String identifier of an account or group, from user request
+   */
+  public AddReviewerResult(String input) {
+    this.input = input;
+  }
+
+  /**
+   * Constructs an error result for the given account.
+   *
+   * @param reviewer String identifier of an account or group
+   * @param error Error message
+   */
+  public AddReviewerResult(String reviewer, String error) {
+    this(reviewer);
+    this.error = error;
+  }
+
+  /**
+   * Constructs a needs-confirmation result for the given account.
+   *
+   * @param confirm Whether confirmation is needed.
+   */
+  public AddReviewerResult(String reviewer, boolean confirm) {
+    this(reviewer);
+    this.confirm = confirm;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index ce07098..f656c2d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -58,12 +58,28 @@
    */
   RevisionApi revision(String id) throws RestApiException;
 
+  /**
+   * Look up the reviewer of the change.
+   * <p>
+   * @param id ID of the account, can be a string of the format
+   *     "Full Name &lt;mail@example.com&gt;", just the email address, a full name
+   *     if it is unique, an account ID, a user name or 'self' for the
+   *     calling user.
+   * @return API for accessing the reviewer.
+   * @throws RestApiException if id is not account ID or is a user that isn't
+   *     known to be a reviewer for this change.
+   */
+  ReviewerApi reviewer(String id) throws RestApiException;
+
   void abandon() throws RestApiException;
   void abandon(AbandonInput in) throws RestApiException;
 
   void restore() throws RestApiException;
   void restore(RestoreInput in) throws RestApiException;
 
+  void move(String destination) throws RestApiException;
+  void move(MoveInput in) throws RestApiException;
+
   /**
    * Create a new change that reverts this change.
    *
@@ -79,6 +95,18 @@
   ChangeApi revert(RevertInput in) throws RestApiException;
 
   List<ChangeInfo> submittedTogether() throws RestApiException;
+  SubmittedTogetherInfo submittedTogether(
+      EnumSet<SubmittedTogetherOption> options) throws RestApiException;
+
+  /**
+   * Publishes a draft change.
+   */
+  void publish() throws RestApiException;
+
+  /**
+   * Deletes a draft change.
+   */
+  void delete() throws RestApiException;
 
   String topic() throws RestApiException;
   void topic(String topic) throws RestApiException;
@@ -130,8 +158,9 @@
 
   ChangeInfo check() throws RestApiException;
   ChangeInfo check(FixInput fix) throws RestApiException;
+  void index() throws RestApiException;
 
-  public abstract class SuggestedReviewersRequest {
+  abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
 
@@ -160,7 +189,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements ChangeApi {
+  class NotImplemented implements ChangeApi {
     @Override
     public String id() {
       throw new NotImplementedException();
@@ -177,6 +206,11 @@
     }
 
     @Override
+    public ReviewerApi reviewer(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public RevisionApi revision(String id) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -202,6 +236,16 @@
     }
 
     @Override
+    public void move(String destination) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void move(MoveInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi revert() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -212,6 +256,16 @@
     }
 
     @Override
+    public void publish() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public String topic() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -292,8 +346,19 @@
     }
 
     @Override
+    public void index() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<ChangeInfo> submittedTogether() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public SubmittedTogetherInfo submittedTogether(
+        EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
index da8aeb2..aa67473 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -58,12 +59,12 @@
   ChangeApi id(String project, String branch, String id)
       throws RestApiException;
 
-  ChangeApi create(ChangeInfo in) throws RestApiException;
+  ChangeApi create(ChangeInput in) throws RestApiException;
 
   QueryRequest query();
   QueryRequest query(String query);
 
-  public abstract class QueryRequest {
+  abstract class QueryRequest {
     private String query;
     private int limit;
     private int start;
@@ -139,7 +140,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements Changes {
+  class NotImplemented implements Changes {
     @Override
     public ChangeApi id(int id) throws RestApiException {
       throw new NotImplementedException();
@@ -156,7 +157,7 @@
     }
 
     @Override
-    public ChangeApi create(ChangeInfo in) throws RestApiException {
+    public ChangeApi create(ChangeInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
index d0c5633..adac284 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
@@ -25,7 +25,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements CommentApi {
+  class NotImplemented implements CommentApi {
     @Override
     public CommentInfo get() throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
new file mode 100644
index 0000000..671f43e
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/** Input passed to {@code DELETE /changes/[id]/reviewers/[id]/votes/[label]}. */
+public class DeleteVoteInput {
+  @DefaultInput
+  public String label;
+
+  /** Who to send email notifications to after vote is deleted. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
index 80a71f8..50335db 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
@@ -26,7 +26,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented extends CommentApi.NotImplemented
+  class NotImplemented extends CommentApi.NotImplemented
       implements DraftApi {
     @Override
     public CommentInfo update(DraftInput in) throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
index dd8f488..9d94f50 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
@@ -16,5 +16,22 @@
 
 import com.google.gerrit.extensions.client.Comment;
 
+import java.util.Objects;
+
 public class DraftInput extends Comment {
+  public String tag;
+
+  @Override
+  public boolean equals(Object o) {
+    if (super.equals(o)) {
+      DraftInput di = (DraftInput) o;
+      return Objects.equals(tag, di.tag);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
index f5f087c..2536c46 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -34,10 +35,66 @@
   DiffInfo diff(String base) throws RestApiException;
 
   /**
+   * @param parent 1-based parent number to diff against
+   */
+  DiffInfo diff(int parent) throws RestApiException;
+
+  /**
+   * Creates a request to retrieve the diff. On the returned request formatting
+   * options for the diff can be set.
+   */
+  DiffRequest diffRequest() throws RestApiException;
+
+  abstract class DiffRequest {
+    private String base;
+    private Integer context;
+    private Boolean intraline;
+    private Whitespace whitespace;
+
+    public abstract DiffInfo get() throws RestApiException;
+
+    public DiffRequest withBase(String base) {
+      this.base = base;
+      return this;
+    }
+
+    public DiffRequest withContext(int context) {
+      this.context = context;
+      return this;
+    }
+
+    public DiffRequest withIntraline(boolean intraline) {
+      this.intraline = intraline;
+      return this;
+    }
+
+    public DiffRequest withWhitespace(Whitespace whitespace) {
+      this.whitespace = whitespace;
+      return this;
+    }
+
+    public String getBase() {
+      return base;
+    }
+
+    public Integer getContext() {
+      return context;
+    }
+
+    public Boolean getIntraline() {
+      return intraline;
+    }
+
+    public Whitespace getWhitespace() {
+      return whitespace;
+    }
+  }
+
+  /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements FileApi {
+  class NotImplemented implements FileApi {
     @Override
     public BinaryResult content() throws RestApiException {
       throw new NotImplementedException();
@@ -52,5 +109,15 @@
     public DiffInfo diff(String base) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public DiffInfo diff(int parent) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DiffRequest diffRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/MoveInput.java
new file mode 100644
index 0000000..795642a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/MoveInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+public class MoveInput {
+  public String message;
+  public String destinationBranch;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
new file mode 100644
index 0000000..888e6bf
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+public enum NotifyHandling {
+  NONE, OWNER, OWNER_REVIEWERS, ALL
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index ee043eb..cbe16ed 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,9 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -26,6 +30,8 @@
   @DefaultInput
   public String message;
 
+  public String tag;
+
   public Map<String, Short> labels;
   public Map<String, List<CommentInput>> comments;
 
@@ -66,7 +72,12 @@
    */
   public String onBehalfOf;
 
-  public static enum DraftHandling {
+  /**
+   * Reviewers that should be added to this change.
+   */
+  public List<AddReviewerInput> reviewers;
+
+  public enum DraftHandling {
     /** Delete pending drafts on this revision only. */
     DELETE,
 
@@ -80,10 +91,6 @@
     PUBLISH_ALL_REVISIONS
   }
 
-  public static enum NotifyHandling {
-    NONE, OWNER, OWNER_REVIEWERS, ALL
-  }
-
   public static class CommentInput extends Comment {
   }
 
@@ -114,6 +121,23 @@
     return label(name, (short) 1);
   }
 
+  public ReviewInput reviewer(String reviewer) {
+    return reviewer(reviewer, REVIEWER, false);
+  }
+
+  public ReviewInput reviewer(String reviewer, ReviewerState state,
+      boolean confirmed) {
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = reviewer;
+    input.state = state;
+    input.confirmed = confirmed;
+    if (reviewers == null) {
+      reviewers = new ArrayList<>();
+    }
+    reviewers.add(input);
+    return this;
+  }
+
   public static ReviewInput recommend() {
     return new ReviewInput().label("Code-Review", 1);
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
new file mode 100644
index 0000000..b9de2e1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+
+import java.util.Map;
+
+/**
+ * Result object representing the outcome of a review request.
+ */
+public class ReviewResult {
+  /**
+   * Map of labels to values after the review was posted. Null if any
+   * reviewer additions were rejected.
+   */
+  @Nullable
+  public Map<String, Short> labels;
+
+  /**
+   * Map of account or group identifier to outcome of adding as a reviewer.
+   * Null if no reviewer additions were requested.
+   */
+  @Nullable
+  public Map<String, AddReviewerResult> reviewers;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
new file mode 100644
index 0000000..d1f09e8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+import java.util.Map;
+
+public interface ReviewerApi {
+
+  Map<String, Short> votes() throws RestApiException;
+  void deleteVote(String label) throws RestApiException;
+  void deleteVote(DeleteVoteInput input) throws RestApiException;
+  void remove() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility
+   * when adding new methods to the interface.
+   **/
+  class NotImplemented implements ReviewerApi {
+    @Override
+    public Map<String, Short> votes() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteVote(String label) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteVote(DeleteVoteInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void remove() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
new file mode 100644
index 0000000..c81f8aa
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.Map;
+
+/**
+ * Account and approval details for an added reviewer.
+ */
+public class ReviewerInfo extends AccountInfo {
+  /**
+   * {@link Map} of label name to initial value for each approval the reviewer
+   * is responsible for.
+   */
+  @Nullable
+  public Map<String, String> approvals;
+
+  public ReviewerInfo(Integer id) {
+    super(id);
+  }
+
+  @Override
+  public String toString() {
+    return username;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 44c2ba4..2731476 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -28,6 +30,7 @@
 
 public interface RevisionApi {
   void delete() throws RestApiException;
+
   void review(ReviewInput in) throws RestApiException;
 
   void submit() throws RestApiException;
@@ -36,13 +39,14 @@
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
   ChangeApi rebase() throws RestApiException;
   ChangeApi rebase(RebaseInput in) throws RestApiException;
-  boolean canRebase();
+  boolean canRebase() throws RestApiException;
 
   void setReviewed(String path, boolean reviewed) throws RestApiException;
   Set<String> reviewed() throws RestApiException;
 
   Map<String, FileInfo> files() throws RestApiException;
   Map<String, FileInfo> files(String base) throws RestApiException;
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
   FileApi file(String path);
   MergeableInfo mergeable() throws RestApiException;
   MergeableInfo mergeableOtherBranches() throws RestApiException;
@@ -65,11 +69,14 @@
 
   Map<String, ActionInfo> actions() throws RestApiException;
 
+  SubmitType submitType() throws RestApiException;
+  SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements RevisionApi {
+  class NotImplemented implements RevisionApi {
     @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
@@ -141,6 +148,11 @@
     }
 
     @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, FileInfo> files() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -194,5 +206,16 @@
     public Map<String, ActionInfo> actions() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public SubmitType submitType() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitType testSubmitType(TestSubmitRuleInput in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
new file mode 100644
index 0000000..d3dff98
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.Set;
+
+public class StarsInput {
+  public Set<String> add;
+  public Set<String> remove;
+
+  public StarsInput() {
+  }
+
+  public StarsInput(Set<String> add) {
+    this.add = add;
+  }
+
+  public StarsInput(Set<String> add, Set<String> remove) {
+    this.add = add;
+    this.remove = remove;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
index 4e08f8d..e415acb 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
@@ -15,7 +15,11 @@
 package com.google.gerrit.extensions.api.changes;
 
 public class SubmitInput {
+  /** Not used anymore, kept for backward compatibility */
   @Deprecated
   public boolean waitForMerge;
+
   public String onBehalfOf;
+
+  public NotifyHandling notify = NotifyHandling.ALL;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
new file mode 100644
index 0000000..52b6904
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+
+import java.util.List;
+
+public class SubmittedTogetherInfo {
+  public List<ChangeInfo> changes;
+  public int nonVisibleChanges;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
new file mode 100644
index 0000000..8649e91f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+/** Output options available for submitted_together requests. */
+public enum SubmittedTogetherOption {
+  NON_VISIBLE_CHANGES(0);
+
+  private final int value;
+
+  SubmittedTogetherOption(int v) {
+    value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
index 348cf4b..3f4971e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
@@ -26,7 +26,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements Config {
+  class NotImplemented implements Config {
     @Override
     public Server server() {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index c093b18..a43c29f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -23,14 +25,45 @@
    */
   String getVersion() throws RestApiException;
 
+  GeneralPreferencesInfo getDefaultPreferences() throws RestApiException;
+  GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
+      throws RestApiException;
+  DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException;
+  DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
+      throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements Server {
+  class NotImplemented implements Server {
     @Override
     public String getVersion() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public GeneralPreferencesInfo getDefaultPreferences()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GeneralPreferencesInfo setDefaultPreferences(
+        GeneralPreferencesInfo in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DiffPreferencesInfo getDefaultDiffPreferences()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
index f5a89c0..d3f4463 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 import java.util.List;
@@ -141,4 +142,102 @@
    * @throws RestApiException
    */
   List<? extends GroupAuditEventInfo> auditLog() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility
+   * when adding new methods to the interface.
+   **/
+  class NotImplemented implements GroupApi {
+    @Override
+    public GroupInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupInfo detail() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String name() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void name(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupInfo owner() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void owner(String owner) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String description() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void description(String description) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupOptionsInfo options() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void options(GroupOptionsInfo options) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<AccountInfo> members() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<AccountInfo> members(boolean recursive)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void addMembers(String... members) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void removeMembers(String... members) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<GroupInfo> includedGroups() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void addGroups(String... groups) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void removeGroups(String... groups) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<? extends GroupAuditEventInfo> auditLog()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java
index 28665fe..ab38434 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupInput.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.api.groups;
 
+import java.util.List;
+
 public class GroupInput {
   public String name;
   public String description;
   public Boolean visibleToAll;
   public String ownerId;
+  public List<String> members;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
index b909f31..b58009d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 import java.util.ArrayList;
@@ -51,7 +52,7 @@
   /** @return new request for listing groups. */
   ListRequest list();
 
-  public abstract class ListRequest {
+  abstract class ListRequest {
     private final EnumSet<ListGroupsOption> options =
         EnumSet.noneOf(ListGroupsOption.class);
     private final List<String> projects = new ArrayList<>();
@@ -114,6 +115,11 @@
       return this;
     }
 
+    public ListRequest withOwned(boolean owned) {
+      this.owned = owned;
+      return this;
+    }
+
     public ListRequest withLimit(int limit) {
       this.limit = limit;
       return this;
@@ -174,4 +180,30 @@
       return suggest;
     }
   }
+
+  /**
+   * A default implementation which allows source compatibility
+   * when adding new methods to the interface.
+   **/
+  class NotImplemented implements Groups {
+    @Override
+    public GroupApi id(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupApi create(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GroupApi create(GroupInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListRequest list() {
+      throw new NotImplementedException();
+    }
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 91cb70e..222248d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -25,10 +26,15 @@
   void delete() throws RestApiException;
 
   /**
+   * Returns the content of a file from the HEAD revision.
+   */
+  BinaryResult file(String path) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements BranchApi {
+  class NotImplemented implements BranchApi {
     @Override
     public BranchApi create(BranchInput in) throws RestApiException {
       throw new NotImplementedException();
@@ -43,5 +49,10 @@
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public BinaryResult file(String path) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index cfe9a99..506bcd4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -19,4 +19,5 @@
 public class BranchInput {
   @DefaultInput
   public String revision;
+  public String ref;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
index a930f0d..3bffac0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
@@ -26,7 +26,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements ChildProjectApi {
+  class NotImplemented implements ChildProjectApi {
     @Override
     public ProjectInfo get() throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
new file mode 100644
index 0000000..64ad6086
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class CommentLinkInfo {
+  public String match;
+  public String link;
+  public String html;
+  public Boolean enabled; // null means true
+
+  public transient String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
new file mode 100644
index 0000000..81c999bc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+
+import java.util.List;
+import java.util.Map;
+
+public class ConfigInfo {
+  public String description;
+  public InheritedBooleanInfo useContributorAgreements;
+  public InheritedBooleanInfo useContentMerge;
+  public InheritedBooleanInfo useSignedOffBy;
+  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
+  public InheritedBooleanInfo requireChangeId;
+  public InheritedBooleanInfo enableSignedPush;
+  public InheritedBooleanInfo requireSignedPush;
+  public InheritedBooleanInfo rejectImplicitMerges;
+  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
+  public SubmitType submitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
+  public Map<String, ActionInfo> actions;
+
+  public Map<String, CommentLinkInfo> commentlinks;
+  public ThemeInfo theme;
+
+  public static class InheritedBooleanInfo {
+    public Boolean value;
+    public InheritableBoolean configuredValue;
+    public Boolean inheritedValue;
+  }
+
+  public static class MaxObjectSizeLimitInfo {
+    public String value;
+    public String configuredValue;
+    public String inheritedValue;
+  }
+
+  public static class ConfigParameterInfo {
+    public String displayName;
+    public String description;
+    public String warning;
+    public ProjectConfigEntryType type;
+    public String value;
+    public Boolean editable;
+    public Boolean inheritable;
+    public String configuredValue;
+    public String inheritedValue;
+    public List<String> permittedValues;
+    public List<String> values;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
new file mode 100644
index 0000000..8ab13f6
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+
+import java.util.Map;
+
+public class ConfigInput {
+  public String description;
+  public InheritableBoolean useContributorAgreements;
+  public InheritableBoolean useContentMerge;
+  public InheritableBoolean useSignedOffBy;
+  public InheritableBoolean createNewChangeForAllNotInTarget;
+  public InheritableBoolean requireChangeId;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
+  public InheritableBoolean rejectImplicitMerges;
+  public String maxObjectSizeLimit;
+  public SubmitType submitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
new file mode 100644
index 0000000..5d6d2b0
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import java.util.List;
+
+public class ConfigValue {
+  public String value;
+  public List<String> values;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
new file mode 100644
index 0000000..e8108a5
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import java.util.List;
+
+public class DeleteBranchesInput {
+  public List<String> branches;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
new file mode 100644
index 0000000..d329510
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DescriptionInput {
+  @DefaultInput
+  public String description;
+  public String commitMessage;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index e3eb4be..e111291 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -26,12 +28,20 @@
   ProjectInfo get() throws RestApiException;
 
   String description() throws RestApiException;
-  void description(PutDescriptionInput in) throws RestApiException;
+  void description(DescriptionInput in) throws RestApiException;
+
+  ProjectAccessInfo access() throws RestApiException;
+  ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
+
+  ConfigInfo config() throws RestApiException;
+  ConfigInfo config(ConfigInput in) throws RestApiException;
 
   ListRefsRequest<BranchInfo> branches();
   ListRefsRequest<TagInfo> tags();
 
-  public abstract class ListRefsRequest<T extends RefInfo> {
+  void deleteBranches(DeleteBranchesInput in) throws RestApiException;
+
+  abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
     protected String substring;
@@ -108,7 +118,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements ProjectApi {
+  class NotImplemented implements ProjectApi {
     @Override
     public ProjectApi create() throws RestApiException {
       throw new NotImplementedException();
@@ -130,7 +140,28 @@
     }
 
     @Override
-    public void description(PutDescriptionInput in)
+    public ProjectAccessInfo access() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ConfigInfo config() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ConfigInfo config(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ProjectAccessInfo access(ProjectAccessInput p)
+      throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void description(DescriptionInput in)
         throws RestApiException {
       throw new NotImplementedException();
     }
@@ -169,5 +200,10 @@
     public TagApi tag(String ref) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
new file mode 100644
index 0000000..bc4674f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public enum ProjectConfigEntryType {
+  STRING, INT, LONG, BOOLEAN, LIST, ARRAY
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
index 27bdf16..1cbf54c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
@@ -36,9 +36,4 @@
   public InheritableBoolean createNewChangeForAllNotInTarget;
   public String maxObjectSizeLimit;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-
-  public static class ConfigValue {
-    public String value;
-    public List<String> values;
-  }
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
index 0e848b9..fdbadb2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -60,8 +60,8 @@
 
   ListRequest list();
 
-  public abstract class ListRequest {
-    public static enum FilterType {
+  abstract class ListRequest {
+    public enum FilterType {
       CODE, PARENT_CANDIDATES, PERMISSIONS, ALL
     }
 
@@ -175,7 +175,7 @@
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements Projects {
+  class NotImplemented implements Projects {
     @Override
     public ProjectApi name(String name) throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
deleted file mode 100644
index 7ea9fb6..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.projects;
-
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
index 6cc1ba4..4348daf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
@@ -18,13 +18,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface TagApi {
+  TagApi create(TagInput input) throws RestApiException;
+
   TagInfo get() throws RestApiException;
 
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
-  public class NotImplemented implements TagApi {
+  class NotImplemented implements TagApi {
+    @Override
+    public TagApi create(TagInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
     @Override
     public TagInfo get() throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
new file mode 100644
index 0000000..929d12e
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class TagInput {
+  @DefaultInput
+  public String ref;
+  public String revision;
+  public String message;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
new file mode 100644
index 0000000..d5d520f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class ThemeInfo {
+  public static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
+
+  public final String css;
+  public final String header;
+  public final String footer;
+
+  public ThemeInfo(String css, String header, String footer) {
+    this.css = css;
+    this.header = header;
+    this.footer = footer;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
new file mode 100644
index 0000000..3fa7bb2
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.auth.oauth;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+import java.io.IOException;
+
+@ExtensionPoint
+public interface OAuthLoginProvider {
+
+  /**
+   * Performs a login with an OAuth2 provider for Git over HTTP
+   * communication.
+   *
+   * An implementation of this interface must transmit the given
+   * user name and secret, which can be either an OAuth2 access token
+   * or a password, to the OAuth2 backend for verification.
+   *
+   * @param username the user's identifier.
+   * @param secret the secret to verify, e.g. a previously received
+   * access token or a password.
+   *
+   * @return information about the logged in user, at least
+   * external id, user name and email address.
+   *
+   * @throws IOException if the login failed.
+   */
+  OAuthUserInfo login(String username, String secret) throws IOException;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
index 901951e..788f420 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
@@ -14,17 +14,40 @@
 
 package com.google.gerrit.extensions.auth.oauth;
 
+import java.io.Serializable;
+
 /* OAuth token */
-public class OAuthToken {
+public class OAuthToken implements Serializable {
+
+  private static final long serialVersionUID = 1L;
 
   private final String token;
   private final String secret;
   private final String raw;
 
+  /**
+   * Time of expiration of this token, or {@code Long#MAX_VALUE} if this
+   * token never expires, or time of expiration is unknown.
+   */
+  private final long expiresAt;
+
+  /**
+   * The identifier of the OAuth provider that issued this token
+   * in the form <tt>"plugin-name:provider-name"</tt>, or {@code null}.
+   */
+  private final String providerId;
+
   public OAuthToken(String token, String secret, String raw) {
+    this(token, secret, raw, Long.MAX_VALUE, null);
+  }
+
+  public OAuthToken(String token, String secret, String raw,
+      long expiresAt, String providerId) {
     this.token = token;
     this.secret = secret;
     this.raw = raw;
+    this.expiresAt = expiresAt;
+    this.providerId = providerId;
   }
 
   public String getToken() {
@@ -38,4 +61,16 @@
   public String getRaw() {
     return raw;
   }
+
+  public long getExpiresAt() {
+    return expiresAt;
+  }
+
+  public boolean isExpired() {
+    return System.currentTimeMillis() > expiresAt;
+  }
+
+  public String getProviderId() {
+    return providerId;
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
new file mode 100644
index 0000000..b2f4262
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.auth.oauth;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+@ExtensionPoint
+public interface OAuthTokenEncrypter {
+
+  /**
+   * Encrypts the secret parts of the given OAuth access token.
+   *
+   * @param unencrypted a raw OAuth access token.
+   */
+  OAuthToken encrypt(OAuthToken unencrypted);
+
+  /**
+   * Decrypts the secret parts of the given OAuth access token.
+   *
+   * @param encrypted an encryppted OAuth access token.
+   */
+  OAuthToken decrypt(OAuthToken encrypted);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeKind.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeKind.java
new file mode 100644
index 0000000..e58e005
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+/** Operation performed by a change relative to its parent. */
+public enum ChangeKind {
+  /** Nontrivial content changes. */
+  REWORK,
+
+  /** Conflict-free merge between the new parent and the prior patch set. */
+  TRIVIAL_REBASE,
+
+  /** Conflict-free change of first (left) parent of a merge commit. */
+  MERGE_FIRST_PARENT_UPDATE,
+
+  /** Same tree and same parent tree. */
+  NO_CODE_CHANGE,
+
+  /** Same tree, parent tree, same commit message. */
+  NO_CHANGE
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
index 56ebf9d..661d253 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
@@ -42,12 +42,12 @@
    * the uploader publishes the change, it becomes a NEW change.
    * Publishing is a one-way action, a change cannot return to DRAFT status.
    * Draft changes are only visible to the uploader and those explicitly
-   * added as reviewers.
+   * added as reviewers. Note that currently draft changes cannot be abandoned.
    *
    * <p>
    * Changes in the DRAFT state can be moved to:
    * <ul>
-   * <li>{@link #NEW} - when the change is published, it becomes a new change;
+   * <li>{@link #NEW} - when the change is published, it becomes a new change.
    * </ul>
    */
   DRAFT,
@@ -69,6 +69,12 @@
    * Once a change has been abandoned, it cannot be further modified by adding
    * a replacement patch set, and it cannot be merged. Draft comments however
    * may be published, permitting reviewers to send constructive feedback.
+   *
+   * <p>
+   * Changes in the ABANDONED state can be moved to:
+   * <ul>
+   * <li>{@link #NEW} - when the Restore action is used.
+   * </ul>
    */
   ABANDONED
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index b9863d7..7c8a3e8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public abstract class Comment {
   /**
@@ -27,6 +28,7 @@
   public String id;
   public String path;
   public Side side;
+  public Integer parent;
   public Integer line;
   public Range range;
   public String inReplyTo;
@@ -38,5 +40,49 @@
     public int startCharacter;
     public int endLine;
     public int endCharacter;
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range r = (Range) o;
+        return Objects.equals(startLine, r.startLine)
+            && Objects.equals(startCharacter, r.startCharacter)
+            && Objects.equals(endLine, r.endLine)
+            && Objects.equals(endCharacter, r.endCharacter);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(startLine, startCharacter, endLine, endCharacter);
+    }
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o != null && getClass() == o.getClass()) {
+      Comment c = (Comment) o;
+      return Objects.equals(patchSet, c.patchSet)
+          && Objects.equals(id, c.id)
+          && Objects.equals(path, c.path)
+          && Objects.equals(side, c.side)
+          && Objects.equals(parent, c.parent)
+          && Objects.equals(line, c.line)
+          && Objects.equals(range, c.range)
+          && Objects.equals(inReplyTo, c.inReplyTo)
+          && Objects.equals(updated, c.updated)
+          && Objects.equals(message, c.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(patchSet, id, path, side, parent, line, range,
+        inReplyTo, updated, message);
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 18555cf..d246996 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -32,7 +32,7 @@
   public static final short[] CONTEXT_CHOICES =
       {3, 10, 25, 50, 75, 100, WHOLE_FILE_CONTEXT};
 
-  public static enum Whitespace {
+  public enum Whitespace {
     IGNORE_NONE,
     IGNORE_TRAILING,
     IGNORE_LEADING_AND_TRAILING,
@@ -61,6 +61,7 @@
   public Whitespace ignoreWhitespace;
   public Boolean retainHeader;
   public Boolean skipDeleted;
+  public Boolean skipUnchanged;
   public Boolean skipUncommented;
 
   public static DiffPreferencesInfo defaults() {
@@ -79,6 +80,7 @@
     i.showTabs = true;
     i.showWhitespaceErrors = true;
     i.skipDeleted = false;
+    i.skipUnchanged = false;
     i.skipUncommented = false;
     i.syntaxHighlighting = true;
     i.hideTopMenu = false;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index fe11b32..84c61b7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -18,6 +18,7 @@
 public class EditPreferencesInfo {
   public Integer tabSize;
   public Integer lineLength;
+  public Integer indentUnit;
   public Integer cursorBlinkRate;
   public Boolean hideTopMenu;
   public Boolean showTabs;
@@ -27,6 +28,7 @@
   public Boolean matchBrackets;
   public Boolean lineWrapping;
   public Boolean autoCloseBrackets;
+  public Boolean showBase;
   public Theme theme;
   public KeyMapType keyMapType;
 
@@ -34,6 +36,7 @@
     EditPreferencesInfo i = new EditPreferencesInfo();
     i.tabSize = 8;
     i.lineLength = 100;
+    i.indentUnit = 2;
     i.cursorBlinkRate = 0;
     i.hideTopMenu = false;
     i.showTabs = true;
@@ -43,6 +46,7 @@
     i.matchBrackets = true;
     i.lineWrapping = false;
     i.autoCloseBrackets = false;
+    i.showBase = false;
     i.theme = Theme.DEFAULT;
     i.keyMapType = KeyMapType.DEFAULT;
     return i;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
new file mode 100644
index 0000000..9754f12
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -0,0 +1,185 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+import java.util.List;
+import java.util.Map;
+
+/** Preferences about a single user. */
+public class GeneralPreferencesInfo {
+
+  /** Default number of items to display per page. */
+  public static final int DEFAULT_PAGESIZE = 25;
+
+  /** Valid choices for the page size. */
+  public static final int[] PAGESIZE_CHOICES = {10, 25, 50, 100};
+
+  /** Preferred method to download a change. */
+  public enum DownloadCommand {
+    REPO_DOWNLOAD, PULL, CHECKOUT, CHERRY_PICK, FORMAT_PATCH
+  }
+
+  public enum DateFormat {
+    /** US style dates: Apr 27, Feb 14, 2010 */
+    STD("MMM d", "MMM d, yyyy"),
+
+    /** US style dates: 04/27, 02/14/10 */
+    US("MM/dd", "MM/dd/yy"),
+
+    /** ISO style dates: 2010-02-14 */
+    ISO("MM-dd", "yyyy-MM-dd"),
+
+    /** European style dates: 27. Apr, 27.04.2010 */
+    EURO("d. MMM", "dd.MM.yyyy"),
+
+    /** UK style dates: 27/04, 27/04/2010 */
+    UK("dd/MM", "dd/MM/yyyy");
+
+    private final String shortFormat;
+    private final String longFormat;
+
+    DateFormat(String shortFormat, String longFormat) {
+      this.shortFormat = shortFormat;
+      this.longFormat = longFormat;
+    }
+
+    public String getShortFormat() {
+      return shortFormat;
+    }
+
+    public String getLongFormat() {
+      return longFormat;
+    }
+  }
+
+  public enum ReviewCategoryStrategy {
+    NONE,
+    NAME,
+    EMAIL,
+    USERNAME,
+    ABBREV
+  }
+
+  public enum DiffView {
+    SIDE_BY_SIDE,
+    UNIFIED_DIFF
+  }
+
+  public enum EmailStrategy {
+    ENABLED,
+    CC_ON_OWN_COMMENTS,
+    DISABLED
+  }
+
+  public enum TimeFormat {
+    /** 12-hour clock: 1:15 am, 2:13 pm */
+    HHMM_12("h:mm a"),
+
+    /** 24-hour clock: 01:15, 14:13 */
+    HHMM_24("HH:mm");
+
+    private final String format;
+
+    TimeFormat(String format) {
+      this.format = format;
+    }
+
+    public String getFormat() {
+      return format;
+    }
+  }
+
+  /** Number of changes to show in a screen. */
+  public Integer changesPerPage;
+  /** Should the site header be displayed when logged in ? */
+  public Boolean showSiteHeader;
+  /** Should the Flash helper movie be used to copy text to the clipboard? */
+  public Boolean useFlashClipboard;
+  /** Type of download URL the user prefers to use. */
+  public String downloadScheme;
+  /** Type of download command the user prefers to use. */
+  public DownloadCommand downloadCommand;
+  public DateFormat dateFormat;
+  public TimeFormat timeFormat;
+  public Boolean relativeDateInChangeTable;
+  public DiffView diffView;
+  public Boolean sizeBarInChangeTable;
+  public Boolean legacycidInChangeTable;
+  public ReviewCategoryStrategy reviewCategoryStrategy;
+  public Boolean muteCommonPathPrefixes;
+  public Boolean signedOffBy;
+  public List<MenuItem> my;
+  public Map<String, String> urlAliases;
+  public EmailStrategy emailStrategy;
+
+  public boolean isShowInfoInReviewCategory() {
+    return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
+  }
+
+  public DateFormat getDateFormat() {
+    if (dateFormat == null) {
+      return DateFormat.STD;
+    }
+    return dateFormat;
+  }
+
+  public TimeFormat getTimeFormat() {
+    if (timeFormat == null) {
+      return TimeFormat.HHMM_12;
+    }
+    return timeFormat;
+  }
+
+  public ReviewCategoryStrategy getReviewCategoryStrategy() {
+    if (reviewCategoryStrategy == null) {
+      return ReviewCategoryStrategy.NONE;
+    }
+    return reviewCategoryStrategy;
+  }
+
+  public DiffView getDiffView() {
+    if (diffView == null) {
+      return DiffView.SIDE_BY_SIDE;
+    }
+    return diffView;
+  }
+
+  public EmailStrategy getEmailStrategy() {
+    if (emailStrategy == null) {
+      return EmailStrategy.ENABLED;
+    }
+    return emailStrategy;
+  }
+
+  public static GeneralPreferencesInfo defaults() {
+    GeneralPreferencesInfo p = new GeneralPreferencesInfo();
+    p.changesPerPage = DEFAULT_PAGESIZE;
+    p.showSiteHeader = true;
+    p.useFlashClipboard = true;
+    p.emailStrategy = EmailStrategy.ENABLED;
+    p.reviewCategoryStrategy = ReviewCategoryStrategy.NONE;
+    p.downloadScheme = null;
+    p.downloadCommand = DownloadCommand.CHECKOUT;
+    p.dateFormat = DateFormat.STD;
+    p.timeFormat = TimeFormat.HHMM_12;
+    p.relativeDateInChangeTable = false;
+    p.diffView = DiffView.SIDE_BY_SIDE;
+    p.sizeBarInChangeTable = true;
+    p.legacycidInChangeTable = false;
+    p.muteCommonPathPrefixes = true;
+    p.signedOffBy = false;
+    return p;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
index 81d6149..0a5b033 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.extensions.client;
 
 public enum GerritTopMenu {
-  ALL, MY, DIFFERENCES, PROJECTS, PEOPLE, PLUGINS, DOCUMENTATION;
+  ALL, MY, PROJECTS, PEOPLE, PLUGINS, DOCUMENTATION;
 
   public final String menuName;
 
-  private GerritTopMenu() {
+  GerritTopMenu() {
     menuName = name().substring(0, 1) + name().substring(1).toLowerCase();
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
new file mode 100644
index 0000000..6450b0d
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+public enum GitBasicAuthPolicy {
+  HTTP,
+  LDAP,
+  HTTP_LDAP
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java
index 57d4849..ce6464d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/InheritableBoolean.java
@@ -18,4 +18,4 @@
   TRUE,
   FALSE,
   INHERIT
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java
index 261168d..9c85ac7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/KeyMapType.java
@@ -17,5 +17,6 @@
 public enum KeyMapType {
   DEFAULT,
   EMACS,
+  SUBLIME,
   VIM
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListAccountsOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListAccountsOption.java
new file mode 100644
index 0000000..b5e9004
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListAccountsOption.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/** Output options available for retrieval of account details. */
+public enum ListAccountsOption {
+  /** Return detailed account properties. */
+  DETAILS(0),
+
+  /** Return all secondary emails. */
+  ALL_EMAILS(1);
+
+  private final int value;
+
+  ListAccountsOption(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
+
+  public static EnumSet<ListAccountsOption> fromBits(int v) {
+    EnumSet<ListAccountsOption> r = EnumSet.noneOf(ListAccountsOption.class);
+    for (ListAccountsOption o : ListAccountsOption.values()) {
+      if ((v & (1 << o.value)) != 0) {
+        r.add(o);
+        v &= ~(1 << o.value);
+      }
+      if (v == 0) {
+        return r;
+      }
+    }
+    if (v != 0) {
+      throw new IllegalArgumentException("unknown " + Integer.toHexString(v));
+    }
+    return r;
+  }
+
+  public static int toBits(Set<ListAccountsOption> set) {
+    int r = 0;
+    for (ListAccountsOption o : set) {
+      r |= 1 << o.value;
+    }
+    return r;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 4c336f7..8b6c5e6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -66,11 +66,14 @@
   COMMIT_FOOTERS(17),
 
   /** Include push certificate information along with any patch sets. */
-  PUSH_CERTIFICATES(18);
+  PUSH_CERTIFICATES(18),
+
+  /** Include change's reviewer updates. */
+  REVIEWER_UPDATES(19);
 
   private final int value;
 
-  private ListChangesOption(int v) {
+  ListChangesOption(int v) {
     this.value = v;
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java
index d87f73e..e95570f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListGroupsOption.java
@@ -26,7 +26,7 @@
 
   private final int value;
 
-  private ListGroupsOption(int v) {
+  ListGroupsOption(int v) {
     this.value = v;
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/MenuItem.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/MenuItem.java
new file mode 100644
index 0000000..25377a5
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/MenuItem.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+public class MenuItem {
+  public final String url;
+  public final String name;
+  public final String target;
+  public final String id;
+
+  // Needed for GWT
+  public MenuItem() {
+    this(null, null, null, null);
+  }
+
+  public MenuItem(String name, String url) {
+    this(name, url, "_blank");
+  }
+
+  public MenuItem(String name, String url, String target) {
+    this(name, url, target, null);
+  }
+
+  public MenuItem(String name, String url, String target, String id) {
+    this.url = url;
+    this.name = name;
+    this.target = target;
+    this.id = id;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
index 6f4190d..3114cb9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
@@ -18,4 +18,4 @@
   ACTIVE,
   READ_ONLY,
   HIDDEN
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
new file mode 100644
index 0000000..556dddc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+import java.util.Objects;
+
+public class ProjectWatchInfo {
+  public String project;
+  public String filter;
+
+  public Boolean notifyNewChanges;
+  public Boolean notifyNewPatchSets;
+  public Boolean notifyAllComments;
+  public Boolean notifySubmittedChanges;
+  public Boolean notifyAbandonedChanges;
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof ProjectWatchInfo) {
+      ProjectWatchInfo w = (ProjectWatchInfo) obj;
+      return Objects.equals(project, w.project)
+          && Objects.equals(filter, w.filter)
+          && Objects.equals(notifyNewChanges, w.notifyNewChanges)
+          && Objects.equals(notifyNewPatchSets, w.notifyNewPatchSets)
+          && Objects.equals(notifyAllComments, w.notifyAllComments)
+          && Objects.equals(notifySubmittedChanges, w.notifySubmittedChanges)
+          && Objects.equals(notifyAbandonedChanges, w.notifyAbandonedChanges);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects
+        .hash(project, filter, notifyNewChanges, notifyNewPatchSets,
+            notifyAllComments, notifySubmittedChanges, notifyAbandonedChanges);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder b = new StringBuilder();
+    b.append(project);
+    if (filter != null) {
+      b.append("%filter=")
+          .append(filter);
+    }
+    b.append("(notifyAbandonedChanges=")
+        .append(toBoolean(notifyAbandonedChanges))
+        .append(", notifyAllComments=")
+        .append(toBoolean(notifyAllComments))
+        .append(", notifyNewChanges=")
+        .append(toBoolean(notifyNewChanges))
+        .append(", notifyNewPatchSets=")
+        .append(toBoolean(notifyNewPatchSets))
+        .append(", notifySubmittedChanges=")
+        .append(toBoolean(notifySubmittedChanges))
+        .append(")");
+    return b.toString();
+  }
+
+  private boolean toBoolean(Boolean b) {
+    return b == null ? false : b;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
new file mode 100644
index 0000000..a58c959
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.client;
+
+public enum ReviewerState {
+  /** The user has contributed at least one nonzero vote on the change. */
+  REVIEWER,
+
+  /** The reviewer was added to the change, but has not voted. */
+  CC,
+
+  /** The user was previously a reviewer on the change, but was removed. */
+  REMOVED;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
index 3485b8b..e077df2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
@@ -19,11 +19,10 @@
   REVISION;
 
   public static Side fromShort(short s) {
-    switch (s) {
-      case 0:
-        return PARENT;
-      case 1:
-        return REVISION;
+    if (s <= 0) {
+      return PARENT;
+    } else if (s == 1) {
+      return REVISION;
     }
     return null;
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
index 2b916f1..fcfeb01 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
@@ -20,4 +20,4 @@
   REBASE_IF_NECESSARY,
   MERGE_ALWAYS,
   CHERRY_PICK
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
index 39730b7..6408f9d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
@@ -17,23 +17,103 @@
 public enum Theme {
   // Light themes
   DEFAULT,
+  DAY_3024,
+  BASE16_LIGHT,
   ECLIPSE,
   ELEGANT,
+  MDN_LIKE,
   NEAT,
+  NEO,
+  PARAISO_LIGHT,
+  SOLARIZED,
+  TTCN,
+  XQ_LIGHT,
+  YETI,
 
   // Dark themes
+  NIGHT_3024,
+  ABCDEF,
+  AMBIANCE,
+  BASE16_DARK,
+  BESPIN,
+  BLACKBOARD,
+  COBALT,
+  COLORFORTH,
+  DRACULA,
+  ERLANG_DARK,
+  HOPSCOTCH,
+  ICECODER,
+  ISOTOPE,
+  LESSER_DARK,
+  LIQUIBYTE,
+  MATERIAL,
+  MBO,
   MIDNIGHT,
+  MONOKAI,
   NIGHT,
-  TWILIGHT;
+  PARAISO_DARK,
+  PASTEL_ON_DARK,
+  RAILSCASTS,
+  RUBYBLUE,
+  SETI,
+  THE_MATRIX,
+  TOMORROW_NIGHT_BRIGHT,
+  TOMORROW_NIGHT_EIGHTIES,
+  TWILIGHT,
+  VIBRANT_INK,
+  XQ_DARK,
+  ZENBURN;
 
   public boolean isDark() {
     switch (this) {
+      case NIGHT_3024:
+      case ABCDEF:
+      case AMBIANCE:
+      case BASE16_DARK:
+      case BESPIN:
+      case BLACKBOARD:
+      case COBALT:
+      case COLORFORTH:
+      case DRACULA:
+      case ERLANG_DARK:
+      case HOPSCOTCH:
+      case ICECODER:
+      case ISOTOPE:
+      case LESSER_DARK:
+      case LIQUIBYTE:
+      case MATERIAL:
+      case MBO:
       case MIDNIGHT:
+      case MONOKAI:
       case NIGHT:
+      case PARAISO_DARK:
+      case PASTEL_ON_DARK:
+      case RAILSCASTS:
+      case RUBYBLUE:
+      case SETI:
+      case THE_MATRIX:
+      case TOMORROW_NIGHT_BRIGHT:
+      case TOMORROW_NIGHT_EIGHTIES:
       case TWILIGHT:
+      case VIBRANT_INK:
+      case XQ_DARK:
+      case ZENBURN:
         return true;
+      case DEFAULT:
+      case DAY_3024:
+      case BASE16_LIGHT:
+      case ECLIPSE:
+      case ELEGANT:
+      case MDN_LIKE:
+      case NEAT:
+      case NEO:
+      case PARAISO_LIGHT:
+      case SOLARIZED:
+      case TTCN:
+      case XQ_LIGHT:
+      case YETI:
       default:
         return false;
     }
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
index 39d98de..2c35d5e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -20,8 +20,10 @@
   public Integer _accountId;
   public String name;
   public String email;
+  public List<String> secondaryEmails;
   public String username;
   public List<AvatarInfo> avatars;
+  public Boolean _moreAccounts;
 
   public AccountInfo(Integer id) {
     this._accountId = id;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
new file mode 100644
index 0000000..6ec5b1d
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class AgreementInfo {
+  public String name;
+  public String description;
+  public String url;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
new file mode 100644
index 0000000..060367b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/** This entity contains information for registering a new contributor agreement. */
+public class AgreementInput {
+  /* The agreement name. */
+  @DefaultInput
+  public String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index ce120cd..6d28dbc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -17,6 +17,7 @@
 import java.sql.Timestamp;
 
 public class ApprovalInfo extends AccountInfo {
+  public String tag;
   public Integer value;
   public Timestamp date;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/BlameInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/BlameInfo.java
new file mode 100644
index 0000000..df3f373
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class BlameInfo {
+  public String author;
+  public String id;
+  public int time;
+  public String commitMsg;
+  public List<RangeInfo> ranges;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    BlameInfo blameInfo = (BlameInfo) o;
+
+    return id.equals(blameInfo.id);
+  }
+
+  @Override
+  public int hashCode() {
+    return id.hashCode();
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index cdfe0c6..003ab24 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.SubmitType;
 
 import java.sql.Timestamp;
 import java.util.Collection;
@@ -32,14 +34,16 @@
   public ChangeStatus status;
   public Timestamp created;
   public Timestamp updated;
+  public Timestamp submitted;
   public Boolean starred;
+  public Collection<String> stars;
   public Boolean reviewed;
+  public SubmitType submitType;
   public Boolean mergeable;
   public Boolean submittable;
   public Integer insertions;
   public Integer deletions;
 
-  public String baseChange;
   public int _number;
 
   public AccountInfo owner;
@@ -48,6 +52,8 @@
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
   public Collection<AccountInfo> removableReviewers;
+  public Map<ReviewerState, Collection<AccountInfo>> reviewers;
+  public Collection<ReviewerUpdateInfo> reviewerUpdates;
   public Collection<ChangeMessageInfo> messages;
 
   public String currentRevision;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
new file mode 100644
index 0000000..88c3ea8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.client.ChangeStatus;
+
+public class ChangeInput {
+  public String project;
+  public String branch;
+  public String subject;
+
+  public String topic;
+  public ChangeStatus status;
+  public String baseChange;
+  public Boolean newBranch;
+  public MergeInput merge;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 7ad40b5..e79918f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -18,6 +18,7 @@
 
 public class ChangeMessageInfo {
   public String id;
+  public String tag;
   public AccountInfo author;
   public Timestamp date;
   public String message;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
index b1f8183..166aaa2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -16,6 +16,24 @@
 
 import com.google.gerrit.extensions.client.Comment;
 
+import java.util.Objects;
+
 public class CommentInfo extends Comment {
   public AccountInfo author;
+  public String tag;
+
+  @Override
+  public boolean equals(Object o) {
+    if (super.equals(o)) {
+      CommentInfo ci = (CommentInfo) o;
+      return Objects.equals(author, ci.author)
+          && Objects.equals(tag, ci.tag);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), author, tag);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffInfo.java
index 58b2d39..3df4b86 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffInfo.java
@@ -35,7 +35,7 @@
   // Binary file
   public Boolean binary;
 
-  public static enum IntraLineStatus {
+  public enum IntraLineStatus {
     OK,
     TIMEOUT,
     FAILURE
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
index 00d0c18..a812908 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -21,4 +21,5 @@
   public Integer linesInserted;
   public Integer linesDeleted;
   public long sizeDelta;
+  public long size;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
new file mode 100644
index 0000000..598d618
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class MergeInput {
+  /**
+   * {@code source} can be any Git object reference expression.
+   *
+   * @see <a href="https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html">gitrevisions(7)</a>
+   */
+  public String source;
+
+  /**
+   * {@code strategy} name of the merge strategy.
+   *
+   * @see org.eclipse.jgit.merge.MergeStrategy
+   */
+  public String strategy;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
index 9c38055..50de74a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
@@ -20,6 +20,10 @@
 
 public class MergeableInfo {
   public SubmitType submitType;
+  public String strategy;
   public boolean mergeable;
+  public boolean commitMerged;
+  public boolean contentMerged;
+  public List<String> conflicts;
   public List<String> mergeableInto;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
index d04b346..ff04fdc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class ProblemInfo {
-  public static enum Status {
+  public enum Status {
     FIXED, FIX_FAILED
   }
 
@@ -24,6 +26,22 @@
   public String outcome;
 
   @Override
+  public int hashCode() {
+    return Objects.hash(message, status, outcome);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ProblemInfo)) {
+      return false;
+    }
+    ProblemInfo p = (ProblemInfo) o;
+    return Objects.equals(message, p.message)
+        && Objects.equals(status, p.status)
+        && Objects.equals(outcome, p.outcome);
+  }
+
+  @Override
   public String toString() {
     StringBuilder sb = new StringBuilder(getClass().getSimpleName())
         .append('[').append(message);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RangeInfo.java
new file mode 100644
index 0000000..5268825
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RangeInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class RangeInfo {
+  public int start;
+  public int end;
+
+  public RangeInfo(int start, int end) {
+    this.start = start;
+    this.end = end;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
new file mode 100644
index 0000000..b3c9cb6
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.client.ReviewerState;
+
+import java.sql.Timestamp;
+
+public class ReviewerUpdateInfo {
+  public Timestamp updated;
+  public AccountInfo updatedBy;
+  public AccountInfo reviewer;
+  public ReviewerState state;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 025c623..34a1e63 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.client.ChangeKind;
+
 import java.sql.Timestamp;
 import java.util.Map;
 
 public class RevisionInfo {
   public transient boolean isCurrent;
   public Boolean draft;
+  public ChangeKind kind;
   public int _number;
   public Timestamp created;
   public AccountInfo uploader;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInfo.java
new file mode 100644
index 0000000..a20bcbf
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInfo.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class SshKeyInfo {
+  public Integer seq;
+  public String sshPublicKey;
+  public String encodedKey;
+  public String algorithm;
+  public String comment;
+  public Boolean valid;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
index d371f35..697caf1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
@@ -17,4 +17,6 @@
 public class SuggestedReviewerInfo {
   public AccountInfo account;
   public GroupBaseInfo group;
-}
+  public int count;
+  public Boolean confirm;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
new file mode 100644
index 0000000..96a1626
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class TestSubmitRuleInput {
+  public enum Filters {
+    RUN, SKIP
+  }
+
+  @DefaultInput
+  public String rule;
+  public Filters filters;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
index 072799f..d78fa63 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.extensions.config;
 
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
 import java.util.Collection;
-import java.util.List;
 
 @ExtensionPoint
 public interface ExternalIncludedIn {
 
   /**
-   * Returns a list of systems that include the given commit.
+   * Returns additional entries for IncludedInInfo as multimap where the
+   * key is the row title and the the values are a list of systems that include
+   * the given commit (e.g. names of servers on which this commit is deployed).
    *
    * The tags and branches in which the commit is included are provided so that
    * a RevWalk can be avoided when a system runs a certain tag or branch.
@@ -33,9 +35,8 @@
    *        included
    * @param tags the tags that include the commit
    * @param branches the branches that include the commit
-   * @return a list of systems that contain the given commit, e.g. names of
-   *         servers on which this commit is deployed
+   * @return additional entries for IncludedInInfo
    */
-  List<String> getIncludedIn(String project, String commit,
+  Multimap<String, String> getIncludedIn(String project, String commit,
       Collection<String> tags, Collection<String> branches);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
new file mode 100644
index 0000000..3263e70
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Notified whenever an account is indexed
+ */
+@ExtensionPoint
+public interface AccountIndexedListener {
+/**
+ * Invoked when an account is indexed
+ * @param id of the account
+ */
+  void onAccountIndexed(int id);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
new file mode 100644
index 0000000..5abfc38
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a user signed up for a Contributor License Agreement. */
+@ExtensionPoint
+public interface AgreementSignupListener {
+  interface Event extends GerritEvent {
+    AccountInfo getAccount();
+    String getAgreementName();
+  }
+
+  void onAgreementSignup(Event e);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
new file mode 100644
index 0000000..40b84a3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is abandoned. */
+@ExtensionPoint
+public interface ChangeAbandonedListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getAbandoner();
+    String getReason();
+  }
+
+  void onChangeAbandoned(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
new file mode 100644
index 0000000..f012710
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+
+import java.sql.Timestamp;
+
+/** Interface to be extended by Events with a Change. */
+public interface ChangeEvent extends GerritEvent {
+  ChangeInfo getChange();
+  AccountInfo getWho();
+  Timestamp getWhen();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
new file mode 100644
index 0000000..fd8dac8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a change is indexed or deleted from the index. */
+@ExtensionPoint
+public interface ChangeIndexedListener {
+  /** Invoked when a change is indexed. */
+  void onChangeIndexed(int id);
+
+  /** Invoked when a change is deleted from the index. */
+  void onChangeDeleted(int id);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
new file mode 100644
index 0000000..d0ca6d6
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is merged. */
+@ExtensionPoint
+public interface ChangeMergedListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getMerger();
+    /**
+     * Represents the merged Revision when the submit strategy is cherry-pick or
+     * rebase-if-necessary.
+     */
+    String getNewRevisionId();
+  }
+
+  void onChangeMerged(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
new file mode 100644
index 0000000..e5f3330
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is restored. */
+@ExtensionPoint
+public interface ChangeRestoredListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getRestorer();
+    String getReason();
+  }
+
+  void onChangeRestored(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java
new file mode 100644
index 0000000..99904c7
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRevertedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.ChangeInfo;
+
+/** Notified whenever a Change is reverted via the UI or REST API. */
+@ExtensionPoint
+public interface ChangeRevertedListener {
+  interface Event extends ChangeEvent {
+    /** The revert change that was created. */
+    ChangeInfo getRevertChange();
+  }
+
+  void onChangeReverted(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
new file mode 100644
index 0000000..6c82034
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+
+import java.util.Map;
+
+/** Notified whenever a comment is added to a change. */
+@ExtensionPoint
+public interface CommentAddedListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getAuthor();
+    String getComment();
+    Map<String, ApprovalInfo> getApprovals();
+    Map<String, ApprovalInfo> getOldApprovals();
+  }
+
+  void onCommentAdded(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
new file mode 100644
index 0000000..3857468
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Draft is published. */
+@ExtensionPoint
+public interface DraftPublishedListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getPublisher();
+  }
+
+  void onDraftPublished(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
index 89d836d..f15dd4d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
@@ -23,14 +23,11 @@
  */
 @ExtensionPoint
 public interface GarbageCollectorListener {
-  public interface Event {
-    /** @return The name of the project that has been garbage collected. */
-    String getProjectName();
-
+  interface Event extends ProjectEvent {
     /**
      * @return Properties describing the result of the garbage collection
      *         performed by JGit.
-     * @see <a href="http://download.eclipse.org/jgit/site/3.7.0.201502260915-r/apidocs/org/eclipse/jgit/api/GarbageCollectCommand.html#call%28%29">GarbageCollectCommand</a>
+     * @see org.eclipse.jgit.api.GarbageCollectCommand#call()
      */
     Properties getStatistics();
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GerritEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GerritEvent.java
new file mode 100644
index 0000000..e43a981
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GerritEvent.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+
+/** Base interface to be extended by Events. */
+public interface GerritEvent {
+  NotifyHandling getNotify();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index a838baf..3f7dfbe 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -14,20 +14,24 @@
 
 package com.google.gerrit.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified when one or more references are modified. */
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
-
-  public interface Event {
-    String getProjectName();
+  interface Event extends ProjectEvent {
     String getRefName();
     String getOldObjectId();
     String getNewObjectId();
     boolean isCreate();
     boolean isDelete();
     boolean isNonFastForward();
+    /**
+     * The updater, could be null if it's the server.
+     */
+    @Nullable AccountInfo getUpdater();
   }
 
   void onGitReferenceUpdated(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
new file mode 100644
index 0000000..c49b0f3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.Collection;
+
+/** Notified whenever a Change's Hashtags are edited. */
+@ExtensionPoint
+public interface HashtagsEditedListener {
+  interface Event extends ChangeEvent {
+    @Deprecated
+    AccountInfo getEditor();
+    Collection<String> getHashtags();
+    Collection<String> getAddedHashtags();
+    Collection<String> getRemovedHashtags();
+  }
+
+  void onHashtagsEdited(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
index 5961d6f..e11c857 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
@@ -19,8 +19,7 @@
 /** Notified whenever the HEAD of a project is updated. */
 @ExtensionPoint
 public interface HeadUpdatedListener {
-  public interface Event {
-    String getProjectName();
+  interface Event extends ProjectEvent {
     String getOldHeadName();
     String getNewHeadName();
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
index 93da347..b3ed37b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
@@ -21,9 +21,9 @@
 /** Listener interested in server startup and shutdown events. */
 @ExtensionPoint
 public interface LifecycleListener extends EventListener {
-  /** Invoke when the server is starting. */
-  public void start();
+  /** Invoked when the server is starting. */
+  void start();
 
   /** Invoked when the server is stopping. */
-  public void stop();
+  void stop();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
index 7eed7d4..07c0bf6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
@@ -20,8 +20,7 @@
 /** Notified whenever a project is created on the master. */
 @ExtensionPoint
 public interface NewProjectCreatedListener {
-  public interface Event {
-    String getProjectName();
+  interface Event extends ProjectEvent {
     String getHeadName();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
new file mode 100644
index 0000000..dfcbdee
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+/** Notified when a plugin fires an event. */
+public interface PluginEventListener {
+  interface Event extends GerritEvent {
+    String pluginName();
+    String getType();
+    String getData();
+  }
+
+  void onPluginEvent(Event e);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
index b03f99c..468950f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
@@ -19,8 +19,7 @@
 /** Notified whenever a project is deleted on the master. */
 @ExtensionPoint
 public interface ProjectDeletedListener {
-  public interface Event {
-    String getProjectName();
+  interface Event extends ProjectEvent {
   }
 
   void onProjectDeleted(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectEvent.java
new file mode 100644
index 0000000..f36ad31
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectEvent.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+/** Interface to be extended by Events with a Project. */
+public interface ProjectEvent extends GerritEvent {
+  String getProjectName();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
new file mode 100644
index 0000000..3cc3fdc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Reviewer is added to a change. */
+@ExtensionPoint
+public interface ReviewerAddedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getReviewer();
+  }
+
+  void onReviewerAdded(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
new file mode 100644
index 0000000..3c2f723
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+
+import java.util.Map;
+
+/** Notified whenever a Reviewer is removed from a change. */
+@ExtensionPoint
+public interface ReviewerDeletedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getReviewer();
+    String getComment();
+    Map<String, ApprovalInfo> getNewApprovals();
+    Map<String, ApprovalInfo> getOldApprovals();
+  }
+
+  void onReviewerDeleted(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
new file mode 100644
index 0000000..5e4e095
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change Revision is created. */
+@ExtensionPoint
+public interface RevisionCreatedListener {
+  interface Event extends RevisionEvent {
+    @Deprecated
+    AccountInfo getUploader();
+  }
+
+  void onRevisionCreated(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
new file mode 100644
index 0000000..27d1067
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.common.RevisionInfo;
+
+/** Interface to be extended by Events with a Revision. */
+public interface RevisionEvent extends ChangeEvent {
+  RevisionInfo getRevision();
+}
+
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
new file mode 100644
index 0000000..68ba22c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change Topic is changed. */
+@ExtensionPoint
+public interface TopicEditedListener {
+  interface Event extends ChangeEvent {
+    @Deprecated
+    AccountInfo getEditor();
+    String getOldTopic();
+  }
+
+  void onTopicEdited(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
index 365d056..35d49b1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
@@ -23,22 +23,22 @@
 @ExtensionPoint
 public interface UsageDataPublishedListener {
 
-  public interface Event {
+  interface Event {
     MetaData getMetaData();
     Timestamp getInstant();
     List<Data> getData();
   }
 
-  public interface Data {
+  interface Data {
     long getValue();
     String getProjectName();
   }
 
-  public interface MetaData {
-    public String getName();
-    public String getUnitName();
-    public String getUnitSymbol();
-    public String getDescription();
+  interface MetaData {
+    String getName();
+    String getUnitName();
+    String getUnitSymbol();
+    String getDescription();
   }
 
   void onUsageDataPublished(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
new file mode 100644
index 0000000..01a83e3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+
+import java.util.Map;
+
+/** Notified whenever a vote is removed from a change. */
+@ExtensionPoint
+public interface VoteDeletedListener {
+  interface Event extends RevisionEvent {
+    Map<String, ApprovalInfo> getOldApprovals();
+    Map<String, ApprovalInfo> getApprovals();
+    Map<String, ApprovalInfo> getRemoved();
+    String getMessage();
+  }
+
+  void onVoteDeleted(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
index da7db17..52be977 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -74,15 +74,33 @@
    * @param member type of entry to store.
    */
   public static <T> void itemOf(Binder binder, TypeLiteral<T> member) {
-    @SuppressWarnings("unchecked")
-    Key<DynamicItem<T>> key = (Key<DynamicItem<T>>) Key.get(
-        Types.newParameterizedType(DynamicItem.class, member.getType()));
+    Key<DynamicItem<T>> key = keyFor(member);
     binder.bind(key)
       .toProvider(new DynamicItemProvider<>(member, key))
       .in(Scopes.SINGLETON);
   }
 
   /**
+   * Construct a single {@code DynamicItem<T>} with a fixed value.
+   * <p>
+   * Primarily useful for passing {@code DynamicItem}s to constructors in tests.
+   *
+   * @param member type of item.
+   * @param item item to store.
+   */
+  public static <T> DynamicItem<T> itemOf(Class<T> member, T item) {
+    return new DynamicItem<>(
+        keyFor(TypeLiteral.get(member)),
+        Providers.of(item), "gerrit");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> Key<DynamicItem<T>> keyFor(TypeLiteral<T> member) {
+    return (Key<DynamicItem<T>>) Key.get(
+        Types.newParameterizedType(DynamicItem.class, member.getType()));
+  }
+
+  /**
    * Bind one implementation as the item using a unique annotation.
    *
    * @param binder a new binder created in the module.
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 28052ef..6eb11bc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -53,6 +53,7 @@
    * @param member type of entry in the set.
    */
   public static <T> void setOf(Binder binder, Class<T> member) {
+    binder.disableCircularProxies();
     setOf(binder, TypeLiteral.get(member));
   }
 
@@ -71,6 +72,7 @@
     @SuppressWarnings("unchecked")
     Key<DynamicSet<T>> key = (Key<DynamicSet<T>>) Key.get(
         Types.newParameterizedType(DynamicSet.class, member.getType()));
+    binder.disableCircularProxies();
     binder.bind(key)
       .toProvider(new DynamicSetProvider<>(member))
       .in(Scopes.SINGLETON);
@@ -84,6 +86,7 @@
    * @return a binder to continue configuring the new set member.
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, Class<T> type) {
+    binder.disableCircularProxies();
     return bind(binder, TypeLiteral.get(type));
   }
 
@@ -95,6 +98,7 @@
    * @return a binder to continue configuring the new set member.
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, TypeLiteral<T> type) {
+    binder.disableCircularProxies();
     return binder.bind(type).annotatedWith(UniqueAnnotations.create());
   }
 
@@ -110,6 +114,7 @@
   public static <T> LinkedBindingBuilder<T> bind(Binder binder,
       Class<T> type,
       Named name) {
+    binder.disableCircularProxies();
     return bind(binder, TypeLiteral.get(type));
   }
 
@@ -125,6 +130,7 @@
   public static <T> LinkedBindingBuilder<T> bind(Binder binder,
       TypeLiteral<T> type,
       Named name) {
+    binder.disableCircularProxies();
     return binder.bind(type).annotatedWith(name);
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
index 2243786..4688f7c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
@@ -17,5 +17,5 @@
 /** Handle for registered information. */
 public interface RegistrationHandle {
   /** Delete this registration. */
-  public void remove();
+  void remove();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
index 7284296..ef38303 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
@@ -18,7 +18,7 @@
 import com.google.inject.Provider;
 
 public interface ReloadableRegistrationHandle<T> extends RegistrationHandle {
-  public Key<T> getKey();
+  Key<T> getKey();
 
-  public RegistrationHandle replace(Key<T> key, Provider<T> item);
+  RegistrationHandle replace(Key<T> key, Provider<T> item);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
index a21c2d4..068d9a0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
@@ -62,7 +62,7 @@
   private Charset characterEncoding;
   private long contentLength = -1;
   private boolean gzip = true;
-  private boolean base64 = false;
+  private boolean base64;
   private String attachmentName;
 
   /** @return the MIME type of the result, for HTTP clients. */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
index f95161d..3b32829 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
@@ -18,5 +18,5 @@
  * A view which may change, although the underlying resource did not change
  */
 public interface ETagView<R extends RestResource> extends RestReadView<R> {
-  public String getETag(R rsrc);
+  String getETag(R rsrc);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
index 4345076..633efea 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
@@ -36,6 +36,11 @@
     return new Impl<>(201, value);
   }
 
+  /** HTTP 202 Accepted: accepted as background task. */
+  public static Accepted accepted(String location) {
+    return new Accepted(location);
+  }
+
   /** HTTP 204 No Content: typically used when the resource is deleted. */
   @SuppressWarnings("unchecked")
   public static <T> Response<T> none() {
@@ -47,6 +52,11 @@
     return new Redirect(location);
   }
 
+  /** Arbitrary status code with wrapped result. */
+  public static <T> Response<T> withStatusCode(int statusCode, T value) {
+    return new Impl<>(statusCode, value);
+  }
+
   @SuppressWarnings({"unchecked", "rawtypes"})
   public static <T> T unwrap(T obj) {
     while (obj instanceof Response) {
@@ -168,4 +178,33 @@
       return String.format("[302 Redirect] %s", location);
     }
   }
+
+  /** Accepted as task for asynchronous execution. */
+  public static final class Accepted {
+    private final String location;
+
+    private Accepted(String url) {
+      this.location = url;
+    }
+
+    public String location() {
+      return location;
+    }
+
+    @Override
+    public int hashCode() {
+      return location.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return o instanceof Accepted
+        && ((Accepted) o).location.equals(location);
+    }
+
+    @Override
+    public String toString() {
+      return String.format("[202 Accepted] %s", location);
+    }
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
index 8032531..29c824f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -30,11 +30,11 @@
      * @return time for the Last-Modified header. HTTP truncates the header
      *         value to seconds.
      */
-    public Timestamp getLastModified();
+    Timestamp getLastModified();
   }
 
   /** A resource with an ETag. */
   public interface HasETag {
-    public String getETag();
+    String getETag();
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
index c7deedb..45aec57 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
@@ -17,7 +17,7 @@
 /** Exports current server information to an extension. */
 public interface ServerInformation {
   /** Current state of the server. */
-  public enum State {
+  enum State {
     /**
      * The server is starting up, and network connections are not yet being
      * accepted. Plugins or extensions starting during this time are starting
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
new file mode 100644
index 0000000..648dff8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface ParentWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
+   * describing a link from a parent revision to an external service.
+   *
+   * <p>In order for the web link to be visible
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
+   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
+   * must be set.<p>
+   *
+   * @param projectName Name of the project
+   * @param commit Commit sha1 of the parent revision
+   * @return WebLinkInfo that links to parent commit in external service,
+   * null if there should be no link.
+   */
+  WebLinkInfo getParentWebLink(String projectName, String commit);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index ad74849..b9004f2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.common.WebLinkInfo;
 
 @ExtensionPoint
-public interface PatchSetWebLink extends WebLink{
+public interface PatchSetWebLink extends WebLink {
 
   /**
    * {@link com.google.gerrit.extensions.common.WebLinkInfo}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
index ead7c31..7ad12cd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
@@ -16,12 +16,13 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.client.GerritTopMenu;
+import com.google.gerrit.extensions.client.MenuItem;
 
 import java.util.List;
 
 @ExtensionPoint
 public interface TopMenu {
-  public class MenuEntry {
+  class MenuEntry {
     public final String name;
     public final List<MenuItem> items;
 
@@ -35,27 +36,5 @@
     }
   }
 
-  public class MenuItem {
-    public final String url;
-    public final String name;
-    public final String target;
-    public final String id;
-
-    public MenuItem(String name, String url) {
-      this(name, url, "_blank");
-    }
-
-    public MenuItem(String name, String url, String target) {
-      this(name, url, target, null);
-    }
-
-    public MenuItem(String name, String url, String target, String id) {
-      this.url = url;
-      this.name = name;
-      this.target = target;
-      this.id = id;
-    }
-  }
-
   List<MenuEntry> getEntries();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
index 63172ce..1807673 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -29,10 +29,10 @@
    *         assumed unavailable and not presented. This is usually the same as
    *         {@code setVisible(false)}.
    */
-  public Description getDescription(R resource);
+  Description getDescription(R resource);
 
   /** Describes an action invokable through the web interface. */
-  public static class Description {
+  class Description {
     private String method;
     private String id;
     private String label;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
index e497f7d..fd677ca 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
@@ -24,7 +24,7 @@
   /**
    * Class that holds target defaults for WebLink anchors.
    */
-  public static class Target {
+  class Target {
     /**
      * Opens the link in a new window or tab
      */
diff --git a/gerrit-gpg/BUCK b/gerrit-gpg/BUCK
index 2b258a9..73d9f04 100644
--- a/gerrit-gpg/BUCK
+++ b/gerrit-gpg/BUCK
@@ -8,7 +8,7 @@
   '//lib/guice:guice',
   '//lib/guice:guice-assistedinject',
   '//lib/guice:guice-servlet',
-  '//lib/jgit:jgit',
+  '//lib/jgit/org.eclipse.jgit:jgit',
   '//lib/log:api',
 ]
 
@@ -45,12 +45,12 @@
     ':gpg',
     ':testutil',
     '//gerrit-cache-h2:cache-h2',
-    '//gerrit-lucene:lucene',  
+    '//gerrit-lucene:lucene',
     '//gerrit-server:testutil',
     '//lib:truth',
     '//lib/bouncycastle:bcpg',
     '//lib/bouncycastle:bcprov',
-    '//lib/jgit:junit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
   ],
   source_under_test = [':gpg'],
   visibility = ['//tools/eclipse:classpath'],
diff --git a/gerrit-gpg/BUILD b/gerrit-gpg/BUILD
new file mode 100644
index 0000000..79f50b1
--- /dev/null
+++ b/gerrit-gpg/BUILD
@@ -0,0 +1,58 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+DEPS = [
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-reviewdb:server',
+  '//gerrit-server:server',
+  '//lib:guava',
+  '//lib:gwtorm',
+  '//lib/guice:guice',
+  '//lib/guice:guice-assistedinject',
+  '//lib/guice:guice-servlet',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/log:api',
+]
+
+java_library(
+  name = 'gpg',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = DEPS + [
+    '//lib/bouncycastle:bcpg',
+    '//lib/bouncycastle:bcprov',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
+
+java_library(
+  name = 'testutil',
+  srcs = TESTUTIL_SRCS,
+  deps = DEPS + [
+    ':gpg',
+    '//lib/bouncycastle:bcpg-without-neverlink',
+    '//lib/bouncycastle:bcprov-without-neverlink',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'gpg_tests',
+  srcs = glob(
+    ['src/test/java/**/*.java'],
+    exclude = TESTUTIL_SRCS,
+  ),
+  deps = DEPS + [
+    ':gpg',
+    ':testutil',
+    '//gerrit-cache-h2:cache-h2',
+    '//gerrit-lucene:lucene',
+    '//gerrit-server:testutil',
+    '//lib:truth',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+    '//lib/bouncycastle:bcpg-without-neverlink',
+    '//lib/bouncycastle:bcprov-without-neverlink',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index c3c886f..db6cb7a 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -28,8 +28,11 @@
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,6 +50,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -63,6 +67,8 @@
   @Singleton
   public static class Factory {
     private final Provider<ReviewDb> db;
+    private final AccountIndexCollection accountIndexes;
+    private final Provider<InternalAccountQuery> accountQueryProvider;
     private final String webUrl;
     private final IdentifiedUser.GenericFactory userFactory;
     private final int maxTrustDepth;
@@ -71,9 +77,13 @@
     @Inject
     Factory(@GerritServerConfig Config cfg,
         Provider<ReviewDb> db,
+        AccountIndexCollection accountIndexes,
+        Provider<InternalAccountQuery> accountQueryProvider,
         IdentifiedUser.GenericFactory userFactory,
         @CanonicalWebUrl String webUrl) {
       this.db = db;
+      this.accountIndexes = accountIndexes;
+      this.accountQueryProvider = accountQueryProvider;
       this.webUrl = webUrl;
       this.userFactory = userFactory;
       this.maxTrustDepth = cfg.getInt("receive", null, "maxTrustDepth", 0);
@@ -83,7 +93,7 @@
         Map<Long, Fingerprint> fps =
             Maps.newHashMapWithExpectedSize(strs.length);
         for (String str : strs) {
-          str = CharMatcher.WHITESPACE.removeFrom(str).toUpperCase();
+          str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
           Fingerprint fp = new Fingerprint(BaseEncoding.base16().decode(str));
           fps.put(fp.getId(), fp);
         }
@@ -107,6 +117,8 @@
   }
 
   private final Provider<ReviewDb> db;
+  private final AccountIndexCollection accountIndexes;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
   private final String webUrl;
   private final IdentifiedUser.GenericFactory userFactory;
 
@@ -114,6 +126,8 @@
 
   private GerritPublicKeyChecker(Factory factory) {
     this.db = factory.db;
+    this.accountIndexes = factory.accountIndexes;
+    this.accountQueryProvider = factory.accountQueryProvider;
     this.webUrl = factory.webUrl;
     this.userFactory = factory.userFactory;
     if (factory.trusted != null) {
@@ -139,9 +153,8 @@
     try {
       if (depth == 0 && expectedUser != null) {
         return checkIdsForExpectedUser(key);
-      } else {
-        return checkIdsForArbitraryUser(key);
       }
+      return checkIdsForArbitraryUser(key);
     } catch (PGPException | OrmException e) {
       String msg = "Error checking user IDs for key";
       log.warn(msg + " " + keyIdToString(key.getKeyID()), e);
@@ -164,12 +177,26 @@
 
   private CheckResult checkIdsForArbitraryUser(PGPPublicKey key)
       throws PGPException, OrmException {
-    AccountExternalId extId = db.get().accountExternalIds().get(
-        toExtIdKey(key));
-    if (extId == null) {
-      return CheckResult.bad("Key is not associated with any users");
+    IdentifiedUser user;
+    if (accountIndexes.getSearchIndex() != null) {
+      List<AccountState> accountStates =
+          accountQueryProvider.get().byExternalId(toExtIdKey(key).get());
+      if (accountStates.isEmpty()) {
+        return CheckResult.bad("Key is not associated with any users");
+      }
+      if (accountStates.size() > 1) {
+        return CheckResult.bad("Key is associated with multiple users");
+      }
+      user = userFactory.create(accountStates.get(0));
+    } else {
+      AccountExternalId extId = db.get().accountExternalIds().get(
+          toExtIdKey(key));
+      if (extId == null) {
+        return CheckResult.bad("Key is not associated with any users");
+      }
+      user = userFactory.create(extId.getAccountId());
     }
-    IdentifiedUser user = userFactory.create(db, extId.getAccountId());
+
     Set<String> allowedUserIds = getAllowedUserIds(user);
     if (allowedUserIds.isEmpty()) {
       return CheckResult.bad("No identities found for user");
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
index 3d939a1..b45bce5 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -340,6 +340,13 @@
         toAdd.clear();
         toRemove.clear();
         break;
+      case FORCED:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
       default:
         break;
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index cdc3c62..59157bd 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -88,10 +88,9 @@
       // key is not ultimately trusted. Assume anyone with Submit permission to
       // the branch is able to verify during review that the code is legitimate.
       return result.isOk();
-    } else {
-      // Directly updating one or more refs: require a trusted key.
-      return result.isTrusted();
     }
+    // Directly updating one or more refs: require a trusted key.
+    return result.isTrusted();
   }
 
   private static boolean onlyMagicBranches(Iterable<ReceiveCommand> commands) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index e6720db..6809234 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -57,6 +57,11 @@
   }
 
   @Override
+  public boolean isEnabled() {
+    return true;
+  }
+
+  @Override
   public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException {
     try {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
index 932f439..e65ebf2 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
@@ -62,6 +62,11 @@
     private static final String MSG = "GPG key APIs disabled";
 
     @Override
+    public boolean isEnabled() {
+      return false;
+    }
+
+    @Override
     public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account) {
       throw new NotImplementedException(MSG);
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index baac714..cac0e72 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -45,14 +46,17 @@
   private final Provider<PersonIdent> serverIdent;
   private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
+  private final AccountCache accountCache;
 
   @Inject
   DeleteGpgKey(@GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<ReviewDb> db,
-      Provider<PublicKeyStore> storeProvider) {
+      Provider<PublicKeyStore> storeProvider,
+      AccountCache accountCache) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.storeProvider = storeProvider;
+    this.accountCache = accountCache;
   }
 
   @Override
@@ -64,6 +68,7 @@
         AccountExternalId.SCHEME_GPGKEY,
         BaseEncoding.base16().encode(key.getFingerprint()));
     db.get().accountExternalIds().deleteKeys(Collections.singleton(extIdKey));
+    accountCache.evict(rsrc.getUser().getAccountId());
 
     try (PublicKeyStore store = storeProvider.get()) {
       store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
@@ -80,6 +85,14 @@
         case NO_CHANGE:
         case FAST_FORWARD:
           break;
+        case FORCED:
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NEW:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
         default:
           throw new ResourceConflictException(
               "Failed to delete public key: " + saveResult);
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index a136007..49657c6 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -70,16 +71,19 @@
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
 
   @Inject
   GpgKeys(DynamicMap<RestView<GpgKey>> views,
       Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory) {
     this.views = views;
     this.db = db;
+    this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
   }
@@ -87,7 +91,6 @@
   @Override
   public ListGpgKeys list()
       throws ResourceNotFoundException, AuthException {
-    checkEnabled();
     return new ListGpgKeys();
   }
 
@@ -95,8 +98,8 @@
   public GpgKey parse(AccountResource parent, IdString id)
       throws ResourceNotFoundException, PGPException, OrmException,
       IOException {
-    checkEnabled();
-    String str = CharMatcher.WHITESPACE.removeFrom(id.get()).toUpperCase();
+    checkVisible(self, parent);
+    String str = CharMatcher.whitespace().removeFrom(id.get()).toUpperCase();
     if ((str.length() != 8 && str.length() != 40)
         || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
       throw new ResourceNotFoundException(id);
@@ -119,7 +122,7 @@
   static byte[] parseFingerprint(String str,
       Iterable<AccountExternalId> existingExtIds)
       throws ResourceNotFoundException {
-    str = CharMatcher.WHITESPACE.removeFrom(str).toUpperCase();
+    str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
     if ((str.length() != 8 && str.length() != 40)
         || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
       throw new ResourceNotFoundException(str);
@@ -151,7 +154,9 @@
   public class ListGpgKeys implements RestReadView<AccountResource> {
     @Override
     public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
-        throws OrmException, PGPException, IOException {
+        throws OrmException, PGPException, IOException,
+        ResourceNotFoundException {
+      checkVisible(self, rsrc);
       Map<String, GpgKeyInfo> keys = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
         for (AccountExternalId extId : getGpgExtIds(rsrc)) {
@@ -225,10 +230,14 @@
     return NB.decodeInt64(fp, fp.length - 8);
   }
 
-  static void checkEnabled() throws ResourceNotFoundException {
+  static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
+      throws ResourceNotFoundException {
     if (!BouncyCastleUtil.havePGP()) {
       throw new ResourceNotFoundException("GPG not enabled");
     }
+    if (self.get() != rsrc.getUser()) {
+      throw new ResourceNotFoundException();
+    }
   }
 
   public static GpgKeyInfo toJson(PGPPublicKey key, CheckResult checkResult)
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 91c4494..2deae3f 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -39,12 +39,18 @@
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.PostGpgKeys.Input;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -80,28 +86,40 @@
   private final Logger log = LoggerFactory.getLogger(getClass());
   private final Provider<PersonIdent> serverIdent;
   private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
   private final AddKeySender.Factory addKeyFactory;
+  private final AccountCache accountCache;
+  private final AccountIndexCollection accountIndexes;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
 
   @Inject
   PostGpgKeys(@GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeyFactory) {
+      AddKeySender.Factory addKeyFactory,
+      AccountCache accountCache,
+      AccountIndexCollection accountIndexes,
+      Provider<InternalAccountQuery> accountQueryProvider) {
     this.serverIdent = serverIdent;
     this.db = db;
+    this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
     this.addKeyFactory = addKeyFactory;
+    this.accountCache = accountCache;
+    this.accountIndexes = accountIndexes;
+    this.accountQueryProvider = accountQueryProvider;
   }
 
   @Override
   public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
       throws ResourceNotFoundException, BadRequestException,
       ResourceConflictException, PGPException, OrmException, IOException {
-    GpgKeys.checkEnabled();
+    GpgKeys.checkVisible(self, rsrc);
 
     List<AccountExternalId> existingExtIds =
         GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
@@ -114,15 +132,28 @@
       for (PGPPublicKeyRing keyRing : newKeys) {
         PGPPublicKey key = keyRing.getPublicKey();
         AccountExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
-        AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
-        if (existing != null) {
-          if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
-            throw new ResourceConflictException(
-                "GPG key already associated with another account");
+        if (accountIndexes.getSearchIndex() != null) {
+          Account account = getAccountByExternalId(extIdKey.get());
+          if (account != null) {
+            if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+              throw new ResourceConflictException(
+                  "GPG key already associated with another account");
+            }
+          } else {
+            newExtIds.add(
+                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
           }
         } else {
-          newExtIds.add(
-              new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+          AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
+          if (existing != null) {
+            if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
+              throw new ResourceConflictException(
+                  "GPG key already associated with another account");
+            }
+          } else {
+            newExtIds.add(
+                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+          }
         }
       }
 
@@ -137,6 +168,7 @@
               return toExtIdKey(fp.get());
             }
           }));
+      accountCache.evict(rsrc.getUser().getAccountId());
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
   }
@@ -228,6 +260,12 @@
           break;
         case NO_CHANGE:
           break;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
         default:
           // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
           throw new ResourceConflictException(
@@ -236,12 +274,34 @@
     }
   }
 
-  private final AccountExternalId.Key toExtIdKey(byte[] fp) {
+  private AccountExternalId.Key toExtIdKey(byte[] fp) {
     return new AccountExternalId.Key(
         AccountExternalId.SCHEME_GPGKEY,
         BaseEncoding.base16().encode(fp));
   }
 
+  private Account getAccountByExternalId(String externalId)
+      throws OrmException {
+    List<AccountState> accountStates =
+        accountQueryProvider.get().byExternalId(externalId);
+
+    if (accountStates.isEmpty()) {
+      return null;
+    }
+
+    if (accountStates.size() > 1) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("GPG key ").append(externalId)
+          .append(" associated with multiple accounts: ");
+      Joiner.on(", ").appendTo(msg,
+          Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
+      log.error(msg.toString());
+      throw new IllegalStateException(msg.toString());
+    }
+
+    return accountStates.get(0).getAccount();
+  }
+
   private Map<String, GpgKeyInfo> toJson(
       Collection<PGPPublicKeyRing> keys,
       Set<Fingerprint> deleted, PublicKeyStore store, IdentifiedUser user)
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 4df9d37..e39c8ae 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -65,6 +66,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -107,7 +109,8 @@
     cfg.setStringList("receive", null, "trustedKey", ImmutableList.of(
         Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
         Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector = Guice.createInjector(new InMemoryModule(cfg));
+    Injector injector = Guice.createInjector(
+        new InMemoryModule(cfg, new TestNotesMigration()));
 
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
@@ -149,12 +152,12 @@
   private IdentifiedUser addUser(String name) throws Exception {
     AuthRequest req = AuthRequest.forUser(name);
     Account.Id id = accountManager.authenticate(req).getAccountId();
-    return userFactory.create(Providers.of(db), id);
+    return userFactory.create(id);
   }
 
-  private IdentifiedUser reloadUser() {
+  private IdentifiedUser reloadUser() throws IOException {
     accountCache.evict(userId);
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
     return user;
   }
 
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 99e96a2..bd71bc5 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -339,6 +339,13 @@
       case FAST_FORWARD:
       case FORCED:
         break;
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case NO_CHANGE:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
       default:
         throw new AssertionError(result);
     }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
index 9c0a908..11e9768 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -149,7 +149,7 @@
         RevWalk rw = new RevWalk(reader)) {
       NoteMap notes = NoteMap.read(
           reader, tr.getRevWalk().parseCommit(
-            tr.getRepository().getRef(REFS_GPG_KEYS).getObjectId()));
+            tr.getRepository().exactRef(REFS_GPG_KEYS).getObjectId()));
       String contents = new String(
           reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(),
           UTF_8);
@@ -242,7 +242,7 @@
       actual.add(userIds.next());
     }
 
-    assertEquals(actual, Arrays.asList(expected));
+    assertEquals(Arrays.asList(expected), actual);
   }
 
   private CommitBuilder newCommitBuilder() {
diff --git a/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java b/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java
index 8072d75..728f276 100644
--- a/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java
+++ b/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java
@@ -91,7 +91,7 @@
 
   private static final MimeTypes MIME_TYPES = new MimeTypes();
 
-  private static final String TIME_IN_THE_PAST = "Fri, 01 Jan 1990 00:00:00 GMT";
+  private static final String TIME_IN_THE_PAST = "Mon, 01 Jan 1990 00:00:00 GMT";
 
   private final SourceHandler handler;
   private final JsonExporter jsonExporter;
diff --git a/gerrit-gwtexpui/BUCK b/gerrit-gwtexpui/BUCK
index 4b2cb03..79a97a9 100644
--- a/gerrit-gwtexpui/BUCK
+++ b/gerrit-gwtexpui/BUCK
@@ -7,7 +7,7 @@
   resources = [
     SRC + 'clippy/client/clippy.css',
     SRC + 'clippy/client/clippy.swf',
-    SRC + 'clippy/client/clipboard-16.png',
+    SRC + 'clippy/client/page_white_copy.png',
     SRC + 'clippy/client/CopyableLabelText.properties',
   ],
   provided_deps = ['//lib/gwt:user'],
@@ -15,7 +15,7 @@
     ':SafeHtml',
     ':UserAgent',
     '//lib:LICENSE-clippy',
-    '//lib:LICENSE-drifty',
+    '//lib:LICENSE-silk_icons',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
new file mode 100644
index 0000000..d3b03ef
--- /dev/null
+++ b/gerrit-gwtexpui/BUILD
@@ -0,0 +1,114 @@
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRC = 'src/main/java/com/google/gwtexpui/'
+
+gwt_module(
+  name = 'Clippy',
+  srcs = glob([SRC + 'clippy/client/*.java']),
+  gwt_xml = SRC + 'clippy/Clippy.gwt.xml',
+  resources = [
+    SRC + 'clippy/client/clippy.css',
+    SRC + 'clippy/client/clippy.swf',
+    SRC + 'clippy/client/page_white_copy.png',
+    SRC + 'clippy/client/CopyableLabelText.properties',
+  ],
+  deps = [
+    ':SafeHtml',
+    ':UserAgent',
+    '//lib/gwt:user',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'CSS',
+  srcs = glob([SRC + 'css/rebind/*.java']),
+  resources = [SRC + 'css/CSS.gwt.xml'],
+  deps = ['//lib/gwt:dev'],
+  visibility = ['//visibility:public'],
+)
+
+gwt_module(
+  name = 'GlobalKey',
+  srcs = glob([SRC + 'globalkey/client/*.java']),
+  gwt_xml = SRC + 'globalkey/GlobalKey.gwt.xml',
+  resources = [
+    SRC + 'globalkey/client/KeyConstants.properties',
+    SRC + 'globalkey/client/key.css',
+  ],
+  deps = [
+    ':SafeHtml',
+    ':UserAgent',
+    '//lib/gwt:user',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'linker_server',
+  srcs = glob([SRC + 'linker/server/*.java']),
+  deps = ['//lib:servlet-api-3_1'],
+  visibility = ['//visibility:public'],
+)
+
+gwt_module(
+  name = 'Progress',
+  srcs = glob([SRC + 'progress/client/*.java']),
+  gwt_xml = SRC + 'progress/Progress.gwt.xml',
+  resources = [SRC + 'progress/client/progress.css'],
+  deps = ['//lib/gwt:user'],
+  visibility = ['//visibility:public'],
+)
+
+gwt_module(
+  name = 'SafeHtml',
+  srcs = glob([SRC + 'safehtml/client/*.java']),
+  gwt_xml = SRC + 'safehtml/SafeHtml.gwt.xml',
+  resources = [SRC + 'safehtml/client/safehtml.css'],
+  deps = ['//lib/gwt:user'],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'SafeHtml_tests',
+  srcs = glob([
+    'src/test/java/com/google/gwtexpui/safehtml/client/**/*.java',
+  ]),
+  deps = [
+    ':SafeHtml',
+    '//lib:truth',
+    '//lib/gwt:user',
+    '//lib/gwt:dev',
+  ],
+)
+
+gwt_module(
+  name = 'UserAgent',
+  srcs = glob([SRC + 'user/client/*.java']),
+  gwt_xml = SRC + 'user/User.gwt.xml',
+  resources = [SRC + 'user/client/tooltip.css'],
+  deps = ['//lib/gwt:user'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  srcs = glob([SRC + 'server/*.java']),
+  deps = ['//lib:servlet-api-3_1'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'client-src-lib',
+  srcs = [],
+  resources = glob(
+    [SRC + n for n in [
+      'clippy/**/*',
+      'globalkey/**/*',
+      'safehtml/**/*',
+      'user/**/*',
+    ]]
+  ),
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
index dd3cc18..a97b392 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
@@ -21,7 +21,7 @@
 import com.google.gwt.resources.client.ImageResource;
 
 public interface ClippyResources extends ClientBundle {
-  public static final ClippyResources I = GWT.create(ClippyResources.class);
+  ClippyResources I = GWT.create(ClippyResources.class);
 
   @Source("clippy.css")
   ClippyCss css();
@@ -30,6 +30,6 @@
   @DoNotEmbed
   DataResource swf();
 
-  @Source("clipboard-16.png")
+  @Source("page_white_copy.png")
   ImageResource clipboard();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
index 4d1b837..8e4d090 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 interface CopyableLabelText extends Constants {
-  static final CopyableLabelText I = GWT.create(CopyableLabelText.class);
+  CopyableLabelText I = GWT.create(CopyableLabelText.class);
 
   String tooltip();
   String copied();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png
deleted file mode 100644
index 9c6e10a..0000000
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png
new file mode 100644
index 0000000..a9f31a2
--- /dev/null
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png
Binary files differ
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
index 05f41d4..4b67260 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
@@ -15,5 +15,5 @@
 package com.google.gwtexpui.globalkey.client;
 
 public interface KeyCommandFilter {
-  public boolean include(KeyCommand key);
+  boolean include(KeyCommand key);
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
index d26ca8c..2b5984d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 public interface KeyConstants extends Constants {
-  public static final KeyConstants I = GWT.create(KeyConstants.class);
+  KeyConstants I = GWT.create(KeyConstants.class);
 
   String applicationSection();
   String showHelp();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java
index a52ca2a..562e12d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyResources.java
@@ -18,7 +18,7 @@
 import com.google.gwt.resources.client.ClientBundle;
 
 public interface KeyResources extends ClientBundle {
-  public static final KeyResources I = GWT.create(KeyResources.class);
+  KeyResources I = GWT.create(KeyResources.class);
 
   @Source("key.css")
   KeyCss css();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
index 38a488e..fd0da74 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextArea.java
@@ -14,7 +14,6 @@
 
 package com.google.gwtexpui.globalkey.client;
 
-import com.google.gwt.dom.client.Element;
 import com.google.gwt.user.client.ui.TextArea;
 
 public class NpTextArea extends TextArea {
@@ -22,11 +21,6 @@
     addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
   }
 
-  public NpTextArea(final Element element) {
-    super(element);
-    addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-  }
-
   public void setSpellCheck(boolean spell) {
     getElement().setPropertyBoolean("spellcheck", spell);
   }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
index 6c820a8..a33f605 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
@@ -30,7 +30,7 @@
  * Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
  */
 public class UserAgentRule {
-  private static final Pattern msie = compile(".*msie ([0-9]+)\\.([0-9]+).*");
+  private static final Pattern msie = compile(".*msie ([0-11]+)\\.([0-11]+).*");
   private static final Pattern gecko = compile(".*rv:([0-9]+)\\.([0-9]+).*");
 
   public String getName() {
@@ -58,6 +58,9 @@
       Matcher m = msie.matcher(ua);
       if (m.matches() && m.groupCount() == 2) {
         int v = makeVersion(m);
+        if (v >= 11000) {
+          return "ie11";
+        }
         if (v >= 10000) {
           return "ie10";
         }
@@ -70,6 +73,8 @@
       }
       return null;
 
+    } else if (ua.contains("edge")) {
+      return "edge";
     } else if (ua.contains("gecko")) {
       Matcher m = gecko.matcher(ua);
       if (m.matches() && m.groupCount() == 2) {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java
index 0276e9a..6bcf2c4 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressResources.java
@@ -18,7 +18,7 @@
 import com.google.gwt.resources.client.ClientBundle;
 
 public interface ProgressResources extends ClientBundle {
-  public static final ProgressResources I = GWT.create(ProgressResources.class);
+  ProgressResources I = GWT.create(ProgressResources.class);
 
   @Source("progress.css")
   ProgressCss css();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
index 6eaa7fd..a84835e 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
@@ -102,7 +102,7 @@
     }
   }
 
-  private static interface Tag {
+  private interface Tag {
     void assertSafe(String name, String value);
   }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
index f7bc907..487e613 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
@@ -22,7 +22,7 @@
    * @return regular expression to match substrings with; should be treated as
    *     immutable.
    */
-  public RegExp pattern();
+  RegExp pattern();
 
   /**
    * Find and replace a single instance of this pattern in an input.
@@ -36,5 +36,5 @@
    * @return result of regular expression replacement.
    * @throws IllegalArgumentException if the input could not be safely sanitized.
    */
-  public String replace(String input);
+  String replace(String input);
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index 1ca688b..cf5a445 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -17,6 +17,10 @@
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
 
 /**
  * A suggestion oracle that tries to highlight the matched text.
@@ -56,7 +60,7 @@
   }
 
   protected String getQueryPattern(final String query) {
-    return "(" + escape(query) + ")";
+    return query;
   }
 
   /**
@@ -84,19 +88,59 @@
         ds = escape(ds);
       }
 
-      // We now surround qstr by <strong>. But the chosen approach is not too
-      // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
-      // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
-      // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
-      // as repairing those mangled escapes is easier than not mangling them in
-      // the first place, we repair them afterwards.
-      ds = sgi(ds, qstr, "<strong>$1</strong>");
+      StringBuilder pattern = new StringBuilder();
+      for (String qterm : splitQuery(qstr)) {
+        qterm = escape(qterm);
+        // We now surround qstr by <strong>. But the chosen approach is not too
+        // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
+        // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
+        // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
+        // as repairing those mangled escapes is easier than not mangling them in
+        // the first place, we repair them afterwards.
+
+        if (pattern.length() > 0) {
+          pattern.append("|");
+        }
+        pattern.append(qterm);
+      }
+
+      ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
+
       // Repairing <strong>-ed escapes.
       ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
 
       displayString = ds;
     }
 
+    /**
+     * Split the query by whitespace and filter out query terms which are
+     * substrings of other query terms.
+     */
+    private static List<String> splitQuery(String query) {
+      List<String> queryTerms = Arrays.asList(query.split("\\s+"));
+      Collections.sort(queryTerms, new Comparator<String>() {
+        @Override
+        public int compare(String s1, String s2) {
+          return Integer.compare(s2.length(), s1.length());
+        }
+      });
+
+      List<String> result = new ArrayList<>();
+      for (String s : queryTerms) {
+        boolean add = true;
+        for (String queryTerm : result) {
+          if (queryTerm.toLowerCase().contains(s.toLowerCase())) {
+            add = false;
+            break;
+          }
+        }
+        if (add) {
+          result.add(s);
+        }
+      }
+      return result;
+    }
+
     private static native String sgi(String inString, String pat, String newHtml)
     /*-{ return inString.replace(RegExp(pat, 'gi'), newHtml); }-*/;
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
index b8f0800..10c2a78 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
@@ -138,7 +138,7 @@
           "(?:[(]" + part + "*" + "[)])*" +
           part + "*" +
         ")",
-        "<a href=\"$1\" target=\"_blank\">$1</a>");
+        "<a href=\"$1\" target=\"_blank\" rel=\"nofollow\">$1</a>");
   }
 
   /**
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
index 950400a..d94f243 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
@@ -129,9 +129,8 @@
   }
 
   public static boolean hasCacheHeader(HttpServletResponse res) {
-    return res.getHeader("Cache-Control") != null
-        || res.getHeader("Expires") != null
-        || "no-cache".equals(res.getHeader("Pragma"));
+    return res.containsHeader("Cache-Control")
+        || res.containsHeader("Expires");
   }
 
   private static void cache(HttpServletResponse res,
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
index e3ab034..7939697 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
@@ -23,7 +23,7 @@
 /** Displays custom tooltip message below an element. */
 public class Tooltip {
   interface Resources extends ClientBundle {
-    static final Resources I = GWT.create(Resources.class);
+    Resources I = GWT.create(Resources.class);
 
     @Source("tooltip.css")
     Css css();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
index 2070200..bbef449 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
@@ -152,6 +152,25 @@
   private static native void bustOutOfIFrame(String newloc)
   /*-{ top.location.href = newloc }-*/;
 
+  /**
+   * Test if Gerrit is running on a mobile browser. This check could be
+   * incomplete, but should cover most cases. Regexes shamelessly borrowed from
+   * CodeMirror.
+   */
+  public static native boolean isMobile() /*-{
+    var ua = $wnd.navigator.userAgent;
+    var ios = /AppleWebKit/.test(ua) && /Mobile\/\w+/.test(ua);
+    return ios
+        || /Android|webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(ua);
+  }-*/;
+
+  /**
+   * Check if the height of the browser view is greater than its width.
+   */
+  public static boolean isPortrait() {
+    return Window.getClientHeight() > Window.getClientWidth();
+  }
+
   private UserAgent() {
   }
 }
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
index bf96d77..8fe743e 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
@@ -25,7 +25,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a> B");
+        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a> B");
   }
 
   @Test
@@ -34,7 +35,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"https://go.here/\" target=\"_blank\">https://go.here/</a> B");
+        "A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">https://go.here/</a> B");
   }
 
   @Test
@@ -43,7 +45,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A (<a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>) B");
+        "A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>) B");
   }
 
   @Test
@@ -52,7 +55,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/#m()\" target=\"_blank\">http://go.here/#m()</a> B");
+        "A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/#m()</a> B");
   }
 
   @Test
@@ -61,7 +65,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A &lt;<a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>&gt; B");
+        "A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>&gt; B");
   }
 
   @Test
@@ -70,7 +75,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/foo\" target=\"_blank\">http://go.here/foo</a> B");
+        "A <a href=\"http://go.here/foo\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/foo</a> B");
   }
 
   @Test
@@ -79,7 +85,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>. B");
+        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>. B");
   }
 
   @Test
@@ -88,7 +95,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>, B");
+        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>, B");
   }
 
   @Test
@@ -97,7 +105,8 @@
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/.\" target=\"_blank\">http://go.here/.</a>. B");
+        "A <a href=\"http://go.here/.\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/.</a>. B");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
index 41d6f37..8f6ff8d 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
@@ -65,7 +65,8 @@
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a> B</p>");
+        "<p>A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a> B</p>");
   }
 
   @Test
@@ -74,7 +75,8 @@
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"https://go.here/\" target=\"_blank\">https://go.here/</a> B</p>");
+        "<p>A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">https://go.here/</a> B</p>");
   }
 
   @Test
@@ -83,7 +85,8 @@
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "<p>A (<a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>) B</p>");
+        "<p>A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>) B</p>");
   }
 
   @Test
@@ -92,7 +95,8 @@
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"http://go.here/#m()\" target=\"_blank\">http://go.here/#m()</a> B</p>");
+        "<p>A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/#m()</a> B</p>");
   }
 
   @Test
@@ -101,7 +105,8 @@
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
     assertThat(n.asString()).isEqualTo(
-        "<p>A &lt;<a href=\"http://go.here/\" target=\"_blank\">http://go.here/</a>&gt; B</p>");
+        "<p>A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+        + ">http://go.here/</a>&gt; B</p>");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtui-common/BUCK b/gerrit-gwtui-common/BUCK
index 436714a..ef78d98 100644
--- a/gerrit-gwtui-common/BUCK
+++ b/gerrit-gwtui-common/BUCK
@@ -41,7 +41,7 @@
   binary_jar = ':diffy_image_files_ln',
   deps = [
     '//lib:LICENSE-diffy',
-    '//lib:LICENSE-CC-BY3.0',
+    '//lib:LICENSE-CC-BY3.0-unported',
   ],
   visibility = ['PUBLIC'],
 )
@@ -64,7 +64,7 @@
     ':client',
     '//lib:junit',
     '//lib/gwt:user',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
   ],
   source_under_test = [':client'],
   vm_args = ['-Xmx512m'],
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml b/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
index c147195..c01dea1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/GerritGwtUICommon.gwt.xml
@@ -22,5 +22,22 @@
   <inherits name='com.google.gwtexpui.globalkey.GlobalKey'/>
   <inherits name='com.google.gwtexpui.progress.Progress'/>
   <inherits name='com.google.gwtexpui.safehtml.SafeHtml'/>
-  <source path='client' />
+  <source path='client'>
+    <include name='AccountFormatter.java'/>
+    <include name='CommonConstants.java'/>
+    <include name='CommonMessages.java'/>
+    <include name='DateFormatter.java'/>
+    <include name='GerritUiExtensionPoint.java'/>
+    <include name='RelativeDateFormatter.java'/>
+    <include name='Resources.java'/>
+    <include name='CommonConstants.properties'/>
+    <include name='CommonMessages.properties'/>
+    <include name='info/*.java'/>
+    <include name='rpc/NativeMap.java'/>
+    <include name='rpc/Natives.java'/>
+    <include name='rpc/NativeString.java'/>
+    <include name='rpc/TransformCallback.java'/>
+    <include name='ui/HighlightSuggestion.java'/>
+    <include name='ui/RemoteSuggestOracle.java'/>
+  </source>
 </module>
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
index 032c47c..5d0b93f 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 public interface CommonConstants extends Constants {
-  public static final CommonConstants C = GWT.create(CommonConstants.class);
+  CommonConstants C = GWT.create(CommonConstants.class);
 
   String inTheFuture();
   String month();
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
index aa5e3cf..5a5b4a3 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Messages;
 
 public interface CommonMessages extends Messages {
-  public static final CommonMessages M = GWT.create(CommonMessages.class);
+  CommonMessages M = GWT.create(CommonMessages.class);
 
   String secondsAgo(long seconds);
   String minutesAgo(long minutes);
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
index b6af366..b357737 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.client;
 
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gwt.i18n.client.DateTimeFormat;
 
 import java.util.Date;
@@ -28,7 +28,7 @@
   private final DateTimeFormat mDate;
   private final DateTimeFormat dtfmt;
 
-  public DateFormatter(AccountPreferencesInfo prefs) {
+  public DateFormatter(GeneralPreferences prefs) {
     String fmt_sTime = prefs.timeFormat().getFormat();
     String fmt_sDate = prefs.dateFormat().getShortFormat();
     String fmt_mDate = prefs.dateFormat().getLongFormat();
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
index 61f73c0..0a339a1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
@@ -20,6 +20,8 @@
   CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
   CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
   CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+  CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
+  CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
 
   /* MyPasswordScreen */
   PASSWORD_SCREEN_BOTTOM,
@@ -34,6 +36,6 @@
   PROJECT_INFO_SCREEN_TOP, PROJECT_INFO_SCREEN_BOTTOM;
 
   public enum Key {
-    ACCOUNT_INFO, CHANGE_INFO, PROJECT_NAME
+    ACCOUNT_INFO, CHANGE_INFO, PROJECT_NAME, REVISION_INFO
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
index eb4b0ba..2165db2 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
@@ -51,9 +51,8 @@
       long seconds = round(ageMillis, SECOND_IN_MILLIS);
       if (seconds == 1) {
         return C.oneSecondAgo();
-      } else {
-        return M.secondsAgo(seconds);
       }
+      return M.secondsAgo(seconds);
     }
 
     // minutes
@@ -61,9 +60,8 @@
       long minutes = round(ageMillis, MINUTE_IN_MILLIS);
       if (minutes == 1) {
         return C.oneMinuteAgo();
-      } else {
-        return M.minutesAgo(minutes);
       }
+      return M.minutesAgo(minutes);
     }
 
     // hours
@@ -71,9 +69,8 @@
       long hours = round(ageMillis, HOUR_IN_MILLIS);
       if (hours == 1) {
         return C.oneHourAgo();
-      } else {
-        return M.hoursAgo(hours);
       }
+      return M.hoursAgo(hours);
     }
 
     // up to 14 days use days
@@ -81,9 +78,8 @@
       long days = round(ageMillis, DAY_IN_MILLIS);
       if (days == 1) {
         return C.oneDayAgo();
-      } else {
-        return M.daysAgo(days);
       }
+      return M.daysAgo(days);
     }
 
     // up to 10 weeks use weeks
@@ -91,9 +87,8 @@
       long weeks = round(ageMillis, WEEK_IN_MILLIS);
       if (weeks == 1) {
         return C.oneWeekAgo();
-      } else {
-        return M.weeksAgo(weeks);
       }
+      return M.weeksAgo(weeks);
     }
 
     // months
@@ -101,9 +96,8 @@
       long months = round(ageMillis, MONTH_IN_MILLIS);
       if (months == 1) {
         return C.oneMonthAgo();
-      } else {
-        return M.monthsAgo(months);
       }
+      return M.monthsAgo(months);
     }
 
     // up to 5 years use "year, months" rounded to months
@@ -114,18 +108,16 @@
       String monthLabel = (months > 1) ? C.months() : (months == 1 ? C.month() : "");
       if (months == 0) {
         return M.years0MonthsAgo(years, yearLabel);
-      } else {
-        return M.yearsMonthsAgo(years, yearLabel, months, monthLabel);
       }
+      return M.yearsMonthsAgo(years, yearLabel, months, monthLabel);
     }
 
     // years
     long years = round(ageMillis, YEAR_IN_MILLIS);
     if (years == 1) {
       return C.oneYearAgo();
-    } else {
-      return M.yearsAgo(years);
     }
+    return M.yearsAgo(years);
   }
 
   private static long upperLimit(long unit) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
index a5a02cd..95751fa 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
@@ -18,93 +18,115 @@
 import com.google.gwt.resources.client.ImageResource;
 
 public interface Resources extends ClientBundle {
-  @Source("addFileComment.png")
-  public ImageResource addFileComment();
+  /**
+   * silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/
+   */
+  @Source("note_add.png")
+  ImageResource addFileComment();
 
-  @Source("arrowDown.png")
-  public ImageResource arrowDown();
+  @Source("tag_blue_add.png")
+  ImageResource addHashtag();
 
-  @Source("arrowRight.png")
-  public ImageResource arrowRight();
+  @Source("user_add.png")
+  ImageResource addUser();
 
-  @Source("arrowUp.png")
-  public ImageResource arrowUp();
+  // derived from resultset_next.png
+  @Source("resultset_down_gray.png")
+  ImageResource arrowDown();
 
-  @Source("deleteHover.png")
-  public ImageResource deleteHover();
+  // derived from resultset_next.png
+  @Source("resultset_next_gray.png")
+  ImageResource arrowRight();
 
-  @Source("deleteNormal.png")
-  public ImageResource deleteNormal();
+  // derived from resultset_next.png
+  @Source("resultset_up_gray.png")
+  ImageResource arrowUp();
 
-  @Source("diffy26.png")
-  public ImageResource gerritAvatar26();
+  @Source("lightbulb.png")
+  ImageResource blame();
 
-  @Source("downloadIcon.png")
-  public ImageResource downloadIcon();
+  @Source("page_white_put.png")
+  ImageResource downloadIcon();
 
-  @Source("draftComments.png")
-  public ImageResource draftComments();
+  // derived from comment.png
+  @Source("comment_draft.png")
+  ImageResource draftComments();
 
-  @Source("editText.png")
-  public ImageResource edit();
+  @Source("page_edit.png")
+  ImageResource edit();
 
-  @Source("editUndo.png")
-  public ImageResource editUndo();
+  @Source("arrow_undo.png")
+  ImageResource editUndo();
 
-  @Source("gear.png")
-  public ImageResource gear();
+  @Source("cog.png")
+  ImageResource gear();
 
+  @Source("tick.png")
+  ImageResource greenCheck();
+
+  @Source("tag_blue.png")
+  ImageResource hashtag();
+
+  @Source("lightbulb.png")
+  ImageResource info();
+
+  @Source("find.png")
+  ImageResource queryIcon();
+
+  @Source("lock.png")
+  ImageResource readOnly();
+
+  @Source("cross.png")
+  ImageResource redNot();
+
+  @Source("disk.png")
+  ImageResource save();
+
+  @Source("star.png")
+  ImageResource starFilled();
+
+  // derived from star.png
+  @Source("star-open.png")
+  ImageResource starOpen();
+
+  @Source("exclamation.png")
+  ImageResource warning();
+
+  @Source("help.png")
+  ImageResource question();
+
+  /**
+   * tango icon library (public domain):
+   * http://tango.freedesktop.org/Tango_Icon_Library
+   */
   @Source("goNext.png")
-  public ImageResource goNext();
+  ImageResource goNext();
 
   @Source("goPrev.png")
-  public ImageResource goPrev();
+  ImageResource goPrev();
 
   @Source("goUp.png")
-  public ImageResource goUp();
-
-  @Source("greenCheck.png")
-  public ImageResource greenCheck();
-
-  @Source("info.png")
-  public ImageResource info();
+  ImageResource goUp();
 
   @Source("listAdd.png")
-  public ImageResource listAdd();
+  ImageResource listAdd();
 
-  @Source("mediaFloppy.png")
-  public ImageResource save();
-
+  // derived from important.png
   @Source("merge.png")
-  public ImageResource merge();
+  ImageResource merge();
 
-  @Source("queryIcon.png")
-  public ImageResource queryIcon();
-
-  @Source("readOnly.png")
-  public ImageResource readOnly();
-
-  @Source("redNot.png")
-  public ImageResource redNot();
-
+  /**
+   * contributed by the artist under Apache2.0
+   */
   @Source("sideBySideDiff.png")
-  public ImageResource sideBySideDiff();
-
-  @Source("starFilled.png")
-  public ImageResource starFilled();
-
-  @Source("starOpen.png")
-  public ImageResource starOpen();
-
-  @Source("undoNormal.png")
-  public ImageResource undoNormal();
+  ImageResource sideBySideDiff();
 
   @Source("unifiedDiff.png")
-  public ImageResource unifiedDiff();
+  ImageResource unifiedDiff();
 
-  @Source("warning.png")
-  public ImageResource warning();
-
-  @Source("question.png")
-  public ImageResource question();
+  /**
+   * contributed by the artist under CC-BY3.0
+   */
+  @Source("diffy26.png")
+  ImageResource gerritAvatar26();
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
index 6ac0404..7679799 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.JsArrayString;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
 
 import java.sql.Timestamp;
@@ -29,6 +30,8 @@
   public final native int _accountId() /*-{ return this._account_id || 0; }-*/;
   public final native String name() /*-{ return this.name; }-*/;
   public final native String email() /*-{ return this.email; }-*/;
+  public final native JsArrayString secondaryEmails()
+      /*-{ return this.secondary_emails; }-*/;
   public final native String username() /*-{ return this.username; }-*/;
 
   public final Timestamp registeredOn() {
@@ -40,9 +43,9 @@
     return ts;
   }
 
-  private final native String registeredOnRaw() /*-{ return this.registered_on; }-*/;
-  private final native Timestamp _getRegisteredOn() /*-{ return this._cts; }-*/;
-  private final native void _setRegisteredOn(Timestamp ts) /*-{ this._cts = ts; }-*/;
+  private native String registeredOnRaw() /*-{ return this.registered_on; }-*/;
+  private native Timestamp _getRegisteredOn() /*-{ return this._cts; }-*/;
+  private native void _setRegisteredOn(Timestamp ts) /*-{ this._cts = ts; }-*/;
 
   /**
    * @return true if the server supplied avatar information about this account.
@@ -64,7 +67,7 @@
     return null;
   }
 
-  private final native JsArray<AvatarInfo> avatars()
+  private native JsArray<AvatarInfo> avatars()
   /*-{ return this.avatars }-*/;
 
   public final native void name(String n) /*-{ this.name = n }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java
deleted file mode 100644
index 11a1b6a..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java
+++ /dev/null
@@ -1,219 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
-import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class AccountPreferencesInfo extends JavaScriptObject {
-  public static AccountPreferencesInfo create() {
-    return createObject().cast();
-  }
-
-  public static AccountPreferencesInfo createDefault() {
-    AccountGeneralPreferences defaultPrefs =
-        AccountGeneralPreferences.createDefault();
-    AccountPreferencesInfo p = createObject().cast();
-    p.changesPerPage(defaultPrefs.getMaximumPageSize());
-    p.showSiteHeader(defaultPrefs.isShowSiteHeader());
-    p.useFlashClipboard(defaultPrefs.isUseFlashClipboard());
-    p.downloadScheme(defaultPrefs.getDownloadUrl());
-    p.downloadCommand(defaultPrefs.getDownloadCommand());
-    p.copySelfOnEmail(defaultPrefs.isCopySelfOnEmails());
-    p.dateFormat(defaultPrefs.getDateFormat());
-    p.timeFormat(defaultPrefs.getTimeFormat());
-    p.relativeDateInChangeTable(defaultPrefs.isRelativeDateInChangeTable());
-    p.sizeBarInChangeTable(defaultPrefs.isSizeBarInChangeTable());
-    p.legacycidInChangeTable(defaultPrefs.isLegacycidInChangeTable());
-    p.muteCommonPathPrefixes(defaultPrefs.isMuteCommonPathPrefixes());
-    p.reviewCategoryStrategy(defaultPrefs.getReviewCategoryStrategy());
-    p.diffView(defaultPrefs.getDiffView());
-    return p;
-  }
-
-  public final short changesPerPage() {
-    short changesPerPage =
-        get("changes_per_page", AccountGeneralPreferences.DEFAULT_PAGESIZE);
-    return 0 < changesPerPage
-        ? changesPerPage
-        : AccountGeneralPreferences.DEFAULT_PAGESIZE;
-  }
-  private final native short get(String n, int d)
-  /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
-
-  public final native boolean showSiteHeader()
-  /*-{ return this.show_site_header || false }-*/;
-
-  public final native boolean useFlashClipboard()
-  /*-{ return this.use_flash_clipboard || false }-*/;
-
-  public final native String downloadScheme()
-  /*-{ return this.download_scheme }-*/;
-
-  public final DownloadCommand downloadCommand() {
-    String s = downloadCommandRaw();
-    return s != null ? DownloadCommand.valueOf(s) : null;
-  }
-  private final native String downloadCommandRaw()
-  /*-{ return this.download_command }-*/;
-
-  public final native boolean copySelfOnEmail()
-  /*-{ return this.copy_self_on_email || false }-*/;
-
-  public final DateFormat dateFormat() {
-    String s = dateFormatRaw();
-    return s != null ? DateFormat.valueOf(s) : null;
-  }
-  private final native String dateFormatRaw()
-  /*-{ return this.date_format }-*/;
-
-  public final TimeFormat timeFormat() {
-    String s = timeFormatRaw();
-    return s != null ? TimeFormat.valueOf(s) : null;
-  }
-  private final native String timeFormatRaw()
-  /*-{ return this.time_format }-*/;
-
-  public final native boolean relativeDateInChangeTable()
-  /*-{ return this.relative_date_in_change_table || false }-*/;
-
-  public final native boolean sizeBarInChangeTable()
-  /*-{ return this.size_bar_in_change_table || false }-*/;
-
-  public final native boolean legacycidInChangeTable()
-  /*-{ return this.legacycid_in_change_table || false }-*/;
-
-  public final native boolean muteCommonPathPrefixes()
-  /*-{ return this.mute_common_path_prefixes || false }-*/;
-
-  public final ReviewCategoryStrategy reviewCategoryStrategy() {
-    String s = reviewCategeoryStrategyRaw();
-    return s != null ? ReviewCategoryStrategy.valueOf(s) : ReviewCategoryStrategy.NONE;
-  }
-  private final native String reviewCategeoryStrategyRaw()
-  /*-{ return this.review_category_strategy }-*/;
-
-  public final DiffView diffView() {
-    String s = diffViewRaw();
-    return s != null ? DiffView.valueOf(s) : null;
-  }
-  private final native String diffViewRaw()
-  /*-{ return this.diff_view }-*/;
-
-  public final native JsArray<TopMenuItem> my()
-  /*-{ return this.my; }-*/;
-
-  public final native void changesPerPage(short n)
-  /*-{ this.changes_per_page = n }-*/;
-
-  public final native void showSiteHeader(boolean s)
-  /*-{ this.show_site_header = s }-*/;
-
-  public final native void useFlashClipboard(boolean u)
-  /*-{ this.use_flash_clipboard = u }-*/;
-
-  public final native void downloadScheme(String d)
-  /*-{ this.download_scheme = d }-*/;
-
-  public final void downloadCommand(DownloadCommand d) {
-    downloadCommandRaw(d != null ? d.toString() : null);
-  }
-  public final native void downloadCommandRaw(String d)
-  /*-{ this.download_command = d }-*/;
-
-  public final native void copySelfOnEmail(boolean c)
-  /*-{ this.copy_self_on_email = c }-*/;
-
-  public final void dateFormat(DateFormat f) {
-    dateFormatRaw(f != null ? f.toString() : null);
-  }
-  private final native void dateFormatRaw(String f)
-  /*-{ this.date_format = f }-*/;
-
-  public final void timeFormat(TimeFormat f) {
-    timeFormatRaw(f != null ? f.toString() : null);
-  }
-  private final native void timeFormatRaw(String f)
-  /*-{ this.time_format = f }-*/;
-
-  public final native void relativeDateInChangeTable(boolean d)
-  /*-{ this.relative_date_in_change_table = d }-*/;
-
-  public final native void sizeBarInChangeTable(boolean s)
-  /*-{ this.size_bar_in_change_table = s }-*/;
-
-  public final native void legacycidInChangeTable(boolean s)
-  /*-{ this.legacycid_in_change_table = s }-*/;
-
-  public final native void muteCommonPathPrefixes(boolean s)
-  /*-{ this.mute_common_path_prefixes = s }-*/;
-
-  public final void reviewCategoryStrategy(ReviewCategoryStrategy s) {
-    reviewCategoryStrategyRaw(s != null ? s.toString() : null);
-  }
-  private final native void reviewCategoryStrategyRaw(String s)
-  /*-{ this.review_category_strategy = s }-*/;
-
-  public final void diffView(DiffView d) {
-    diffViewRaw(d != null ? d.toString() : null);
-  }
-  private final native void diffViewRaw(String d)
-  /*-{ this.diff_view = d }-*/;
-
-  public final void setMyMenus(List<TopMenuItem> myMenus) {
-    initMy();
-    for (TopMenuItem n : myMenus) {
-      addMy(n);
-    }
-  }
-  final native void initMy() /*-{ this.my = []; }-*/;
-  final native void addMy(TopMenuItem m) /*-{ this.my.push(m); }-*/;
-
-  public final Map<String, String> urlAliases() {
-    Map<String, String> urlAliases = new HashMap<>();
-    for (String k : Natives.keys(_urlAliases())) {
-      urlAliases.put(k, urlAliasToken(k));
-    }
-    return urlAliases;
-  }
-
-  private final native String urlAliasToken(String m) /*-{ return this.url_aliases[m]; }-*/;
-  private final native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
-
-  public final void setUrlAliases(Map<String, String> urlAliases) {
-    initUrlAliases();
-    for (Map.Entry<String, String> e : urlAliases.entrySet()) {
-      putUrlAlias(e.getKey(), e.getValue());
-    }
-  }
-  private final native void putUrlAlias(String m, String t) /*-{ this.url_aliases[m] = t; }-*/;
-  private final native void initUrlAliases() /*-{ this.url_aliases = {}; }-*/;
-
-  protected AccountPreferencesInfo() {
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
index 15d1c6c..345e1e3 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.info;
 
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.reviewdb.client.AuthType;
@@ -74,12 +75,16 @@
   }
 
   public final boolean isHttpPasswordSettingsEnabled() {
-    if (isLdap() && isGitBasicAuth()) {
+    if (isGitBasicAuth() && gitBasicAuthPolicy() == GitBasicAuthPolicy.LDAP) {
       return false;
     }
     return true;
   }
 
+  public final GitBasicAuthPolicy gitBasicAuthPolicy() {
+    return GitBasicAuthPolicy.valueOf(gitBasicAuthPolicyRaw());
+  }
+
   public final native boolean useContributorAgreements()
   /*-{ return this.use_contributor_agreements || false; }-*/;
   public final native String loginUrl() /*-{ return this.login_url; }-*/;
@@ -90,8 +95,10 @@
   public final native String editFullNameUrl() /*-{ return this.edit_full_name_url; }-*/;
   public final native String httpPasswordUrl() /*-{ return this.http_password_url; }-*/;
   public final native boolean isGitBasicAuth() /*-{ return this.is_git_basic_auth || false; }-*/;
-  private final native String authTypeRaw() /*-{ return this.auth_type; }-*/;
-  private final native JsArrayString _editableAccountFields()
+  private native String gitBasicAuthPolicyRaw()
+  /*-{ return this.git_basic_auth_policy; }-*/;
+  private native String authTypeRaw() /*-{ return this.auth_type; }-*/;
+  private native JsArrayString _editableAccountFields()
   /*-{ return this.editable_account_fields; }-*/;
 
   protected AuthInfo() {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index cace7ad..9eea93e 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -28,9 +30,13 @@
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
 
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
@@ -65,13 +71,17 @@
     return revList.get(revList.length() - 1).isEdit();
   }
 
-  private final native Timestamp _getCts() /*-{ return this._cts; }-*/;
-  private final native void _setCts(Timestamp ts) /*-{ this._cts = ts; }-*/;
+  private native Timestamp _getCts() /*-{ return this._cts; }-*/;
+  private native void _setCts(Timestamp ts) /*-{ this._cts = ts; }-*/;
 
   public final Timestamp updated() {
     return JavaSqlTimestamp_JsonSerializer.parseTimestamp(updatedRaw());
   }
 
+  public final Timestamp submitted() {
+    return JavaSqlTimestamp_JsonSerializer.parseTimestamp(submittedRaw());
+  }
+
   public final String idAbbreviated() {
     return new Change.Key(changeId()).abbreviate();
   }
@@ -84,6 +94,16 @@
     return allLabels().keySet();
   }
 
+  public final Set<Integer> removableReviewerIds() {
+    Set<Integer> removable = new HashSet<>();
+    if (removableReviewers() != null) {
+      for (AccountInfo a : Natives.asList(removableReviewers())) {
+        removable.add(a._accountId());
+      }
+    }
+    return removable;
+  }
+
   public final native String id() /*-{ return this.id; }-*/;
   public final native String project() /*-{ return this.project; }-*/;
   public final native String branch() /*-{ return this.branch; }-*/;
@@ -92,11 +112,12 @@
   public final native boolean mergeable() /*-{ return this.mergeable ? true : false; }-*/;
   public final native int insertions() /*-{ return this.insertions; }-*/;
   public final native int deletions() /*-{ return this.deletions; }-*/;
-  private final native String statusRaw() /*-{ return this.status; }-*/;
+  private native String statusRaw() /*-{ return this.status; }-*/;
   public final native String subject() /*-{ return this.subject; }-*/;
   public final native AccountInfo owner() /*-{ return this.owner; }-*/;
-  private final native String createdRaw() /*-{ return this.created; }-*/;
-  private final native String updatedRaw() /*-{ return this.updated; }-*/;
+  private native String createdRaw() /*-{ return this.created; }-*/;
+  private native String updatedRaw() /*-{ return this.updated; }-*/;
+  private native String submittedRaw() /*-{ return this.submitted; }-*/;
   public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
@@ -121,6 +142,23 @@
   public final native JsArray<AccountInfo> removableReviewers()
   /*-{ return this.removable_reviewers; }-*/;
 
+  private native NativeMap<JsArray<AccountInfo>> _reviewers()
+  /*-{ return this.reviewers; }-*/;
+  public final Map<ReviewerState, List<AccountInfo>> reviewers() {
+    NativeMap<JsArray<AccountInfo>> reviewers = _reviewers();
+    Map<ReviewerState, List<AccountInfo>> result = new HashMap<>();
+    for (String k : reviewers.keySet()) {
+      ReviewerState state = ReviewerState.valueOf(k.toUpperCase());
+      List<AccountInfo> accounts = result.get(state);
+      if (accounts == null) {
+        accounts = new ArrayList<>();
+        result.put(state, accounts);
+      }
+      accounts.addAll(Natives.asList(reviewers.get(k)));
+    }
+    return result;
+  }
+
   public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
   public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
 
@@ -128,12 +166,21 @@
   public final native boolean _more_changes()
   /*-{ return this._more_changes ? true : false; }-*/;
 
+  public final SubmitType submitType() {
+    String submitType = _submitType();
+    if (submitType == null) {
+      return null;
+    }
+    return SubmitType.valueOf(submitType);
+  }
+  private native String _submitType() /*-{ return this.submit_type; }-*/;
+
   public final boolean submittable() {
     init();
     return _submittable();
   }
 
-  private final native boolean _submittable()
+  private native boolean _submittable()
   /*-{ return this.submittable ? true : false; }-*/;
 
   /**
@@ -161,9 +208,8 @@
             // more than one label is missing, so it's unclear which to quick
             // approve, return -1
             return -1;
-          } else {
-            ret = i;
           }
+          ret = i;
           continue;
 
         case OK: // Label already applied.
@@ -212,7 +258,7 @@
       return null;
     }
 
-    private final native NativeMap<NativeString> _values() /*-{ return this.values; }-*/;
+    private native NativeMap<NativeString> _values() /*-{ return this.values; }-*/;
     public final Set<String> values() {
       return Natives.keys(_values());
     }
@@ -287,12 +333,22 @@
       revisionInfo.takeFromEdit(edit);
       return revisionInfo;
     }
-    private final native void takeFromEdit(EditInfo edit) /*-{
+    public static RevisionInfo forParent(int number, CommitInfo commit) {
+      RevisionInfo revisionInfo = createObject().cast();
+      revisionInfo.takeFromParent(number, commit);
+      return revisionInfo;
+    }
+    private native void takeFromEdit(EditInfo edit) /*-{
       this._number = 0;
       this.name = edit.name;
       this.commit = edit.commit;
       this.edit_base = edit.base_revision;
     }-*/;
+    private native void takeFromParent(int number, CommitInfo commit) /*-{
+      this._number = number;
+      this.commit = commit;
+      this.name = this._number;
+    }-*/;
     public final native int _number() /*-{ return this._number; }-*/;
     public final native String name() /*-{ return this.name; }-*/;
     public final native boolean draft() /*-{ return this.draft || false; }-*/;
@@ -388,7 +444,7 @@
   public static class GitPerson extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
     public final native String email() /*-{ return this.email; }-*/;
-    private final native String dateRaw() /*-{ return this.date; }-*/;
+    private native String dateRaw() /*-{ return this.date; }-*/;
 
     public final Timestamp date() {
       return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
@@ -402,7 +458,7 @@
     public final native AccountInfo author() /*-{ return this.author; }-*/;
     public final native String message() /*-{ return this.message; }-*/;
     public final native int _revisionNumber() /*-{ return this._revision_number || 0; }-*/;
-    private final native String dateRaw() /*-{ return this.date; }-*/;
+    private native String dateRaw() /*-{ return this.date; }-*/;
 
     public final Timestamp date() {
       return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
@@ -428,7 +484,7 @@
     public final native JsArrayString branches() /*-{ return this.branches; }-*/;
     public final native JsArrayString tags() /*-{ return this.tags; }-*/;
     public final native JsArrayString external(String n) /*-{ return this.external[n]; }-*/;
-    private final native NativeMap<JsArrayString> external() /*-{ return this.external; }-*/;
+    private native NativeMap<JsArrayString> external() /*-{ return this.external; }-*/;
 
     protected IncludedInInfo() {
     }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
index eee2847..183e6af 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
@@ -39,8 +39,8 @@
   }
 
   public final native DownloadSchemeInfo scheme(String n) /*-{ return this.schemes[n]; }-*/;
-  private final native NativeMap<DownloadSchemeInfo> _schemes() /*-{ return this.schemes; }-*/;
-  private final native JsArrayString _archives() /*-{ return this.archives; }-*/;
+  private native NativeMap<DownloadSchemeInfo> _schemes() /*-{ return this.schemes; }-*/;
+  private native JsArrayString _archives() /*-{ return this.archives; }-*/;
 
   protected DownloadInfo() {
   }
@@ -96,8 +96,8 @@
     public final native boolean isAuthSupported() /*-{ return this.is_auth_supported || false; }-*/;
     public final native String command(String n) /*-{ return this.commands[n]; }-*/;
     public final native String cloneCommand(String n) /*-{ return this.clone_commands[n]; }-*/;
-    private final native NativeMap<NativeString> _commands() /*-{ return this.commands; }-*/;
-    private final native NativeMap<NativeString> _cloneCommands() /*-{ return this.clone_commands; }-*/;
+    private native NativeMap<NativeString> _commands() /*-{ return this.commands; }-*/;
+    private native NativeMap<NativeString> _cloneCommands() /*-{ return this.clone_commands; }-*/;
 
     protected DownloadSchemeInfo() {
     }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
index d95f9ef..9b290a5 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
@@ -34,10 +34,15 @@
   // JSNI methods cannot have 'long' as a parameter type or a return type and
   // it's suggested to use double in this case:
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html#important
+  public final long size() {
+    return (long)_size();
+  }
+  private native double _size() /*-{ return this.size || 0; }-*/;
+
   public final long sizeDelta() {
     return (long)_sizeDelta();
   }
-  private final native double _sizeDelta() /*-{ return this.size_delta || 0; }-*/;
+  private native double _sizeDelta() /*-{ return this.size_delta || 0; }-*/;
 
   public final native int _row() /*-{ return this._row }-*/;
   public final native void _row(int r) /*-{ this._row = r }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
new file mode 100644
index 0000000..45953cb
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
@@ -0,0 +1,235 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gerrit.client.rpc.NativeMap;
+import com.google.gerrit.client.rpc.NativeString;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class GeneralPreferences extends JavaScriptObject {
+  public static GeneralPreferences create() {
+    return createObject().cast();
+  }
+
+  public static GeneralPreferences createDefault() {
+    GeneralPreferencesInfo d =
+        GeneralPreferencesInfo.defaults();
+    GeneralPreferences p = createObject().cast();
+    p.changesPerPage(d.changesPerPage);
+    p.showSiteHeader(d.showSiteHeader);
+    p.useFlashClipboard(d.useFlashClipboard);
+    p.downloadScheme(d.downloadScheme);
+    p.downloadCommand(d.downloadCommand);
+    p.dateFormat(d.getDateFormat());
+    p.timeFormat(d.getTimeFormat());
+    p.relativeDateInChangeTable(d.relativeDateInChangeTable);
+    p.sizeBarInChangeTable(d.sizeBarInChangeTable);
+    p.legacycidInChangeTable(d.legacycidInChangeTable);
+    p.muteCommonPathPrefixes(d.muteCommonPathPrefixes);
+    p.signedOffBy(d.signedOffBy);
+    p.reviewCategoryStrategy(d.getReviewCategoryStrategy());
+    p.diffView(d.getDiffView());
+    p.emailStrategy(d.emailStrategy);
+    return p;
+  }
+
+  public final int changesPerPage() {
+    int changesPerPage =
+        get("changes_per_page", GeneralPreferencesInfo.DEFAULT_PAGESIZE);
+    return 0 < changesPerPage
+        ? changesPerPage
+        : GeneralPreferencesInfo.DEFAULT_PAGESIZE;
+  }
+  private native short get(String n, int d)
+  /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
+
+  public final native boolean showSiteHeader()
+  /*-{ return this.show_site_header || false }-*/;
+
+  public final native boolean useFlashClipboard()
+  /*-{ return this.use_flash_clipboard || false }-*/;
+
+  public final native String downloadScheme()
+  /*-{ return this.download_scheme }-*/;
+
+  public final DownloadCommand downloadCommand() {
+    String s = downloadCommandRaw();
+    return s != null ? DownloadCommand.valueOf(s) : null;
+  }
+  private native String downloadCommandRaw()
+  /*-{ return this.download_command }-*/;
+
+  public final DateFormat dateFormat() {
+    String s = dateFormatRaw();
+    return s != null ? DateFormat.valueOf(s) : null;
+  }
+  private native String dateFormatRaw()
+  /*-{ return this.date_format }-*/;
+
+  public final TimeFormat timeFormat() {
+    String s = timeFormatRaw();
+    return s != null ? TimeFormat.valueOf(s) : null;
+  }
+  private native String timeFormatRaw()
+  /*-{ return this.time_format }-*/;
+
+  public final native boolean relativeDateInChangeTable()
+  /*-{ return this.relative_date_in_change_table || false }-*/;
+
+  public final native boolean sizeBarInChangeTable()
+  /*-{ return this.size_bar_in_change_table || false }-*/;
+
+  public final native boolean legacycidInChangeTable()
+  /*-{ return this.legacycid_in_change_table || false }-*/;
+
+  public final native boolean muteCommonPathPrefixes()
+  /*-{ return this.mute_common_path_prefixes || false }-*/;
+
+  public final native boolean signedOffBy()
+  /*-{ return this.signed_off_by || false }-*/;
+
+  public final ReviewCategoryStrategy reviewCategoryStrategy() {
+    String s = reviewCategeoryStrategyRaw();
+    return s != null ? ReviewCategoryStrategy.valueOf(s) : ReviewCategoryStrategy.NONE;
+  }
+  private native String reviewCategeoryStrategyRaw()
+  /*-{ return this.review_category_strategy }-*/;
+
+  public final DiffView diffView() {
+    String s = diffViewRaw();
+    return s != null ? DiffView.valueOf(s) : null;
+  }
+  private native String diffViewRaw()
+  /*-{ return this.diff_view }-*/;
+
+  public final EmailStrategy emailStrategy() {
+    String s = emailStrategyRaw();
+    return s != null ? EmailStrategy.valueOf(s) : null;
+  }
+
+  private native String emailStrategyRaw()
+  /*-{ return this.email_strategy }-*/;
+
+  public final native JsArray<TopMenuItem> my()
+  /*-{ return this.my; }-*/;
+
+  public final native void changesPerPage(int n)
+  /*-{ this.changes_per_page = n }-*/;
+
+  public final native void showSiteHeader(boolean s)
+  /*-{ this.show_site_header = s }-*/;
+
+  public final native void useFlashClipboard(boolean u)
+  /*-{ this.use_flash_clipboard = u }-*/;
+
+  public final native void downloadScheme(String d)
+  /*-{ this.download_scheme = d }-*/;
+
+  public final void downloadCommand(DownloadCommand d) {
+    downloadCommandRaw(d != null ? d.toString() : null);
+  }
+  public final native void downloadCommandRaw(String d)
+  /*-{ this.download_command = d }-*/;
+
+  public final void dateFormat(DateFormat f) {
+    dateFormatRaw(f != null ? f.toString() : null);
+  }
+  private native void dateFormatRaw(String f)
+  /*-{ this.date_format = f }-*/;
+
+  public final void timeFormat(TimeFormat f) {
+    timeFormatRaw(f != null ? f.toString() : null);
+  }
+  private native void timeFormatRaw(String f)
+  /*-{ this.time_format = f }-*/;
+
+  public final native void relativeDateInChangeTable(boolean d)
+  /*-{ this.relative_date_in_change_table = d }-*/;
+
+  public final native void sizeBarInChangeTable(boolean s)
+  /*-{ this.size_bar_in_change_table = s }-*/;
+
+  public final native void legacycidInChangeTable(boolean s)
+  /*-{ this.legacycid_in_change_table = s }-*/;
+
+  public final native void muteCommonPathPrefixes(boolean s)
+  /*-{ this.mute_common_path_prefixes = s }-*/;
+
+  public final native void signedOffBy(boolean s)
+  /*-{ this.signed_off_by = s }-*/;
+
+  public final void reviewCategoryStrategy(ReviewCategoryStrategy s) {
+    reviewCategoryStrategyRaw(s != null ? s.toString() : null);
+  }
+  private native void reviewCategoryStrategyRaw(String s)
+  /*-{ this.review_category_strategy = s }-*/;
+
+  public final void diffView(DiffView d) {
+    diffViewRaw(d != null ? d.toString() : null);
+  }
+  private native void diffViewRaw(String d)
+  /*-{ this.diff_view = d }-*/;
+
+  public final void emailStrategy(EmailStrategy s) {
+    emailStrategyRaw(s != null ? s.toString() : null);
+  }
+  private native void emailStrategyRaw(String s)
+  /*-{ this.email_strategy = s }-*/;
+
+  public final void setMyMenus(List<TopMenuItem> myMenus) {
+    initMy();
+    for (TopMenuItem n : myMenus) {
+      addMy(n);
+    }
+  }
+  final native void initMy() /*-{ this.my = []; }-*/;
+  final native void addMy(TopMenuItem m) /*-{ this.my.push(m); }-*/;
+
+  public final Map<String, String> urlAliases() {
+    Map<String, String> urlAliases = new HashMap<>();
+    for (String k : Natives.keys(_urlAliases())) {
+      urlAliases.put(k, urlAliasToken(k));
+    }
+    return urlAliases;
+  }
+
+  private native String urlAliasToken(String m) /*-{ return this.url_aliases[m]; }-*/;
+  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
+
+  public final void setUrlAliases(Map<String, String> urlAliases) {
+    initUrlAliases();
+    for (Map.Entry<String, String> e : urlAliases.entrySet()) {
+      putUrlAlias(e.getKey(), e.getValue());
+    }
+  }
+  private native void putUrlAlias(String m, String t) /*-{ this.url_aliases[m] = t; }-*/;
+  private native void initUrlAliases() /*-{ this.url_aliases = {}; }-*/;
+
+  protected GeneralPreferences() {
+  }
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
index 55ef892..750412d 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
@@ -36,6 +36,7 @@
 
   public final native String allProjects() /*-{ return this.all_projects; }-*/;
   public final native String allUsers() /*-{ return this.all_users; }-*/;
+  public final native boolean docSearch() /*-{ return this.doc_search; }-*/;
   public final native String docUrl() /*-{ return this.doc_url; }-*/;
   public final native boolean editGpgKeys() /*-{ return this.edit_gpg_keys || false; }-*/;
   public final native String reportBugUrl() /*-{ return this.report_bug_url; }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebInfo.java
deleted file mode 100644
index eb5a697..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebInfo.java
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.http.client.URL;
-
-import java.util.HashMap;
-import java.util.Map;
-
-public class GitwebInfo extends JavaScriptObject {
-  public final native String url() /*-{ return this.url; }-*/;
-  public final native GitwebTypeInfo type() /*-{ return this.type; }-*/;
-
-  /**
-   * Checks whether the given patch set can be linked.
-   *
-   * Draft patch sets can only be linked if linking of drafts was enabled by
-   * configuration.
-   *
-   * @param ps patch set to check whether it can be linked
-   * @return true if the patch set can be linked, otherwise false
-   */
-  public final boolean canLink(PatchSet ps) {
-    return !ps.isDraft() || type().linkDrafts();
-  }
-
-  /**
-   * Checks whether the given revision can be linked.
-   *
-   * Draft revisions can only be linked if linking of drafts was enabled by
-   * configuration.
-   *
-   * @param revision revision to check whether it can be linked
-   * @return true if the revision can be linked, otherwise false
-   */
-  public final boolean canLink(RevisionInfo revision) {
-    return revision.draft() || type().linkDrafts();
-  }
-
-  /**
-   * Returns the name for gitweb links.
-   *
-   * @return the name for gitweb links
-   */
-  public final String getLinkName() {
-    return "(" + type().name() + ")";
-  }
-
-  /**
-   * Returns the gitweb link to a revision.
-   *
-   * @param project the name of the project
-   * @param commit the commit ID
-   * @return gitweb link to a revision
-   */
-  public final String toRevision(String  project, String commit) {
-    ParameterizedString pattern = new ParameterizedString(type().revision());
-    Map<String, String> p = new HashMap<>();
-    p.put("project", encode(project));
-    p.put("commit", encode(commit));
-    return url() + pattern.replace(p);
-  }
-
-  /**
-   * Returns the gitweb link to a revision.
-   *
-   * @param project the name of the project
-   * @param ps the patch set
-   * @return gitweb link to a revision
-   */
-  public final String toRevision(Project.NameKey project, PatchSet ps) {
-    return toRevision(project.get(), ps.getRevision().get());
-  }
-
-  /**
-   * Returns the gitweb link to a project.
-   *
-   * @param project the project name key
-   * @return gitweb link to a project
-   */
-  public final String toProject(Project.NameKey project) {
-    ParameterizedString pattern = new ParameterizedString(type().project());
-
-    Map<String, String> p = new HashMap<>();
-    p.put("project", encode(project.get()));
-    return url() + pattern.replace(p);
-  }
-
-  /**
-   * Returns the gitweb link to a branch.
-   *
-   * @param branch the branch name key
-   * @return gitweb link to a branch
-   */
-  public final String toBranch(Branch.NameKey branch) {
-    ParameterizedString pattern = new ParameterizedString(type().branch());
-
-    Map<String, String> p = new HashMap<>();
-    p.put("project", encode(branch.getParentKey().get()));
-    p.put("branch", encode(branch.get()));
-    return url() + pattern.replace(p);
-  }
-
-  /**
-   * Returns the gitweb link to a file.
-   *
-   * @param project the branch name key
-   * @param commit the commit ID
-   * @param file the path of the file
-   * @return gitweb link to a file
-   */
-  public final String toFile(String  project, String commit, String file) {
-    Map<String, String> p = new HashMap<>();
-    p.put("project", encode(project));
-    p.put("commit", encode(commit));
-    p.put("file", encode(file));
-
-    ParameterizedString pattern = (file == null || file.isEmpty())
-        ? new ParameterizedString(type().rootTree())
-        : new ParameterizedString(type().file());
-    return url() + pattern.replace(p);
-  }
-
-  /**
-   * Returns the gitweb link to a file history.
-   *
-   * @param branch the branch name key
-   * @param file the path of the file
-   * @return gitweb link to a file history
-   */
-  public final String toFileHistory(Branch.NameKey branch, String file) {
-    ParameterizedString pattern = new ParameterizedString(type().fileHistory());
-
-    Map<String, String> p = new HashMap<>();
-    p.put("project", encode(branch.getParentKey().get()));
-    p.put("branch", encode(branch.get()));
-    p.put("file", encode(file));
-    return url() + pattern.replace(p);
-  }
-
-  private final String encode(String segment) {
-    if (type().urlEncode()) {
-      return URL.encodeQueryString(type().replacePathSeparator(segment));
-    } else {
-      return segment;
-    }
-  }
-
-  protected GitwebInfo() {
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebTypeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebTypeInfo.java
deleted file mode 100644
index 6726719..0000000
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GitwebTypeInfo.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.info;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-public class GitwebTypeInfo extends JavaScriptObject {
-  /**
-   * Replace the standard path separator ('/') in a branch name or project
-   * name with a custom path separator configured by the property
-   * gitweb.pathSeparator.
-   * @param urlSegment The branch or project to replace the path separator in
-   * @return the urlSegment with the standard path separator replaced by the
-   * custom path separator
-   */
-  public final String replacePathSeparator(String urlSegment) {
-    if (!"/".equals(pathSeparator())) {
-      return urlSegment.replace("/", pathSeparator());
-    }
-    return urlSegment;
-  }
-
-  public final native String name() /*-{ return this.name; }-*/;
-  public final native String revision() /*-{ return this.revision; }-*/;
-  public final native String project() /*-{ return this.project; }-*/;
-  public final native String branch() /*-{ return this.branch; }-*/;
-  public final native String rootTree() /*-{ return this.root_tree; }-*/;
-  public final native String file() /*-{ return this.file; }-*/;
-  public final native String fileHistory() /*-{ return this.file_history; }-*/;
-  public final native String pathSeparator() /*-{ return this.path_separator; }-*/;
-  public final native boolean linkDrafts() /*-{ return this.link_drafts || false; }-*/;
-  public final native boolean urlEncode() /*-{ return this.url_encode || false; }-*/;
-
-  protected GitwebTypeInfo() {
-  }
-}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
index f7477a1..7955347 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
@@ -27,7 +27,7 @@
   public final native JsArrayString userIds() /*-{ return this.user_ids; }-*/;
   public final native String key() /*-{ return this.key; }-*/;
 
-  private final native String statusRaw() /*-{ return this.status; }-*/;
+  private native String statusRaw() /*-{ return this.status; }-*/;
   public final Status status() {
     String s = statusRaw();
     if (s == null) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
new file mode 100644
index 0000000..08fd130
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+
+public class OAuthTokenInfo extends JavaScriptObject {
+
+  protected OAuthTokenInfo() {
+  }
+
+  public final native String username() /*-{ return this.username; }-*/;
+  public final native String resourceHost() /*-{ return this.resource_host; }-*/;
+  public final native String accessToken() /*-{ return this.access_token; }-*/;
+  public final native String providerId() /*-{ return this.provider_id; }-*/;
+  public final native String expiresAt() /*-{ return this.expires_at; }-*/;
+  public final native String type() /*-{ return this.type; }-*/;
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
index b0e52fa..112c4db 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArrayString;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -27,7 +28,6 @@
   public final native ChangeConfigInfo change() /*-{ return this.change; }-*/;
   public final native DownloadInfo download() /*-{ return this.download; }-*/;
   public final native GerritInfo gerrit() /*-{ return this.gerrit; }-*/;
-  public final native GitwebInfo gitweb() /*-{ return this.gitweb; }-*/;
   public final native PluginConfigInfo plugin() /*-{ return this.plugin; }-*/;
   public final native SshdInfo sshd() /*-{ return this.sshd; }-*/;
   public final native SuggestInfo suggest() /*-{ return this.suggest; }-*/;
@@ -43,7 +43,7 @@
   }
 
   public final native String urlAliasToken(String n) /*-{ return this.url_aliases[n]; }-*/;
-  private final native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
+  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
 
 
   public final boolean hasSshd() {
@@ -55,6 +55,7 @@
 
   public static class ChangeConfigInfo extends JavaScriptObject {
     public final native boolean allowDrafts() /*-{ return this.allow_drafts || false; }-*/;
+    public final native boolean allowBlame() /*-{ return this.allow_blame || false; }-*/;
     public final native int largeChange() /*-{ return this.large_change || 0; }-*/;
     public final native String replyLabel() /*-{ return this.reply_label; }-*/;
     public final native String replyTooltip() /*-{ return this.reply_tooltip; }-*/;
@@ -68,6 +69,8 @@
 
   public static class PluginConfigInfo extends JavaScriptObject {
     public final native boolean hasAvatars() /*-{ return this.has_avatars || false; }-*/;
+    public final native JsArrayString jsResourcePaths() /*-{
+        return this.js_resource_paths || []; }-*/;
 
     protected PluginConfigInfo() {
     }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
index 0e16dc0..572d454 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
@@ -22,7 +22,7 @@
   public static final JavaScriptObject TYPE = init();
 
   // Used from core and plugins
-  private static final native JavaScriptObject init() /*-{
+  private static native JavaScriptObject init() /*-{
     if ($wnd.Gerrit === undefined || $wnd.Gerrit.JsonString === undefined) {
       return function(s){this.s=s};
     } else {
@@ -30,16 +30,16 @@
     }
   }-*/;
 
-  static final NativeString wrap(String s) {
+  static NativeString wrap(String s) {
     return wrap0(TYPE, s);
   }
 
-  private static final native NativeString wrap0(JavaScriptObject T, String s)
+  private static native NativeString wrap0(JavaScriptObject T, String s)
   /*-{ return new T(s) }-*/;
 
-  public final native String asString() /*-{ return this.s; }-*/;
+  public native String asString() /*-{ return this.s; }-*/;
 
-  public static final AsyncCallback<NativeString>
+  public static AsyncCallback<NativeString>
   unwrap(final AsyncCallback<String> cb) {
     return new AsyncCallback<NativeString>() {
       @Override
@@ -54,11 +54,11 @@
     };
   }
 
-  public static final boolean is(JavaScriptObject o) {
+  public static boolean is(JavaScriptObject o) {
     return is(TYPE, o);
   }
 
-  private static final native boolean is(JavaScriptObject T, JavaScriptObject o)
+  private static native boolean is(JavaScriptObject T, JavaScriptObject o)
   /*-{ return o instanceof T }-*/;
 
   protected NativeString() {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java
index 93c86a2..00e6bb1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/TransformCallback.java
@@ -17,7 +17,7 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 /** Transforms a value and passes it on to another callback. */
-public abstract class TransformCallback<I, O> implements AsyncCallback<I>{
+public abstract class TransformCallback<I, O> implements AsyncCallback<I> {
   private final AsyncCallback<O> callback;
 
   protected TransformCallback(AsyncCallback<O> callback) {
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/addFileComment.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/addFileComment.png
deleted file mode 100644
index 4ae3ae8..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/addFileComment.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowDown.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowDown.png
deleted file mode 100644
index ba67de7..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowDown.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowRight.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowRight.png
deleted file mode 100644
index 8549f5d..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowRight.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowUp.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowUp.png
deleted file mode 100644
index 5674d6c..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrowUp.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png
new file mode 100644
index 0000000..6972c5e
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/arrow_undo.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png
new file mode 100644
index 0000000..67de2c6
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cog.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png
new file mode 100644
index 0000000..3408ddf
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/comment_draft.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png
new file mode 100644
index 0000000..1514d51
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/cross.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteHover.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteHover.png
deleted file mode 100644
index 9fde3fa..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteHover.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteNormal.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteNormal.png
deleted file mode 100644
index 47a1195..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/deleteNormal.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy100.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy100.png
deleted file mode 100644
index 4be4541..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/diffy100.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png
new file mode 100644
index 0000000..99d532e
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/disk.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/downloadIcon.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/downloadIcon.png
deleted file mode 100644
index 1a6520e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/downloadIcon.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/draftComments.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/draftComments.png
deleted file mode 100644
index 276912a..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/draftComments.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editText.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editText.png
deleted file mode 100644
index 2927275..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editText.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editUndo.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editUndo.png
deleted file mode 100644
index 4790e10..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/editUndo.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png
new file mode 100644
index 0000000..c37bd06
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/exclamation.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png
new file mode 100644
index 0000000..1547479
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/find.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/gear.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/gear.png
deleted file mode 100644
index 2f84e47..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/gear.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png
new file mode 100644
index 0000000..5d87e45
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/goDown.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/greenCheck.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/greenCheck.png
deleted file mode 100644
index 207c0e7..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/greenCheck.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png
new file mode 100644
index 0000000..5c87017
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/help.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/info.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/info.png
deleted file mode 100644
index 8851b99..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/info.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png
new file mode 100644
index 0000000..d22fde8
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lightbulb.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png
new file mode 100644
index 0000000..2ebc4f6
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/lock.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/mediaFloppy.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/mediaFloppy.png
deleted file mode 100644
index f1d7a19..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/mediaFloppy.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png
new file mode 100644
index 0000000..abdad91
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/note_add.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png
new file mode 100644
index 0000000..046811e
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_edit.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png
new file mode 100644
index 0000000..884ffd6
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/page_white_put.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/queryIcon.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/queryIcon.png
deleted file mode 100644
index 5ebf2cb..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/queryIcon.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/question.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/question.png
deleted file mode 100644
index f25fc3f..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/question.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/readOnly.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/readOnly.png
deleted file mode 100644
index 62e89f9..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/readOnly.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/redNot.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/redNot.png
deleted file mode 100644
index 99834fd..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/redNot.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png
new file mode 100644
index 0000000..7bdd8ea
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_down_gray.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png
new file mode 100644
index 0000000..3049ef4
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_next_gray.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png
new file mode 100644
index 0000000..966a25e
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/resultset_up_gray.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png
new file mode 100644
index 0000000..edd577c
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star-open.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png
new file mode 100644
index 0000000..b88c857
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/star.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starFilled.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starFilled.png
deleted file mode 100644
index db1e24e..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starFilled.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starOpen.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starOpen.png
deleted file mode 100644
index 6c955de..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/starOpen.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png
new file mode 100644
index 0000000..9757fc6
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png
new file mode 100644
index 0000000..f135248
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tag_blue_add.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png
new file mode 100644
index 0000000..a9925a0
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/tick.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/undoNormal.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/undoNormal.png
deleted file mode 100644
index b780f75..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/undoNormal.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png
new file mode 100644
index 0000000..deae99b
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_add.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/warning.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/warning.png
deleted file mode 100644
index 81e9ed2..0000000
--- a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/warning.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/BUCK b/gerrit-gwtui/BUCK
index ead19f4..1e39831 100644
--- a/gerrit-gwtui/BUCK
+++ b/gerrit-gwtui/BUCK
@@ -19,40 +19,40 @@
   visibility = ['//:'],
 )
 
-gwt_module(
-  name = 'ui_module',
-  srcs = glob(['src/main/java/**/*.java']),
-  gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
-  resources = glob(['src/main/java/**/*']),
-  deps = [
-    ':freebie_application_icon_set',
-    '//gerrit-gwtui-common:diffy_logo',
-    '//gerrit-gwtui-common:client',
-    '//gerrit-gwtexpui:CSS',
-    '//lib/codemirror:codemirror',
-    '//lib/gwt:user',
-  ],
-  visibility = [
-    '//tools/eclipse:classpath',
-    '//Documentation:licenses.txt',
-    '//Documentation:js_licenses.txt',
-  ],
-)
+def gen_ui_module(name, suffix = ""):
+  gwt_module(
+    name = name + suffix,
+    srcs = glob(['src/main/java/**/*.java']),
+    gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
+    resources = glob(['src/main/java/**/*']),
+    deps = [
+      ':silk_icons',
+      '//gerrit-gwtui-common:diffy_logo',
+      '//gerrit-gwtui-common:client',
+      '//gerrit-gwtexpui:CSS',
+      '//lib/codemirror:codemirror' + suffix,
+      '//lib/gwt:user',
+    ],
+    visibility = [
+      '//tools/eclipse:classpath',
+      '//Documentation:licenses.txt',
+      '//Documentation:js_licenses.txt',
+    ],
+  )
+
+gen_ui_module(name = 'ui_module')
+gen_ui_module(name = 'ui_module', suffix = '_r')
 
 java_library(
-  name = 'freebie_application_icon_set',
+  name = 'silk_icons',
   deps = [
-    '//lib:LICENSE-freebie_application_icon_set',
-    '//lib:LICENSE-CC-BY3.0',
+    '//lib:LICENSE-silk_icons',
   ],
 )
 
 java_test(
   name = 'ui_tests',
   srcs = glob(['src/test/java/**/*.java']),
-  resources = glob(['src/test/resources/**/*']) + [
-    'src/main/java/com/google/gerrit/GerritGwtUI.gwt.xml',
-  ],
   deps = [
     ':ui_module',
     '//gerrit-common:client',
@@ -60,7 +60,6 @@
     '//lib:junit',
     '//lib/gwt:dev',
     '//lib/gwt:user',
-    '//lib/gwt:gwt-test-utils',
   ],
   source_under_test = [':ui_module'],
   vm_args = ['-Xmx512m'],
diff --git a/gerrit-gwtui/gwt.defs b/gerrit-gwtui/gwt.defs
index 783c343..cd8fa74 100644
--- a/gerrit-gwtui/gwt.defs
+++ b/gerrit-gwtui/gwt.defs
@@ -18,12 +18,14 @@
   'firefox',
   'gecko1_8',
   'safari',
-  'msie', 'ie8', 'ie9',
+  'msie', 'ie8', 'ie9', 'ie10', 'ie11',
+  'edge',
 ]
 ALIASES = {
   'chrome': 'safari',
   'firefox': 'gecko1_8',
-  'msie': 'ie9',
+  'msie': 'ie11',
+  'edge': 'edge',
 }
 MODULE = 'com.google.gerrit.GerritGwtUI'
 CPU_COUNT = cpu_count()
@@ -32,6 +34,7 @@
   dbg = 'ui_dbg' + suffix
   opt = 'ui_opt' + suffix
   soyc = 'ui_soyc' + suffix
+  module_dep = ':ui_module' + suffix
   args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
 
   genrule(
@@ -51,7 +54,7 @@
   gwt_binary(
     name = opt,
     modules = [module],
-    module_deps = [':ui_module'],
+    module_deps = [module_dep],
     deps = deps + ([':' + dbg] if CPU_COUNT < 8 else []),
     local_workers = CPU_COUNT,
     strict = True,
@@ -64,19 +67,19 @@
     modules = [module],
     style = 'PRETTY',
     optimize = 0,
-    module_deps = [':ui_module'],
+    module_deps = [module_dep],
     deps = deps,
     local_workers = CPU_COUNT,
     strict = True,
     experimental_args = args,
     vm_args = GWT_JVM_ARGS,
-    visibility = ['//:eclipse'],
+    visibility = ['PUBLIC'],
   )
 
   gwt_binary(
     name = soyc,
     modules = [module],
-    module_deps = [':ui_module'],
+    module_deps = [module_dep],
     deps = deps + [':' + dbg],
     local_workers = CPU_COUNT,
     strict = True,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
index c02518b..9644093 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
@@ -34,6 +34,8 @@
       <when-property-is name="user.agent" value="ie8"/>
       <when-property-is name="user.agent" value="ie9"/>
       <when-property-is name="user.agent" value="ie10"/>
+      <when-property-is name="user.agent" value="ie11"/>
+      <when-property-is name="user.agent" value="edge"/>
     </any>
   </replace-with>
 </module>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
index bec89cc..4ca1721 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
@@ -46,18 +46,6 @@
    * @param size A requested size. Note that the size can be ignored depending
    *        on the avatar provider. A size <= 0 indicates to let the provider
    *        decide a default size.
-   */
-  public AvatarImage(AccountInfo account, int size) {
-    this(account, size, true);
-  }
-
-  /**
-   * An avatar image for the given account using the requested size.
-   *
-   * @param account The account in which we are interested
-   * @param size A requested size. Note that the size can be ignored depending
-   *        on the avatar provider. A size <= 0 indicates to let the provider
-   *        decide a default size.
    * @param addPopup show avatar popup with user info on hovering over the
    *        avatar image
    */
@@ -138,7 +126,7 @@
     private Timer showTimer;
     private Timer hideTimer;
 
-    public PopupHandler(AccountInfo account, UIObject target) {
+    PopupHandler(AccountInfo account, UIObject target) {
       this.account = account;
       this.target = target;
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index ba3cc4c..ba4b202 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -35,6 +35,7 @@
 import static com.google.gerrit.common.PageLinks.SETTINGS_HTTP_PASSWORD;
 import static com.google.gerrit.common.PageLinks.SETTINGS_MYGROUPS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_NEW_AGREEMENT;
+import static com.google.gerrit.common.PageLinks.SETTINGS_OAUTH_TOKEN;
 import static com.google.gerrit.common.PageLinks.SETTINGS_PREFERENCES;
 import static com.google.gerrit.common.PageLinks.SETTINGS_PROJECTS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_SSHKEYS;
@@ -48,6 +49,7 @@
 import com.google.gerrit.client.account.MyGpgKeysScreen;
 import com.google.gerrit.client.account.MyGroupsScreen;
 import com.google.gerrit.client.account.MyIdentitiesScreen;
+import com.google.gerrit.client.account.MyOAuthTokenScreen;
 import com.google.gerrit.client.account.MyPasswordScreen;
 import com.google.gerrit.client.account.MyPreferencesScreen;
 import com.google.gerrit.client.account.MyProfileScreen;
@@ -83,17 +85,17 @@
 import com.google.gerrit.client.dashboards.DashboardList;
 import com.google.gerrit.client.diff.DisplaySide;
 import com.google.gerrit.client.diff.SideBySide;
+import com.google.gerrit.client.diff.Unified;
 import com.google.gerrit.client.documentation.DocScreen;
 import com.google.gerrit.client.editor.EditScreen;
 import com.google.gerrit.client.groups.GroupApi;
 import com.google.gerrit.client.groups.GroupInfo;
-import com.google.gerrit.client.patches.UnifiedPatchScreen;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.Screen;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
@@ -102,23 +104,29 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.RunAsyncCallback;
 import com.google.gwt.http.client.URL;
+import com.google.gwtexpui.user.client.UserAgent;
 import com.google.gwtorm.client.KeyUtil;
 
 public class Dispatcher {
-  public static String toSideBySide(PatchSet.Id diffBase, Patch.Key id) {
-    return toPatch("", diffBase, id);
-  }
-
-  public static String toSideBySide(PatchSet.Id diffBase,
+  public static String toPatch(PatchSet.Id diffBase,
       PatchSet.Id revision, String fileName) {
     return toPatch("", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toSideBySide(PatchSet.Id diffBase,
+  public static String toPatch(PatchSet.Id diffBase,
       PatchSet.Id revision, String fileName, DisplaySide side, int line) {
     return toPatch("", diffBase, revision, fileName, side, line);
   }
 
+  public static String toSideBySide(PatchSet.Id diffBase, Patch.Key id) {
+    return toPatch("sidebyside", diffBase, id);
+  }
+
+  public static String toSideBySide(PatchSet.Id diffBase,
+      PatchSet.Id revision, String fileName) {
+    return toPatch("sidebyside", diffBase, revision, fileName, null, 0);
+  }
+
   public static String toUnified(PatchSet.Id diffBase,
       PatchSet.Id revision, String fileName) {
     return toPatch("unified", diffBase, revision, fileName, null, 0);
@@ -277,12 +285,10 @@
   private static Screen mine() {
     if (Gerrit.isSignedIn()) {
       return new AccountDashboardScreen(Gerrit.getUserAccount().getId());
-
-    } else {
-      Screen r = new AccountDashboardScreen(null);
-      r.setRequiresSignIn(true);
-      return r;
     }
+    Screen r = new AccountDashboardScreen(null);
+    r.setRequiresSignIn(true);
+    return r;
   }
 
   private static void dashboard(final String token) {
@@ -418,7 +424,7 @@
       int line = 0;
       int at = rest.lastIndexOf('@');
       if (at > 0) {
-        String l = rest.substring(at+1);
+        String l = rest.substring(at + 1);
         if (l.startsWith("a")) {
           side = DisplaySide.A;
           l = l.substring(1);
@@ -471,14 +477,14 @@
 
     if ("".equals(panel) || /* DEPRECATED URL */"cm".equals(panel)) {
       if (preferUnified()) {
-        unified(token, baseId, id);
+        unified(token, baseId, id, side, line);
       } else {
         codemirror(token, baseId, id, side, line, false);
       }
     } else if ("sidebyside".equals(panel)) {
-      codemirror(token, null, id, side, line, false);
+      codemirror(token, baseId, id, side, line, false);
     } else if ("unified".equals(panel)) {
-      unified(token, baseId, id);
+      unified(token, baseId, id, side, line);
     } else if ("edit".equals(panel)) {
       codemirror(token, null, id, side, line, true);
     } else {
@@ -487,17 +493,17 @@
   }
 
   private static boolean preferUnified() {
-    return DiffView.UNIFIED_DIFF.equals(Gerrit.getUserPreferences().diffView());
+    return DiffView.UNIFIED_DIFF.equals(Gerrit.getUserPreferences().diffView())
+        || (UserAgent.isPortrait() && UserAgent.isMobile());
   }
 
-  private static void unified(final String token,
-      final PatchSet.Id baseId,
-      final Patch.Key id) {
+  private static void unified(final String token, final PatchSet.Id baseId,
+      final Patch.Key id, final DisplaySide side, final int line) {
     GWT.runAsync(new AsyncSplit(token) {
       @Override
       public void onSuccess() {
-        UnifiedPatchScreen.TopView top = Gerrit.getPatchScreenTopView();
-        Gerrit.display(token, new UnifiedPatchScreen(id, top, baseId));
+        Gerrit.display(token,
+            new Unified(baseId, id.getParentKey(), id.get(), side, line));
       }
     });
   }
@@ -564,6 +570,12 @@
           return new MyPasswordScreen();
         }
 
+        if (matchExact(SETTINGS_OAUTH_TOKEN, token)
+            && Gerrit.info().auth().isOAuth()
+            && Gerrit.info().auth().isGitBasicAuth()) {
+          return new MyOAuthTokenScreen();
+        }
+
         if (matchExact(MY_GROUPS, token)
             || matchExact(SETTINGS_MYGROUPS, token)) {
           return new MyGroupsScreen();
@@ -599,9 +611,8 @@
               new ExtensionSettingsScreen(skip(token));
           if (view.isFound()) {
             return view;
-          } else {
-            return new NotFoundScreen();
           }
+          return new NotFoundScreen();
         }
 
         return new NotFoundScreen();
@@ -706,11 +717,15 @@
               // shown in the web UI).
               //
               if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
-                Gerrit.display(toGroup(group.getGroupId(), AccountGroupScreen.MEMBERS),
-                    new AccountGroupMembersScreen(group, token));
+                String newToken =
+                    toGroup(group.getGroupId(), AccountGroupScreen.MEMBERS);
+                Gerrit.display(newToken,
+                    new AccountGroupMembersScreen(group, newToken));
               } else {
-                Gerrit.display(toGroup(group.getGroupId(), AccountGroupScreen.INFO),
-                    new AccountGroupInfoScreen(group, token));
+                String newToken =
+                    toGroup(group.getGroupId(), AccountGroupScreen.INFO);
+                Gerrit.display(newToken,
+                    new AccountGroupInfoScreen(group, newToken));
               }
             } else if (AccountGroupScreen.INFO.equals(panel)) {
               Gerrit.display(token, new AccountGroupInfoScreen(group, token));
@@ -780,9 +795,8 @@
     if (token.startsWith(want)) {
       prefixlen = want.length();
       return true;
-    } else {
-      return false;
     }
+    return false;
   }
 
   private static String skip(String token) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
index a5c5659..87cb4b3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
@@ -27,7 +27,6 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Label;
 import com.google.gwt.user.client.ui.PopupPanel;
-import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtjsonrpc.client.RemoteJsonException;
 
@@ -96,11 +95,6 @@
     body.add(message.toBlockWidget());
   }
 
-  public ErrorDialog(final Widget w) {
-    this();
-    body.add(w);
-  }
-
   /** Create a dialog box to nicely format an exception. */
   public ErrorDialog(final Throwable what) {
     this();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index c77b71f..dd1505c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.client;
 
+import com.google.gerrit.client.change.Resources;
 import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gwt.i18n.client.NumberFormat;
 
@@ -25,7 +26,7 @@
 public class FormatUtil {
   private static DateFormatter dateFormatter;
 
-  public static void setPreferences(AccountPreferencesInfo prefs) {
+  public static void setPreferences(GeneralPreferences prefs) {
     dateFormatter = new DateFormatter(prefs);
   }
 
@@ -134,4 +135,19 @@
         + NumberFormat.getFormat("#.0").format(bytes / Math.pow(1024, exp))
         + " " + "KMGTPE".charAt(exp - 1) + "iB";
   }
+
+  public static String formatPercentage(long size, long delta) {
+    if (size == 0) {
+      return Resources.C.notAvailable();
+    }
+    return (delta > 0 ? "+" : "-") + formatAbsPercentage(size, delta);
+  }
+
+  public static String formatAbsPercentage(long size, long delta) {
+    if (size == 0) {
+      return Resources.C.notAvailable();
+    }
+    int p = Math.abs(Math.round(delta * 100 / size));
+    return p + "%";
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
index 560fa9e..d280e07 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
@@ -25,18 +25,17 @@
 import com.google.gerrit.client.admin.ProjectScreen;
 import com.google.gerrit.client.api.ApiGlue;
 import com.google.gerrit.client.api.PluginLoader;
-import com.google.gerrit.client.changes.ChangeConstants;
+import com.google.gerrit.client.change.LocalComments;
 import com.google.gerrit.client.changes.ChangeListScreen;
 import com.google.gerrit.client.config.ConfigServerApi;
 import com.google.gerrit.client.documentation.DocInfo;
 import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
 import com.google.gerrit.client.info.AuthInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.info.ServerInfo;
 import com.google.gerrit.client.info.TopMenu;
 import com.google.gerrit.client.info.TopMenuItem;
 import com.google.gerrit.client.info.TopMenuList;
-import com.google.gerrit.client.patches.UnifiedPatchScreen;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
@@ -55,7 +54,6 @@
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.core.client.EntryPoint;
 import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.AnchorElement;
 import com.google.gwt.dom.client.Document;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -101,7 +99,6 @@
 
 public class Gerrit implements EntryPoint {
   public static final GerritConstants C = GWT.create(GerritConstants.class);
-  public static final ChangeConstants CC = GWT.create(ChangeConstants.class);
   public static final GerritMessages M = GWT.create(GerritMessages.class);
   public static final GerritResources RESOURCES =
       GWT.create(GerritResources.class);
@@ -114,9 +111,10 @@
   private static String myHost;
   private static ServerInfo myServerInfo;
   private static AccountInfo myAccount;
-  private static AccountPreferencesInfo myPrefs;
+  private static GeneralPreferences myPrefs;
   private static UrlAliasMatcher urlAliasMatcher;
   private static boolean hasDocumentation;
+  private static boolean docSearch;
   private static String docUrl;
   private static HostPageData.Theme myTheme;
   private static String defaultScreenToken;
@@ -136,7 +134,6 @@
   private static SearchPanel searchPanel;
   private static final Dispatcher dispatcher = new Dispatcher();
   private static ViewSite<Screen> body;
-  private static UnifiedPatchScreen patchScreen;
   private static String lastChangeListToken;
   private static String lastViewToken;
 
@@ -150,13 +147,6 @@
     Window.Location.reload();
   }
 
-  public static UnifiedPatchScreen.TopView getPatchScreenTopView() {
-    if (patchScreen == null) {
-      return null;
-    }
-    return patchScreen.getTopView();
-  }
-
   public static void displayLastChangeList() {
     if (lastChangeListToken != null) {
       display(lastChangeListToken);
@@ -205,27 +195,10 @@
       doSignIn(token);
     } else {
       view.setToken(token);
-      body.setView(view);
-    }
-  }
-
-  /**
-   * Update any top level menus which can vary based on the view which was
-   * loaded.
-   * @param view the loaded view.
-   */
-  public static void updateMenus(Screen view) {
-    LinkMenuBar diffBar = menuBars.get(GerritTopMenu.DIFFERENCES.menuName);
-    if (view instanceof UnifiedPatchScreen) {
-      patchScreen = (UnifiedPatchScreen) view;
-      menuLeft.setVisible(diffBar, true);
-      menuLeft.selectTab(menuLeft.getWidgetIndex(diffBar));
-    } else {
-      if (patchScreen != null && menuLeft.getSelectedWidget() == diffBar) {
-        menuLeft.selectTab(isSignedIn() ? 1 : 0);
+      if (isSignedIn()) {
+        LocalComments.saveInlineComments();
       }
-      patchScreen = null;
-      menuLeft.setVisible(diffBar, false);
+      body.setView(view);
     }
   }
 
@@ -318,7 +291,7 @@
   }
 
   /** @return the preferences of the currently signed in user, the default preferences if not signed in */
-  public static AccountPreferencesInfo getUserPreferences() {
+  public static GeneralPreferences getUserPreferences() {
     return myPrefs;
   }
 
@@ -404,7 +377,7 @@
     myAccount = AccountInfo.create(0, null, null, null);
     myAccountDiffPref = null;
     editPrefs = null;
-    myPrefs = AccountPreferencesInfo.createDefault();
+    myPrefs = GeneralPreferences.createDefault();
     urlAliasMatcher.clearUserAliases();
     xGerritAuth = null;
     refreshMenuBar();
@@ -417,7 +390,6 @@
 
   private void setXsrfToken() {
     xGerritAuth = Cookies.getCookie(XSRF_COOKIE_NAME);
-    Cookies.removeCookie(XSRF_COOKIE_NAME);
     JsonUtil.setDefaultXsrfManager(new XsrfManager() {
       @Override
       public String getToken(JsonDefTarget proxy) {
@@ -433,7 +405,9 @@
 
   @Override
   public void onModuleLoad() {
-    UserAgent.assertNotInIFrame();
+    if (!canLoadInIFrame()) {
+      UserAgent.assertNotInIFrame();
+    }
     setXsrfToken();
 
     KeyUtil.setEncoderImpl(new KeyUtil.Encoder() {
@@ -483,6 +457,7 @@
           hasDocumentation = true;
           docUrl = du;
         }
+        docSearch = info.gerrit().docSearch();
       }
     }));
     HostPageDataService hpd = GWT.create(HostPageDataService.class);
@@ -508,9 +483,9 @@
                 }
           }));
           AccountApi.self().view("preferences")
-              .get(cbg.add(new GerritCallback<AccountPreferencesInfo>() {
+              .get(cbg.add(new GerritCallback<GeneralPreferences>() {
             @Override
-            public void onSuccess(AccountPreferencesInfo prefs) {
+            public void onSuccess(GeneralPreferences prefs) {
               myPrefs = prefs;
               onModuleLoad2(result);
             }
@@ -526,7 +501,7 @@
           }));
         } else {
           myAccount = AccountInfo.create(0, null, null, null);
-          myPrefs = AccountPreferencesInfo.createDefault();
+          myPrefs = GeneralPreferences.createDefault();
           editPrefs = null;
           onModuleLoad2(result);
         }
@@ -534,6 +509,10 @@
     }));
   }
 
+  private native boolean canLoadInIFrame() /*-{
+    return $wnd.gerrit_hostpagedata.canLoadInIFrame || false;
+  }-*/;
+
   private static void initHostname() {
     myHost = Location.getHostName();
     final int d1 = myHost.indexOf('.');
@@ -713,15 +692,6 @@
       menuLeft.selectTab(0);
     }
 
-    patchScreen = null;
-    LinkMenuBar diffBar = new LinkMenuBar();
-    menuBars.put(GerritTopMenu.DIFFERENCES.menuName, diffBar);
-    menuLeft.addInvisible(diffBar, C.menuDiff());
-    addDiffLink(diffBar, C.menuDiffCommit(), UnifiedPatchScreen.TopView.COMMIT);
-    addDiffLink(diffBar, C.menuDiffPreferences(), UnifiedPatchScreen.TopView.PREFERENCES);
-    addDiffLink(diffBar, C.menuDiffPatchSets(), UnifiedPatchScreen.TopView.PATCH_SETS);
-    addDiffLink(diffBar, C.menuDiffFiles(), UnifiedPatchScreen.TopView.FILES);
-
     final LinkMenuBar projectsBar = new LinkMenuBar();
     menuBars.put(GerritTopMenu.PROJECTS.menuName, projectsBar);
     addLink(projectsBar, C.menuProjectsList(), PageLinks.ADMIN_PROJECTS);
@@ -885,18 +855,18 @@
   public static void refreshUserPreferences() {
     if (isSignedIn()) {
       AccountApi.self().view("preferences")
-          .get(new GerritCallback<AccountPreferencesInfo>() {
+          .get(new GerritCallback<GeneralPreferences>() {
             @Override
-            public void onSuccess(AccountPreferencesInfo prefs) {
+            public void onSuccess(GeneralPreferences prefs) {
               setUserPreferences(prefs);
             }
           });
     } else {
-      setUserPreferences(AccountPreferencesInfo.createDefault());
+      setUserPreferences(GeneralPreferences.createDefault());
     }
   }
 
-  public static void setUserPreferences(AccountPreferencesInfo prefs) {
+  public static void setUserPreferences(GeneralPreferences prefs) {
     myPrefs = prefs;
     applyUserPreferences();
     refreshMenuBar();
@@ -914,6 +884,10 @@
     urlAliasMatcher.updateUserAliases(myPrefs.urlAliases());
   }
 
+  public static boolean hasDocSearch() {
+    return docSearch;
+  }
+
   private static void getDocIndex(final AsyncCallback<DocInfo> cb) {
     RequestBuilder req =
         new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL()
@@ -966,7 +940,7 @@
 
       @Override
       public void onKeyDown(KeyDownEvent event) {
-        if(event.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
+        if (event.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
           showHidePopup();
           event.preventDefault();
         }
@@ -1009,19 +983,6 @@
     m.insertItem(new LinkMenuItem(text, historyToken), beforeIndex);
   }
 
-  private static void addDiffLink(final LinkMenuBar m, final String text,
-      final UnifiedPatchScreen.TopView tv) {
-    m.addItem(new LinkMenuItem(text, "") {
-        @Override
-        public void go() {
-          if (patchScreen != null) {
-            patchScreen.setTopView(tv);
-          }
-          AnchorElement.as(getElement()).blur();
-        }
-      });
-  }
-
   private static LinkMenuItem addProjectLink(LinkMenuBar m, TopMenuItem item) {
     LinkMenuItem i = new ProjectLinkMenuItem(item.getName(), item.getUrl()) {
         @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
index 269999c..4c8c58d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
@@ -105,6 +105,7 @@
   String sectionNavigation();
   String sectionActions();
   String keySearch();
+  String keyEditor();
   String keyHelp();
 
   String sectionJumping();
@@ -126,4 +127,7 @@
   String stringListPanelDelete();
   String stringListPanelUp();
   String stringListPanelDown();
+
+  String searchDropdownChanges();
+  String searchDropdownDoc();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
index fb74506..10d7e1d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
@@ -88,6 +88,7 @@
 sectionNavigation = Navigation
 sectionActions = Actions
 keySearch = Search
+keyEditor = Open Inline Editor
 keyHelp = Press '?' to view keyboard shortcuts
 
 sectionJumping = Jumping
@@ -109,3 +110,6 @@
 stringListPanelDelete = Delete
 stringListPanelUp = Up
 stringListPanelDown = Down
+
+searchDropdownChanges = Changes
+searchDropdownDoc = Docs
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index 7735e1d..32e30d4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -37,27 +37,9 @@
   String cSIZE();
   String cSUBJECT();
   String cSTATUS();
-  String cellsNextToFileComment();
-  String changeScreenDescription();
-  String changeScreenStarIcon();
   String changeSize();
   String changeTable();
   String changeTablePrevNextLinks();
-  String changeTypeCell();
-  String commentCell();
-  String commentEditorPanel();
-  String commentHolder();
-  String commentHolderLeftmost();
-  String commentPanel();
-  String commentPanelAuthorCell();
-  String commentPanelButtons();
-  String commentPanelContent();
-  String commentPanelDateCell();
-  String commentPanelHeader();
-  String commentPanelLast();
-  String commentPanelMessage();
-  String commentPanelSummary();
-  String commentPanelSummaryCell();
   String commentedActionDialog();
   String commentedActionMessage();
   String contributorAgreementAlreadySubmitted();
@@ -69,14 +51,6 @@
   String dataCellHidden();
   String dataHeader();
   String dataHeaderHidden();
-  String diffLinkCell();
-  String diffText();
-  String diffTextCONTEXT();
-  String diffTextDELETE();
-  String diffTextFileHeader();
-  String diffTextHunkHeader();
-  String diffTextINSERT();
-  String diffTextNoLF();
   String downloadBox();
   String downloadBoxTable();
   String downloadBoxTableCommandColumn();
@@ -89,7 +63,6 @@
   String downloadLinkHeaderGap();
   String downloadLinkList();
   String downloadLink_Active();
-  String drafts();
   String editHeadButton();
   String emptySection();
   String errorDialog();
@@ -99,12 +72,6 @@
   String errorDialogTitle();
   String extensionPanel();
   String loadingPluginsDialog();
-  String fileColumnHeader();
-  String fileCommentBorder();
-  String fileLine();
-  String fileLineDELETE();
-  String fileLineINSERT();
-  String filePathCell();
   String gerritBody();
   String gerritTopMenu();
   String greenCheckClass();
@@ -120,18 +87,15 @@
   String groupUUIDPanel();
   String header();
   String iconCell();
-  String iconCellOfFileCommentRow();
   String iconHeader();
   String identityUntrustedExternalId();
   String infoBlock();
   String inputFieldTypeHint();
   String labelNotApplicable();
   String leftMostCell();
-  String lineNumber();
   String link();
   String linkMenuBar();
   String linkMenuItemNotLast();
-  String linkPanel();
   String maxObjectSizeLimitEffectiveLabel();
   String menuBarUserName();
   String menuBarUserNameAvatar();
@@ -141,18 +105,16 @@
   String menuScreenMenuBar();
   String needsReview();
   String negscore();
-  String noborder();
-  String nowrap();
+  String oauthExpires();
+  String oauthInfoBlock();
+  String oauthPanel();
+  String oauthPanelCookieEntry();
+  String oauthPanelCookieHeading();
+  String oauthPanelNetRCEntry();
+  String oauthPanelNetRCHeading();
+  String oauthToken();
   String pagingLink();
-  String patchBrowserPopup();
-  String patchBrowserPopupBody();
-  String patchCellReverseDiff();
-  String patchContentTable();
-  String patchHistoryTable();
-  String patchHistoryTablePatchSetHeader();
-  String patchNoDifference();
   String patchSetActions();
-  String patchSizeCell();
   String pluginProjectConfigInheritedValue();
   String pluginsTable();
   String posscore();
@@ -160,23 +122,20 @@
   String projectFilterLabel();
   String projectFilterPanel();
   String projectNameColumn();
+  String queryIcon();
   String rebaseContentPanel();
   String rebaseSuggestBox();
   String registerScreenExplain();
   String registerScreenNextLinks();
   String registerScreenSection();
-  String reviewedPanelBottom();
-  String rightBorder();
   String rpcStatus();
   String screen();
   String screenHeader();
   String searchPanel();
   String suggestBoxPopup();
   String sectionHeader();
-  String sideBySideScreenLinkTable();
   String singleLine();
   String smallHeading();
-  String sourceFilePath();
   String specialBranchDataCell();
   String specialBranchIconCell();
   String sshHostKeyPanel();
@@ -187,15 +146,12 @@
   String sshKeyPanelInvalid();
   String sshKeyTable();
   String stringListPanelButtons();
-  String topMostCell();
   String topmenu();
   String topmenuMenuLeft();
   String topmenuMenuRight();
   String topmenuTDglue();
   String topmenuTDmenu();
   String topmost();
-  String unifiedTable();
-  String unifiedTableHeader();
   String userInfoPopup();
   String usernameField();
   String watchedProjectFilter();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
index 21da8ce..58442b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
@@ -36,4 +36,6 @@
   String cannotDownloadPlugin(String scriptPath);
 
   String parentUpdateFailed(String message);
+
+  String fileCount(int fileNumber, int fileCount);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
index 2832d41..b2d67b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
@@ -17,3 +17,5 @@
 cannotDownloadPlugin = Cannot load plugin from {0}
 
 parentUpdateFailed = Setting parent project failed: {0}
+
+fileCount = File {0} of {1}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
new file mode 100644
index 0000000..f86d8f4
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class RangeInfo extends JavaScriptObject {
+  public final native int start() /*-{ return this.start; }-*/;
+
+  public final native int end() /*-{ return this.end; }-*/;
+
+  protected RangeInfo() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
index 45b1d52..83a187b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
@@ -27,6 +27,7 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwt.user.client.ui.SuggestBox;
 import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
@@ -34,6 +35,7 @@
 
 class SearchPanel extends Composite {
   private final HintTextBox searchBox;
+  private final ListBox dropdown;
   private HandlerRegistration regFocus;
 
   SearchPanel() {
@@ -54,6 +56,18 @@
       }
     });
 
+    if (Gerrit.hasDocSearch()) {
+      dropdown = new ListBox();
+      dropdown.setStyleName("searchDropdown");
+      dropdown.addItem(Gerrit.C.searchDropdownChanges());
+      dropdown.addItem(Gerrit.C.searchDropdownDoc());
+      dropdown.setVisibleItemCount(1);
+      dropdown.setSelectedIndex(0);
+    } else {
+      // Doc search is NOT available.
+      dropdown = null;
+    }
+
     final SuggestBox suggestBox =
         new SuggestBox(new SearchSuggestOracle(), searchBox, suggestionDisplay);
     searchBox.setStyleName("searchTextBox");
@@ -70,6 +84,9 @@
     });
 
     body.add(suggestBox);
+    if (dropdown != null) {
+      body.add(dropdown);
+    }
     body.add(searchButton);
   }
 
@@ -110,14 +127,23 @@
 
     searchBox.setFocus(false);
 
-    if (query.matches("^[1-9][0-9]*$")) {
-      Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
+    if (dropdown != null
+        && dropdown.getSelectedValue().equals(Gerrit.C.searchDropdownDoc())) {
+      // doc
+      Gerrit.display(PageLinks.toDocumentationQuery(query));
     } else {
-      Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+      // changes
+      if (query.matches("^[1-9][0-9]*$")) {
+        Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
+      } else {
+        Gerrit.display(
+            PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+      }
     }
   }
 
-  private static class MySuggestionDisplay extends SuggestBox.DefaultSuggestionDisplay {
+  private static class MySuggestionDisplay
+      extends SuggestBox.DefaultSuggestionDisplay {
     private boolean isSuggestionSelected;
 
     private MySuggestionDisplay() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index afbaf85..54c5b92 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -29,7 +29,9 @@
   private static final List<ParamSuggester> paramSuggester = Arrays.asList(
       new ParamSuggester(Arrays.asList("project:", "parentproject:"),
           new ProjectNameSuggestOracle()),
-      new ParamSuggester(Arrays.asList("owner:", "reviewer:"),
+      new ParamSuggester(Arrays.asList(
+          "owner:", "reviewer:", "commentby:", "reviewedby:", "author:",
+          "committer:", "from:"),
           new AccountSuggestOracle() {
             @Override
             public void onRequestSuggestions(final Request request, final Callback done) {
@@ -102,6 +104,8 @@
     suggestions.add("has:draft");
     suggestions.add("has:edit");
     suggestions.add("has:star");
+    suggestions.add("has:stars");
+    suggestions.add("star:");
 
     suggestions.add("is:");
     suggestions.add("is:starred");
@@ -124,6 +128,7 @@
     suggestions.add("status:closed");
     suggestions.add("status:merged");
     suggestions.add("status:abandoned");
+    suggestions.add("status:draft");
 
     suggestions.add("added:");
     suggestions.add("deleted:");
@@ -205,7 +210,7 @@
   private static class SearchSuggestion implements SuggestOracle.Suggestion {
     private final String suggestion;
     private final String fullQuery;
-    public SearchSuggestion(String suggestion, String fullQuery) {
+    SearchSuggestion(String suggestion, String fullQuery) {
       this.suggestion = suggestion;
       // Add a space to the query if it is a complete operation (e.g.
       // "status:open") so the user can keep on typing.
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
index a1bcfe8..acd2e78 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
@@ -27,6 +27,7 @@
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
+import java.util.HashSet;
 import java.util.Set;
 
 /**
@@ -45,13 +46,14 @@
 
   /** Put the account edit preferences */
   public static void putEditPreferences(EditPreferences in,
-      AsyncCallback<VoidResult> cb) {
+      AsyncCallback<EditPreferences> cb) {
     self().view("preferences.edit").put(in, cb);
   }
 
   public static void suggest(String query, int limit,
       AsyncCallback<JsArray<AccountInfo>> cb) {
     new RestApi("/accounts/")
+      .addParameterTrue("suggest")
       .addParameter("q", query)
       .addParameter("n", limit)
       .background()
@@ -76,6 +78,11 @@
     new RestApi("/accounts/").id(account).view("username").put(input, cb);
   }
 
+  /** Retrieve the account name */
+  public static void getName(String account, AsyncCallback<NativeString> cb) {
+    new RestApi("/accounts/").id(account).view("name").get(cb);
+  }
+
   /** Retrieve email addresses */
   public static void getEmails(String account,
       AsyncCallback<JsArray<EmailInfo>> cb) {
@@ -103,6 +110,54 @@
         .post(sshPublicKey, cb);
   }
 
+  /** Retrieve Watched Projects */
+  public static void getWatchedProjects(String account,
+      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+    new RestApi("/accounts/").id(account).view("watched.projects").get(cb);
+  }
+
+  /** Create/Update Watched Project */
+  public static void updateWatchedProject(
+      String account,
+      ProjectWatchInfo watchedProjectInfo,
+      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+    Set<ProjectWatchInfo> watchedProjectInfos = new HashSet<>();
+    watchedProjectInfos.add(watchedProjectInfo);
+    updateWatchedProjects(account, watchedProjectInfos, cb);
+  }
+
+  /** Create/Update Watched Projects */
+  public static void updateWatchedProjects(
+      String account,
+      Set<ProjectWatchInfo> watchedProjectInfos,
+      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+    new RestApi("/accounts/")
+        .id(account)
+        .view("watched.projects")
+        .post(projectWatchArrayFromSet(watchedProjectInfos), cb);
+  }
+
+  /** Delete Watched Project */
+  public static void deleteWatchedProject(
+      String account,
+      ProjectWatchInfo watchedProjectInfo,
+      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+    Set<ProjectWatchInfo> watchedProjectInfos = new HashSet<>();
+    watchedProjectInfos.add(watchedProjectInfo);
+    deleteWatchedProjects(account, watchedProjectInfos, cb);
+  }
+
+  /** Delete Watched Projects */
+  public static void deleteWatchedProjects(
+      String account,
+      Set<ProjectWatchInfo> watchedProjectInfos,
+      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+    new RestApi("/accounts/")
+        .id(account)
+        .view("watched.projects:delete")
+        .post(projectWatchArrayFromSet(watchedProjectInfos), cb);
+  }
+
   /**
    * Delete SSH keys. For each key to be deleted a separate DELETE request is
    * fired to the server. The {@code onSuccess} method of the provided callback
@@ -141,6 +196,15 @@
     new RestApi("/accounts/").id(account).view("password.http").delete(cb);
   }
 
+  private static JsArray<ProjectWatchInfo> projectWatchArrayFromSet(
+      Set<ProjectWatchInfo> set) {
+    JsArray<ProjectWatchInfo> jsArray = JsArray.createArray().cast();
+    for (ProjectWatchInfo p : set) {
+      jsArray.push(p);
+    }
+    return jsArray;
+  }
+
   private static class HttpPasswordInput extends JavaScriptObject {
     final native void generate(boolean g) /*-{ if(g)this.generate=g; }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index 94884fa..a084612 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -31,7 +31,6 @@
   String contextWholeFile();
   String showSiteHeader();
   String useFlashClipboard();
-  String copySelfOnEmails();
   String reviewCategoryLabel();
   String messageShowInReviewCategoryNone();
   String messageShowInReviewCategoryName();
@@ -43,6 +42,7 @@
   String showSizeBarInChangeTable();
   String showLegacycidInChangeTable();
   String muteCommonPathPrefixes();
+  String signedOffBy();
   String myMenu();
   String myMenuInfo();
   String myMenuName();
@@ -57,6 +57,7 @@
   String tabGpgKeys();
   String tabHttpAccess();
   String tabMyGroups();
+  String tabOAuthToken();
   String tabPreferences();
   String tabSshKeys();
   String tabWatchedProjects();
@@ -81,6 +82,12 @@
   String invalidUserName();
   String invalidUserEmail();
 
+  String labelOAuthToken();
+  String labelOAuthExpires();
+  String labelOAuthNetRCEntry();
+  String labelOAuthGitCookie();
+  String labelOAuthExpired();
+
   String sshKeyInvalid();
   String sshKeyAlgorithm();
   String sshKeyKey();
@@ -159,4 +166,9 @@
   String welcomeAgreementText();
   String welcomeAgreementLater();
   String welcomeContinue();
+
+  String messageEnabled();
+  String messageCCMeOnMyComments();
+  String messageDisabled();
+  String emailFieldLabel();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index 0944448..ca2d316 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -5,9 +5,8 @@
 preferredEmail = Email Address
 registeredOn = Registered
 accountId = Account ID
-showSiteHeader = Show Site Header
+showSiteHeader = Show Site Header / Footer
 useFlashClipboard = Use Flash Clipboard Widget
-copySelfOnEmails = CC Me On Comments I Write
 reviewCategoryLabel = Display In Review Category
 messageShowInReviewCategoryNone = None (default)
 messageShowInReviewCategoryName = Show Name
@@ -15,6 +14,11 @@
 messageShowInReviewCategoryUsername = Show Username
 messageShowInReviewCategoryAbbrev = Show Abbreviated Name
 
+emailFieldLabel = Email Notifications:
+messageEnabled = Enabled
+messageCCMeOnMyComments = CC Me On Comments I Write
+messageDisabled = Disabled
+
 maximumPageSizeFieldLabel = Maximum Page Size:
 diffViewLabel = Diff View:
 dateFormatLabel = Date/Time Format:
@@ -24,6 +28,7 @@
 showSizeBarInChangeTable = Show Change Sizes As Colored Bars
 showLegacycidInChangeTable = Show Change Number In Changes Table
 muteCommonPathPrefixes = Mute Common Path Prefixes In File List
+signedOffBy = Insert Signed-off-by Footer For Inline Edit Changes
 myMenu = My Menu
 myMenuInfo = \
   Menu items for the 'My' top level menu. \
@@ -39,6 +44,7 @@
 tabEditPreferences = Edit Preferences
 tabGpgKeys = GPG Public Keys
 tabHttpAccess = HTTP Password
+tabOAuthToken = OAuth Token
 tabMyGroups = Groups
 tabPreferences = Preferences
 tabSshKeys = SSH Public Keys
@@ -63,6 +69,13 @@
 linkReloadContact = Reload
 invalidUserName = Username must contain only letters, numbers, _, - or .
 invalidUserEmail = Email format is wrong.
+
+labelOAuthToken = Access Token
+labelOAuthExpires = Expires
+labelOAuthNetRCEntry = Entry for ~/.netrc
+labelOAuthGitCookie = Entry for ~/.gitcookies
+labelOAuthExpired = To obtain an access token please sign out and sign in again.
+
 sshKeyInvalid = Invalid Key
 sshKeyAlgorithm = Algorithm
 sshKeyKey = Key
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
index 68a99e0..b398c0f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
@@ -18,7 +18,7 @@
 
 public interface AccountMessages extends Messages {
   String lines(short cnt);
-  String rowsPerPage(short cnt);
+  String rowsPerPage(int cnt);
   String changeScreenServerDefault(String d);
   String enterIAGREE(String iagree);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
index 901fe96..ae3599d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.common.PageLinks;
@@ -59,6 +61,7 @@
   NpTextBox nameTxt;
   private ListBox emailPick;
   private Button registerNewEmail;
+  private OnEditEnabler onEditEnabler;
 
   ContactPanelShort() {
     body = new FlowPanel();
@@ -165,6 +168,8 @@
         }
       }
     });
+
+    onEditEnabler = new OnEditEnabler(save, nameTxt);
   }
 
   private boolean canEditFullName() {
@@ -194,36 +199,41 @@
     haveAccount = false;
     haveEmails = false;
 
-    Util.ACCOUNT_SVC.myAccount(new GerritCallback<Account>() {
+    CallbackGroup group = new CallbackGroup();
+    AccountApi.getName("self", group.add(new GerritCallback<NativeString>() {
+
       @Override
-      public void onSuccess(Account result) {
-        if (!isAttached()) {
-          return;
-        }
-        display(FormatUtil.asInfo(result));
+      public void onSuccess(NativeString result) {
+        nameTxt.setText(result.asString());
         haveAccount = true;
-        postLoad();
       }
-    });
-    AccountApi.getEmails("self", new GerritCallback<JsArray<EmailInfo>>() {
+
+      @Override
+      public void onFailure(Throwable caught) {
+      }
+    }));
+
+    AccountApi.getEmails("self", group.addFinal(new GerritCallback<JsArray<EmailInfo>>() {
       @Override
       public void onSuccess(JsArray<EmailInfo> result) {
-        if (!isAttached()) {
-          return;
-        }
         for (EmailInfo i : Natives.asList(result)) {
           emailPick.addItem(i.email());
+          if (i.isPreferred()) {
+            currentEmail = i.email();
+          }
         }
         haveEmails = true;
         postLoad();
       }
-    });
+    }));
   }
 
   private void postLoad() {
     if (haveAccount && haveEmails) {
       updateEmailList();
       registerNewEmail.setEnabled(true);
+      save.setEnabled(false);
+      onEditEnabler.updateOriginalValue(nameTxt);
     }
     display();
   }
@@ -242,7 +252,7 @@
     currentEmail = account.email();
     nameTxt.setText(account.name());
     save.setEnabled(false);
-    new OnEditEnabler(save, nameTxt);
+    onEditEnabler.updateOriginalValue(nameTxt);
   }
 
   private void doRegisterNewEmail() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
index daf1181..7c707b2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
@@ -44,6 +44,7 @@
     p.theme(in.theme);
     p.hideEmptyPane(in.hideEmptyPane);
     p.retainHeader(in.retainHeader);
+    p.skipUnchanged(in.skipUnchanged);
     p.skipUncommented(in.skipUncommented);
     p.skipDeleted(in.skipDeleted);
     p.matchBrackets(in.matchBrackets);
@@ -64,6 +65,7 @@
     p.showTabs = showTabs();
     p.showWhitespaceErrors = showWhitespaceErrors();
     p.skipDeleted = skipDeleted();
+    p.skipUnchanged = skipUnchanged();
     p.skipUncommented = skipUncommented();
     p.syntaxHighlighting = syntaxHighlighting();
     p.hideTopMenu = hideTopMenu();
@@ -140,6 +142,7 @@
   public final native void renderEntireFile(boolean r) /*-{ this.render_entire_file = r }-*/;
   public final native void retainHeader(boolean r) /*-{ this.retain_header = r }-*/;
   public final native void hideEmptyPane(boolean s) /*-{ this.hide_empty_pane = s }-*/;
+  public final native void skipUnchanged(boolean s) /*-{ this.skip_unchanged = s }-*/;
   public final native void skipUncommented(boolean s) /*-{ this.skip_uncommented = s }-*/;
   public final native void skipDeleted(boolean s) /*-{ this.skip_deleted = s }-*/;
   public final native void matchBrackets(boolean m) /*-{ this.match_brackets = m }-*/;
@@ -157,16 +160,17 @@
   public final native boolean renderEntireFile() /*-{ return this.render_entire_file || false }-*/;
   public final native boolean hideEmptyPane() /*-{ return this.hide_empty_pane || false }-*/;
   public final native boolean retainHeader() /*-{ return this.retain_header || false }-*/;
+  public final native boolean skipUnchanged() /*-{ return this.skip_unchanged || false }-*/;
   public final native boolean skipUncommented() /*-{ return this.skip_uncommented || false }-*/;
   public final native boolean skipDeleted() /*-{ return this.skip_deleted || false }-*/;
   public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
   public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
 
-  private final native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
-  private final native void setIgnoreWhitespaceRaw(String i) /*-{ this.ignore_whitespace = i }-*/;
-  private final native String ignoreWhitespaceRaw() /*-{ return this.ignore_whitespace }-*/;
-  private final native String themeRaw() /*-{ return this.theme }-*/;
-  private final native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
+  private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
+  private native void setIgnoreWhitespaceRaw(String i) /*-{ this.ignore_whitespace = i }-*/;
+  private native String ignoreWhitespaceRaw() /*-{ return this.ignore_whitespace }-*/;
+  private native String themeRaw() /*-{ return this.theme }-*/;
+  private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
 
   protected DiffPreferences() {
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
index 39af4d4..ae89607 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
@@ -24,6 +24,7 @@
     EditPreferences p = createObject().cast();
     p.tabSize(in.tabSize);
     p.lineLength(in.lineLength);
+    p.indentUnit(in.indentUnit);
     p.cursorBlinkRate(in.cursorBlinkRate);
     p.hideTopMenu(in.hideTopMenu);
     p.showTabs(in.showTabs);
@@ -33,14 +34,16 @@
     p.matchBrackets(in.matchBrackets);
     p.lineWrapping(in.lineWrapping);
     p.autoCloseBrackets(in.autoCloseBrackets);
+    p.showBase(in.showBase);
     p.theme(in.theme);
     p.keyMapType(in.keyMapType);
     return p;
   }
 
-  public final void copyTo(EditPreferencesInfo p) {
+  public final EditPreferencesInfo copyTo(EditPreferencesInfo p) {
     p.tabSize = tabSize();
     p.lineLength = lineLength();
+    p.indentUnit = indentUnit();
     p.cursorBlinkRate = cursorBlinkRate();
     p.hideTopMenu = hideTopMenu();
     p.showTabs = showTabs();
@@ -50,22 +53,25 @@
     p.matchBrackets = matchBrackets();
     p.lineWrapping = lineWrapping();
     p.autoCloseBrackets = autoCloseBrackets();
+    p.showBase = showBase();
     p.theme = theme();
     p.keyMapType = keyMapType();
+    return p;
   }
 
   public final void theme(Theme i) {
     setThemeRaw(i != null ? i.toString() : Theme.DEFAULT.toString());
   }
-  private final native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
+  private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
 
   public final void keyMapType(KeyMapType i) {
     setkeyMapTypeRaw(i != null ? i.toString() : KeyMapType.DEFAULT.toString());
   }
-  private final native void setkeyMapTypeRaw(String i) /*-{ this.key_map_type = i }-*/;
+  private native void setkeyMapTypeRaw(String i) /*-{ this.key_map_type = i }-*/;
 
   public final native void tabSize(int t) /*-{ this.tab_size = t }-*/;
   public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
+  public final native void indentUnit(int c) /*-{ this.indent_unit = c }-*/;
   public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
   public final native void hideTopMenu(boolean s) /*-{ this.hide_top_menu = s }-*/;
   public final native void showTabs(boolean s) /*-{ this.show_tabs = s }-*/;
@@ -75,18 +81,19 @@
   public final native void matchBrackets(boolean m) /*-{ this.match_brackets = m }-*/;
   public final native void lineWrapping(boolean w) /*-{ this.line_wrapping = w }-*/;
   public final native void autoCloseBrackets(boolean c) /*-{ this.auto_close_brackets = c }-*/;
+  public final native void showBase(boolean s) /*-{ this.show_base = s }-*/;
 
   public final Theme theme() {
     String s = themeRaw();
     return s != null ? Theme.valueOf(s) : Theme.DEFAULT;
   }
-  private final native String themeRaw() /*-{ return this.theme }-*/;
+  private native String themeRaw() /*-{ return this.theme }-*/;
 
   public final KeyMapType keyMapType() {
     String s = keyMapTypeRaw();
     return s != null ? KeyMapType.valueOf(s) : KeyMapType.DEFAULT;
   }
-  private final native String keyMapTypeRaw() /*-{ return this.key_map_type }-*/;
+  private native String keyMapTypeRaw() /*-{ return this.key_map_type }-*/;
 
   public final int tabSize() {
     return get("tab_size", 8);
@@ -96,6 +103,10 @@
     return get("line_length", 100);
   }
 
+  public final int indentUnit() {
+    return get("indent_unit", 2);
+  }
+
   public final int cursorBlinkRate() {
     return get("cursor_blink_rate", 0);
   }
@@ -108,7 +119,8 @@
   public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
   public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
   public final native boolean autoCloseBrackets() /*-{ return this.auto_close_brackets || false }-*/;
-  private final native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
+  public final native boolean showBase() /*-{ return this.show_base || false }-*/;
+  private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
 
   protected EditPreferences() {
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
new file mode 100644
index 0000000..a4c92fe
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.account;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.GeneralPreferences;
+import com.google.gerrit.client.info.OAuthTokenInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gwt.i18n.client.DateTimeFormat;
+import com.google.gwt.i18n.client.LocaleInfo;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.Grid;
+import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.Widget;
+import com.google.gwtexpui.clippy.client.CopyableLabel;
+
+import java.util.Date;
+
+public class MyOAuthTokenScreen extends SettingsScreen {
+  private CopyableLabel tokenLabel;
+  private Label expiresLabel;
+  private Label expiredNote;
+  private CopyableLabel netrcValue;
+  private CopyableLabel cookieValue;
+  private FlowPanel flow;
+  private Grid grid;
+
+  @Override
+  protected void onInitUI() {
+    super.onInitUI();
+
+    tokenLabel = new CopyableLabel("");
+    tokenLabel.addStyleName(Gerrit.RESOURCES.css().oauthToken());
+
+    expiresLabel = new Label("");
+    expiresLabel.addStyleName(Gerrit.RESOURCES.css().oauthExpires());
+
+    grid = new Grid(2, 2);
+    grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
+    grid.addStyleName(Gerrit.RESOURCES.css().oauthInfoBlock());
+    add(grid);
+
+    expiredNote = new Label(Util.C.labelOAuthExpired());
+    expiredNote.setVisible(false);
+    add(expiredNote);
+
+    row(grid, 0, Util.C.labelOAuthToken(), tokenLabel);
+    row(grid, 1, Util.C.labelOAuthExpires(), expiresLabel);
+
+    CellFormatter fmt = grid.getCellFormatter();
+    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
+    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
+    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().bottomheader());
+
+    flow = new FlowPanel();
+    flow.setStyleName(Gerrit.RESOURCES.css().oauthPanel());
+    add(flow);
+
+    Label netrcLabel = new Label(Util.C.labelOAuthNetRCEntry());
+    netrcLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCHeading());
+    flow.add(netrcLabel);
+    netrcValue= new CopyableLabel("");
+    netrcValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCEntry());
+    flow.add(netrcValue);
+
+    Label cookieLabel = new Label(Util.C.labelOAuthGitCookie());
+    cookieLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieHeading());
+    flow.add(cookieLabel);
+    cookieValue = new CopyableLabel("");
+    cookieValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieEntry());
+    flow.add(cookieValue);
+  }
+
+  private void row(Grid grid, int row, String name, Widget field) {
+    final CellFormatter fmt = grid.getCellFormatter();
+    if (LocaleInfo.getCurrentLocale().isRTL()) {
+      grid.setText(row, 1, name);
+      grid.setWidget(row, 0, field);
+      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().header());
+    } else {
+      grid.setText(row, 0, name);
+      grid.setWidget(row, 1, field);
+      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().header());
+    }
+  }
+
+  @Override
+  protected void onLoad() {
+    super.onLoad();
+    AccountApi.self().view("preferences")
+    .get(new ScreenLoadCallback<GeneralPreferences>(this) {
+      @Override
+      protected void preDisplay(GeneralPreferences prefs) {
+        display(prefs);
+      }
+    });
+  }
+
+  private void display(final GeneralPreferences prefs) {
+    AccountApi.self().view("oauthtoken")
+    .get(new GerritCallback<OAuthTokenInfo>() {
+      @Override
+      public void onSuccess(OAuthTokenInfo tokenInfo) {
+        tokenLabel.setText(tokenInfo.accessToken());
+        expiresLabel.setText(getExpiresAt(tokenInfo, prefs));
+        netrcValue.setText(getNetRC(tokenInfo));
+        cookieValue.setText(getCookie(tokenInfo));
+        flow.setVisible(true);
+        expiredNote.setVisible(false);
+      }
+      @Override
+      public void onFailure(Throwable caught) {
+        if (isNoSuchEntity(caught) || isSigninFailure(caught)) {
+          tokenLabel.setText("");
+          expiresLabel.setText("");
+          netrcValue.setText("");
+          cookieValue.setText("");
+          flow.setVisible(false);
+          expiredNote.setVisible(true);
+        } else {
+          showFailure(caught);
+        }
+      }
+    });
+  }
+
+  private static long getExpiresAt(OAuthTokenInfo tokenInfo) {
+    if (tokenInfo.expiresAt() == null) {
+      return Long.MAX_VALUE;
+    }
+    long expiresAt;
+    try {
+      expiresAt = Long.parseLong(tokenInfo.expiresAt());
+    } catch (NumberFormatException e) {
+      return Long.MAX_VALUE;
+    }
+    return expiresAt;
+  }
+
+  private static long getExpiresAtSeconds(OAuthTokenInfo tokenInfo) {
+    return getExpiresAt(tokenInfo) / 1000L;
+  }
+
+  private static String getExpiresAt(OAuthTokenInfo tokenInfo,
+      GeneralPreferences prefs) {
+    long expiresAt = getExpiresAt(tokenInfo);
+    if (expiresAt == Long.MAX_VALUE) {
+      return "";
+    }
+    String dateFormat = prefs.dateFormat().getLongFormat();
+    String timeFormat = prefs.timeFormat().getFormat();
+    DateTimeFormat formatter = DateTimeFormat.getFormat(
+        dateFormat + " " + timeFormat);
+    return formatter.format(new Date(expiresAt));
+  }
+
+  private static String getNetRC(OAuthTokenInfo accessTokenInfo) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("machine ");
+    sb.append(accessTokenInfo.resourceHost());
+    sb.append(" login ");
+    sb.append(accessTokenInfo.username());
+    sb.append(" password ");
+    sb.append(accessTokenInfo.accessToken());
+    return sb.toString();
+  }
+
+  private static String getCookie(OAuthTokenInfo accessTokenInfo) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(accessTokenInfo.resourceHost());
+    sb.append("\tFALSE\t/\tTRUE\t");
+    sb.append(getExpiresAtSeconds(accessTokenInfo));
+    sb.append("\tgit-");
+    sb.append(accessTokenInfo.username());
+    sb.append('\t');
+    sb.append(accessTokenInfo.accessToken());
+    if (accessTokenInfo.providerId() != null) {
+      sb.append('@').append(accessTokenInfo.providerId());
+    }
+    return sb.toString();
+  }
+
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index 2b20ad6..2b01b59 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -14,22 +14,22 @@
 
 package com.google.gerrit.client.account;
 
-import static com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DEFAULT_PAGESIZE;
-import static com.google.gerrit.reviewdb.client.AccountGeneralPreferences.PAGESIZE_CHOICES;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.DEFAULT_PAGESIZE;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.PAGESIZE_CHOICES;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.GerritUiExtensionPoint;
 import com.google.gerrit.client.StringListPanel;
 import com.google.gerrit.client.api.ExtensionPanel;
 import com.google.gerrit.client.config.ConfigServerApi;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.info.TopMenuItem;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -50,16 +50,17 @@
 public class MyPreferencesScreen extends SettingsScreen {
   private CheckBox showSiteHeader;
   private CheckBox useFlashClipboard;
-  private CheckBox copySelfOnEmails;
   private CheckBox relativeDateInChangeTable;
   private CheckBox sizeBarInChangeTable;
   private CheckBox legacycidInChangeTable;
   private CheckBox muteCommonPathPrefixes;
+  private CheckBox signedOffBy;
   private ListBox maximumPageSize;
   private ListBox dateFormat;
   private ListBox timeFormat;
   private ListBox reviewCategoryStrategy;
   private ListBox diffView;
+  private ListBox emailStrategy;
   private StringListPanel myMenus;
   private Button save;
 
@@ -69,41 +70,54 @@
 
     showSiteHeader = new CheckBox(Util.C.showSiteHeader());
     useFlashClipboard = new CheckBox(Util.C.useFlashClipboard());
-    copySelfOnEmails = new CheckBox(Util.C.copySelfOnEmails());
     maximumPageSize = new ListBox();
-    for (final short v : PAGESIZE_CHOICES) {
+    for (final int v : PAGESIZE_CHOICES) {
       maximumPageSize.addItem(Util.M.rowsPerPage(v), String.valueOf(v));
     }
 
     reviewCategoryStrategy = new ListBox();
     reviewCategoryStrategy.addItem(
         Util.C.messageShowInReviewCategoryNone(),
-        AccountGeneralPreferences.ReviewCategoryStrategy.NONE.name());
+        GeneralPreferencesInfo.ReviewCategoryStrategy.NONE.name());
     reviewCategoryStrategy.addItem(
         Util.C.messageShowInReviewCategoryName(),
-        AccountGeneralPreferences.ReviewCategoryStrategy.NAME.name());
+        GeneralPreferencesInfo.ReviewCategoryStrategy.NAME.name());
     reviewCategoryStrategy.addItem(
         Util.C.messageShowInReviewCategoryEmail(),
-        AccountGeneralPreferences.ReviewCategoryStrategy.EMAIL.name());
+        GeneralPreferencesInfo.ReviewCategoryStrategy.EMAIL.name());
     reviewCategoryStrategy.addItem(
         Util.C.messageShowInReviewCategoryUsername(),
-        AccountGeneralPreferences.ReviewCategoryStrategy.USERNAME.name());
+        GeneralPreferencesInfo.ReviewCategoryStrategy.USERNAME.name());
     reviewCategoryStrategy.addItem(
         Util.C.messageShowInReviewCategoryAbbrev(),
-        AccountGeneralPreferences.ReviewCategoryStrategy.ABBREV.name());
+        GeneralPreferencesInfo.ReviewCategoryStrategy.ABBREV.name());
+
+    emailStrategy = new ListBox();
+    emailStrategy.addItem(Util.C.messageEnabled(),
+        GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
+    emailStrategy
+        .addItem(
+            Util.C.messageCCMeOnMyComments(),
+            GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS
+                .name());
+    emailStrategy
+        .addItem(
+            Util.C.messageDisabled(),
+            GeneralPreferencesInfo.EmailStrategy.DISABLED
+                .name());
 
     diffView = new ListBox();
     diffView.addItem(
         com.google.gerrit.client.changes.Util.C.sideBySide(),
-        AccountGeneralPreferences.DiffView.SIDE_BY_SIDE.name());
+        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE.name());
     diffView.addItem(
         com.google.gerrit.client.changes.Util.C.unifiedDiff(),
-        AccountGeneralPreferences.DiffView.UNIFIED_DIFF.name());
+        GeneralPreferencesInfo.DiffView.UNIFIED_DIFF.name());
 
     Date now = new Date();
     dateFormat = new ListBox();
-    for (AccountGeneralPreferences.DateFormat fmt : AccountGeneralPreferences.DateFormat
-        .values()) {
+    for (GeneralPreferencesInfo.DateFormat fmt
+        : GeneralPreferencesInfo.DateFormat.values()) {
       StringBuilder r = new StringBuilder();
       r.append(DateTimeFormat.getFormat(fmt.getShortFormat()).format(now));
       r.append(" ; ");
@@ -112,8 +126,8 @@
     }
 
     timeFormat = new ListBox();
-    for (AccountGeneralPreferences.TimeFormat fmt : AccountGeneralPreferences.TimeFormat
-        .values()) {
+    for (GeneralPreferencesInfo.TimeFormat fmt
+        : GeneralPreferencesInfo.TimeFormat.values()) {
       StringBuilder r = new StringBuilder();
       r.append(DateTimeFormat.getFormat(fmt.getFormat()).format(now));
       timeFormat.addItem(r.toString(), fmt.name());
@@ -139,24 +153,12 @@
     sizeBarInChangeTable = new CheckBox(Util.C.showSizeBarInChangeTable());
     legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
     muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
+    signedOffBy = new CheckBox(Util.C.signedOffBy());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(10 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(12 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, showSiteHeader);
-    row++;
-
-    if (flashClippy) {
-      formGrid.setText(row, labelIdx, "");
-      formGrid.setWidget(row, fieldIdx, useFlashClipboard);
-      row++;
-    }
-
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, copySelfOnEmails);
-    row++;
 
     formGrid.setText(row, labelIdx, Util.C.reviewCategoryLabel());
     formGrid.setWidget(row, fieldIdx, reviewCategoryStrategy);
@@ -170,6 +172,18 @@
     formGrid.setWidget(row, fieldIdx, dateTimePanel);
     row++;
 
+    formGrid.setText(row, labelIdx, Util.C.emailFieldLabel());
+    formGrid.setWidget(row, fieldIdx, emailStrategy);
+    row++;
+
+    formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
+    formGrid.setWidget(row, fieldIdx, diffView);
+    row++;
+
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, showSiteHeader);
+    row++;
+
     formGrid.setText(row, labelIdx, "");
     formGrid.setWidget(row, fieldIdx, relativeDateInChangeTable);
     row++;
@@ -186,8 +200,14 @@
     formGrid.setWidget(row, fieldIdx, muteCommonPathPrefixes);
     row++;
 
-    formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
-    formGrid.setWidget(row, fieldIdx, diffView);
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, signedOffBy);
+    row++;
+
+    if (flashClippy) {
+      formGrid.setText(row, labelIdx, "");
+      formGrid.setWidget(row, fieldIdx, useFlashClipboard);
+    }
 
     add(formGrid);
 
@@ -208,7 +228,6 @@
     final OnEditEnabler e = new OnEditEnabler(save);
     e.listenTo(showSiteHeader);
     e.listenTo(useFlashClipboard);
-    e.listenTo(copySelfOnEmails);
     e.listenTo(maximumPageSize);
     e.listenTo(dateFormat);
     e.listenTo(timeFormat);
@@ -216,8 +235,10 @@
     e.listenTo(sizeBarInChangeTable);
     e.listenTo(legacycidInChangeTable);
     e.listenTo(muteCommonPathPrefixes);
+    e.listenTo(signedOffBy);
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
+    e.listenTo(emailStrategy);
   }
 
   @Override
@@ -229,9 +250,9 @@
     add(extensionPanel);
 
     AccountApi.self().view("preferences")
-        .get(new ScreenLoadCallback<AccountPreferencesInfo>(this) {
+        .get(new ScreenLoadCallback<GeneralPreferences>(this) {
       @Override
-      public void preDisplay(AccountPreferencesInfo prefs) {
+      public void preDisplay(GeneralPreferences prefs) {
         display(prefs);
       }
     });
@@ -240,7 +261,6 @@
   private void enable(final boolean on) {
     showSiteHeader.setEnabled(on);
     useFlashClipboard.setEnabled(on);
-    copySelfOnEmails.setEnabled(on);
     maximumPageSize.setEnabled(on);
     dateFormat.setEnabled(on);
     timeFormat.setEnabled(on);
@@ -248,29 +268,34 @@
     sizeBarInChangeTable.setEnabled(on);
     legacycidInChangeTable.setEnabled(on);
     muteCommonPathPrefixes.setEnabled(on);
+    signedOffBy.setEnabled(on);
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
+    emailStrategy.setEnabled(on);
   }
 
-  private void display(AccountPreferencesInfo p) {
+  private void display(GeneralPreferences p) {
     showSiteHeader.setValue(p.showSiteHeader());
     useFlashClipboard.setValue(p.useFlashClipboard());
-    copySelfOnEmails.setValue(p.copySelfOnEmail());
     setListBox(maximumPageSize, DEFAULT_PAGESIZE, p.changesPerPage());
-    setListBox(dateFormat, AccountGeneralPreferences.DateFormat.STD, //
+    setListBox(dateFormat, GeneralPreferencesInfo.DateFormat.STD, //
         p.dateFormat());
-    setListBox(timeFormat, AccountGeneralPreferences.TimeFormat.HHMM_12, //
+    setListBox(timeFormat, GeneralPreferencesInfo.TimeFormat.HHMM_12, //
         p.timeFormat());
     relativeDateInChangeTable.setValue(p.relativeDateInChangeTable());
     sizeBarInChangeTable.setValue(p.sizeBarInChangeTable());
     legacycidInChangeTable.setValue(p.legacycidInChangeTable());
     muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
+    signedOffBy.setValue(p.signedOffBy());
     setListBox(reviewCategoryStrategy,
-        AccountGeneralPreferences.ReviewCategoryStrategy.NONE,
+        GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
         p.reviewCategoryStrategy());
     setListBox(diffView,
-        AccountGeneralPreferences.DiffView.SIDE_BY_SIDE,
+        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
         p.diffView());
+    setListBox(emailStrategy,
+        GeneralPreferencesInfo.EmailStrategy.ENABLED,
+        p.emailStrategy());
     display(p.my());
   }
 
@@ -282,8 +307,8 @@
     myMenus.display(values);
   }
 
-  private void setListBox(final ListBox f, final short defaultValue,
-      final short currentValue) {
+  private void setListBox(final ListBox f, final int defaultValue,
+      final int currentValue) {
     setListBox(f, String.valueOf(defaultValue), String.valueOf(currentValue));
   }
 
@@ -308,7 +333,7 @@
     }
   }
 
-  private short getListBox(final ListBox f, final short defaultValue) {
+  private int getListBox(final ListBox f, final int defaultValue) {
     final int idx = f.getSelectedIndex();
     if (0 <= idx) {
       return Short.parseShort(f.getValue(idx));
@@ -334,27 +359,31 @@
   }
 
   private void doSave() {
-    AccountPreferencesInfo p = AccountPreferencesInfo.create();
+    GeneralPreferences p = GeneralPreferences.create();
     p.showSiteHeader(showSiteHeader.getValue());
     p.useFlashClipboard(useFlashClipboard.getValue());
-    p.copySelfOnEmail(copySelfOnEmails.getValue());
     p.changesPerPage(getListBox(maximumPageSize, DEFAULT_PAGESIZE));
     p.dateFormat(getListBox(dateFormat,
-        AccountGeneralPreferences.DateFormat.STD,
-        AccountGeneralPreferences.DateFormat.values()));
+        GeneralPreferencesInfo.DateFormat.STD,
+        GeneralPreferencesInfo.DateFormat.values()));
     p.timeFormat(getListBox(timeFormat,
-        AccountGeneralPreferences.TimeFormat.HHMM_12,
-        AccountGeneralPreferences.TimeFormat.values()));
+        GeneralPreferencesInfo.TimeFormat.HHMM_12,
+        GeneralPreferencesInfo.TimeFormat.values()));
     p.relativeDateInChangeTable(relativeDateInChangeTable.getValue());
     p.sizeBarInChangeTable(sizeBarInChangeTable.getValue());
     p.legacycidInChangeTable(legacycidInChangeTable.getValue());
     p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
+    p.signedOffBy(signedOffBy.getValue());
     p.reviewCategoryStrategy(getListBox(reviewCategoryStrategy,
         ReviewCategoryStrategy.NONE,
         ReviewCategoryStrategy.values()));
     p.diffView(getListBox(diffView,
-        AccountGeneralPreferences.DiffView.SIDE_BY_SIDE,
-        AccountGeneralPreferences.DiffView.values()));
+        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
+        GeneralPreferencesInfo.DiffView.values()));
+
+    p.emailStrategy(getListBox(emailStrategy,
+        GeneralPreferencesInfo.EmailStrategy.ENABLED,
+        GeneralPreferencesInfo.EmailStrategy.values()));
 
     List<TopMenuItem> items = new ArrayList<>();
     for (List<String> v : myMenus.getValues()) {
@@ -366,9 +395,9 @@
     save.setEnabled(false);
 
     AccountApi.self().view("preferences")
-        .put(p, new GerritCallback<AccountPreferencesInfo>() {
+        .put(p, new GerritCallback<GeneralPreferences>() {
           @Override
-          public void onSuccess(AccountPreferencesInfo prefs) {
+          public void onSuccess(GeneralPreferences prefs) {
             Gerrit.setUserPreferences(prefs);
             enable(true);
             display(prefs);
@@ -395,9 +424,9 @@
         @Override
         public void onClick(ClickEvent event) {
           ConfigServerApi.defaultPreferences(
-              new GerritCallback<AccountPreferencesInfo>() {
+              new GerritCallback<GeneralPreferences>() {
                 @Override
-                public void onSuccess(AccountPreferencesInfo p) {
+                public void onSuccess(GeneralPreferences p) {
                   MyPreferencesScreen.this.display(p.my());
                   widget.setEnabled(true);
                 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
index a8bf3e5..ce5cfd3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
@@ -16,13 +16,13 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.HintTextBox;
 import com.google.gerrit.client.ui.ProjectListPopup;
 import com.google.gerrit.client.ui.ProjectNameSuggestOracle;
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AccountProjectWatchInfo;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
@@ -36,8 +36,6 @@
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
 import com.google.gwt.user.client.ui.HorizontalPanel;
 
-import java.util.List;
-
 public class MyWatchedProjectsScreen extends SettingsScreen {
   private Button addNew;
   private RemoteSuggestBox nameBox;
@@ -188,35 +186,40 @@
     nameBox.setEnabled(false);
     filterTxt.setEnabled(false);
 
-    Util.ACCOUNT_SVC.addProjectWatch(projectName, filter,
-        new GerritCallback<AccountProjectWatchInfo>() {
+    final ProjectWatchInfo projectWatchInfo = JavaScriptObject
+        .createObject().cast();
+    projectWatchInfo.project(projectName);
+    projectWatchInfo.filter(filterTxt.getText());
+
+    AccountApi.updateWatchedProject("self", projectWatchInfo,
+        new GerritCallback<JsArray<ProjectWatchInfo>>() {
           @Override
-          public void onSuccess(final AccountProjectWatchInfo result) {
+          public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
             addNew.setEnabled(true);
             nameBox.setEnabled(true);
             filterTxt.setEnabled(true);
 
             nameBox.setText("");
-            watchesTab.insertWatch(result);
+            watchesTab.insertWatch(projectWatchInfo);
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             addNew.setEnabled(true);
             nameBox.setEnabled(true);
             filterTxt.setEnabled(true);
-
             super.onFailure(caught);
           }
         });
   }
 
   protected void populateWatches() {
-    Util.ACCOUNT_SVC.myProjectWatch(
-        new ScreenLoadCallback<List<AccountProjectWatchInfo>>(this) {
+    AccountApi.getWatchedProjects("self",
+        new GerritCallback<JsArray<ProjectWatchInfo>>() {
       @Override
-      public void preDisplay(final List<AccountProjectWatchInfo> result) {
-        watchesTab.display(result);
+      public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
+        display();
+        watchesTab.display(watchedProjects);
       }
     });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
index 08effdc..3647baf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
@@ -16,23 +16,22 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.ProjectLink;
-import com.google.gerrit.common.data.AccountProjectWatchInfo;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Label;
-import com.google.gwtjsonrpc.common.VoidResult;
 
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 
-public class MyWatchesTable extends FancyFlexTable<AccountProjectWatchInfo> {
+public class MyWatchesTable extends FancyFlexTable<ProjectWatchInfo> {
 
   public MyWatchesTable() {
     table.setWidth("");
@@ -63,22 +62,22 @@
   }
 
   public void deleteChecked() {
-    final Set<AccountProjectWatch.Key> ids = getCheckedIds();
-    if (!ids.isEmpty()) {
-      Util.ACCOUNT_SVC.deleteProjectWatches(ids,
-          new GerritCallback<VoidResult>() {
+    final Set<ProjectWatchInfo> infos = getCheckedProjectWatchInfos();
+    if (!infos.isEmpty()) {
+      AccountApi.deleteWatchedProjects("self", infos,
+          new GerritCallback<JsArray<ProjectWatchInfo>>() {
             @Override
-            public void onSuccess(final VoidResult result) {
-              remove(ids);
+            public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
+              remove(infos);
             }
           });
     }
   }
 
-  protected void remove(Set<AccountProjectWatch.Key> ids) {
+  protected void remove(Set<ProjectWatchInfo> infos) {
     for (int row = 1; row < table.getRowCount();) {
-      final AccountProjectWatchInfo k = getRowItem(row);
-      if (k != null && ids.contains(k.getWatch().getKey())) {
+      final ProjectWatchInfo k = getRowItem(row);
+      if (k != null && infos.contains(k)) {
         table.removeRow(row);
       } else {
         row++;
@@ -86,23 +85,23 @@
     }
   }
 
-  protected Set<AccountProjectWatch.Key> getCheckedIds() {
-    final Set<AccountProjectWatch.Key> ids = new HashSet<>();
+  protected Set<ProjectWatchInfo> getCheckedProjectWatchInfos() {
+    final Set<ProjectWatchInfo> infos = new HashSet<>();
     for (int row = 1; row < table.getRowCount(); row++) {
-      final AccountProjectWatchInfo k = getRowItem(row);
+      final ProjectWatchInfo k = getRowItem(row);
       if (k != null && ((CheckBox) table.getWidget(row, 1)).getValue()) {
-        ids.add(k.getWatch().getKey());
+        infos.add(k);
       }
     }
-    return ids;
+    return infos;
   }
 
-  public void insertWatch(final AccountProjectWatchInfo k) {
-    final String newName = k.getProject().getName();
+  public void insertWatch(final ProjectWatchInfo k) {
+    final String newName = k.project();
     int row = 1;
     for (; row < table.getRowCount(); row++) {
-      final AccountProjectWatchInfo i = getRowItem(row);
-      if (i != null && i.getProject().getName().compareTo(newName) >= 0) {
+      final ProjectWatchInfo i = getRowItem(row);
+      if (i != null && i.project().compareTo(newName) >= 0) {
         break;
       }
     }
@@ -112,24 +111,25 @@
     populate(row, k);
   }
 
-  public void display(final List<AccountProjectWatchInfo> result) {
+  public void display(final JsArray<ProjectWatchInfo> result) {
     while (2 < table.getRowCount()) {
       table.removeRow(table.getRowCount() - 1);
     }
 
-    for (final AccountProjectWatchInfo k : result) {
+    for (ProjectWatchInfo info : Natives.asList(result)) {
       final int row = table.getRowCount();
       table.insertRow(row);
       applyDataRowStyle(row);
-      populate(row, k);
+      populate(row, info);
     }
   }
 
-  protected void populate(final int row, final AccountProjectWatchInfo info) {
+  protected void populate(final int row, final ProjectWatchInfo info) {
     final FlowPanel fp = new FlowPanel();
-    fp.add(new ProjectLink(info.getProject().getNameKey()));
-    if (info.getWatch().getFilter() != null) {
-      Label filter = new Label(info.getWatch().getFilter());
+    fp.add(new ProjectLink(info.project(),
+        new Project.NameKey(info.project())));
+    if (info.filter() != null) {
+      Label filter = new Label(info.filter());
       filter.setStyleName(Gerrit.RESOURCES.css().watchedProjectFilter());
       fp.add(filter);
     }
@@ -137,11 +137,11 @@
     table.setWidget(row, 1, new CheckBox());
     table.setWidget(row, 2, fp);
 
-    addNotifyButton(AccountProjectWatch.NotifyType.NEW_CHANGES, info, row, 3);
-    addNotifyButton(AccountProjectWatch.NotifyType.NEW_PATCHSETS, info, row, 4);
-    addNotifyButton(AccountProjectWatch.NotifyType.ALL_COMMENTS, info, row, 5);
-    addNotifyButton(AccountProjectWatch.NotifyType.SUBMITTED_CHANGES, info, row, 6);
-    addNotifyButton(AccountProjectWatch.NotifyType.ABANDONED_CHANGES, info, row, 7);
+    addNotifyButton(ProjectWatchInfo.Type.NEW_CHANGES, info, row, 3);
+    addNotifyButton(ProjectWatchInfo.Type.NEW_PATCHSETS, info, row, 4);
+    addNotifyButton(ProjectWatchInfo.Type.ALL_COMMENTS, info, row, 5);
+    addNotifyButton(ProjectWatchInfo.Type.SUBMITTED_CHANGES, info, row, 6);
+    addNotifyButton(ProjectWatchInfo.Type.ABANDONED_CHANGES, info, row, 7);
 
     final FlexCellFormatter fmt = table.getFlexCellFormatter();
     fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
@@ -155,27 +155,28 @@
     setRowItem(row, info);
   }
 
-  protected void addNotifyButton(final AccountProjectWatch.NotifyType type,
-      final AccountProjectWatchInfo info, final int row, final int col) {
+  protected void addNotifyButton(final ProjectWatchInfo.Type type,
+      final ProjectWatchInfo info, final int row, final int col) {
     final CheckBox cbox = new CheckBox();
 
     cbox.addClickHandler(new ClickHandler() {
       @Override
       public void onClick(final ClickEvent event) {
-        final boolean oldVal = info.getWatch().isNotify(type);
-        info.getWatch().setNotify(type, cbox.getValue());
+        final Boolean oldVal = info.notify(type);
+        info.notify(type, cbox.getValue());
         cbox.setEnabled(false);
-        Util.ACCOUNT_SVC.updateProjectWatch(info.getWatch(),
-            new GerritCallback<VoidResult>() {
+
+        AccountApi.updateWatchedProject("self", info,
+            new GerritCallback<JsArray<ProjectWatchInfo>>() {
               @Override
-              public void onSuccess(final VoidResult result) {
+              public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
                 cbox.setEnabled(true);
               }
 
               @Override
-              public void onFailure(final Throwable caught) {
+              public void onFailure(Throwable caught) {
                 cbox.setEnabled(true);
-                info.getWatch().setNotify(type, oldVal);
+                info.notify(type, oldVal);
                 cbox.setValue(oldVal);
                 super.onFailure(caught);
               }
@@ -183,7 +184,7 @@
       }
     });
 
-    cbox.setValue(info.getWatch().isNotify(type));
+    cbox.setValue(info.notify(type));
     table.setWidget(row, col, cbox);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
new file mode 100644
index 0000000..e43ec0c
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.client.account;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class ProjectWatchInfo extends JavaScriptObject {
+
+  public enum Type {
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    ALL_COMMENTS,
+    SUBMITTED_CHANGES,
+    ABANDONED_CHANGES
+  }
+
+  public final native String project() /*-{ return this.project; }-*/;
+  public final native String filter() /*-{ return this.filter; }-*/;
+
+  public final native void project(String s) /*-{ this.project = s; }-*/;
+  public final native void filter(String s) /*-{ this.filter = s; }-*/;
+
+  public final void notify(ProjectWatchInfo.Type t, Boolean b) {
+    if (t == ProjectWatchInfo.Type.NEW_CHANGES) {
+      notifyNewChanges(b.booleanValue());
+    } else if (t == Type.NEW_PATCHSETS) {
+      notifyNewPatchSets(b.booleanValue());
+    } else if (t == Type.ALL_COMMENTS) {
+      notifyAllComments(b.booleanValue());
+    } else if (t == Type.SUBMITTED_CHANGES) {
+      notifySubmittedChanges(b.booleanValue());
+    } else if (t == Type.ABANDONED_CHANGES) {
+      notifyAbandonedChanges(b.booleanValue());
+    }
+  }
+
+  public final Boolean notify(ProjectWatchInfo.Type t) {
+    boolean b = false;
+    if (t == ProjectWatchInfo.Type.NEW_CHANGES) {
+      b = notifyNewChanges();
+    } else if (t == Type.NEW_PATCHSETS) {
+      b = notifyNewPatchSets();
+    } else if (t == Type.ALL_COMMENTS) {
+      b = notifyAllComments();
+    } else if (t == Type.SUBMITTED_CHANGES) {
+      b = notifySubmittedChanges();
+    } else if (t == Type.ABANDONED_CHANGES) {
+      b = notifyAbandonedChanges();
+    }
+    return Boolean.valueOf(b);
+  }
+
+  private native boolean notifyNewChanges() /*-{ return this['notify_new_changes'] ? true : false; }-*/;
+  private native boolean notifyNewPatchSets() /*-{ return this['notify_new_patch_sets'] ? true : false; }-*/;
+  private native boolean notifyAllComments() /*-{ return this['notify_all_comments'] ? true : false; }-*/;
+  private native boolean notifySubmittedChanges() /*-{ return this['notify_submitted_changes'] ? true : false; }-*/;
+  private native boolean notifyAbandonedChanges() /*-{ return this['notify_abandoned_changes'] ? true : false; }-*/;
+
+  private native void notifyNewChanges(boolean b) /*-{ this['notify_new_changes'] = b ? true : null; }-*/;
+  private native void notifyNewPatchSets(boolean b) /*-{ this['notify_new_patch_sets'] = b ? true : null; }-*/;
+  private native void notifyAllComments(boolean b) /*-{ this['notify_all_comments'] = b ? true : null; }-*/;
+  private native void notifySubmittedChanges(boolean b) /*-{ this['notify_submitted_changes'] = b ? true : null; }-*/;
+  private native void notifyAbandonedChanges(boolean b) /*-{ this['notify_abandoned_changes'] = b ? true : null; }-*/;
+
+  protected ProjectWatchInfo() {
+
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
index e8c58ef..ee7407e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
@@ -47,6 +47,10 @@
     if (Gerrit.info().auth().isHttpPasswordSettingsEnabled()) {
       linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
     }
+    if (Gerrit.info().auth().isOAuth()
+        && Gerrit.info().auth().isGitBasicAuth()) {
+      linkByGerrit(Util.C.tabOAuthToken(), PageLinks.SETTINGS_OAUTH_TOKEN);
+    }
     if (Gerrit.info().gerrit().editGpgKeys()) {
       linkByGerrit(Util.C.tabGpgKeys(), PageLinks.SETTINGS_GPGKEYS);
     }
@@ -81,7 +85,7 @@
 
   private void linkByPlugin(String pluginName, String text, String target) {
     if (ambiguousMenuNames.contains(text)) {
-      text += " ("+ pluginName + ")";
+      text += " (" + pluginName + ")";
     }
     link(text, target);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
index 8f5d7a8..39849c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
@@ -57,11 +57,6 @@
     this(null, null, change, null, revision, action);
   }
 
-  public ActionButton(ChangeInfo change, EditInfo edit,
-      ActionInfo action) {
-    this(null, null, change, edit, null, action);
-  }
-
   private ActionButton(Project.NameKey project, BranchInfo branch,
       ChangeInfo change, EditInfo edit, RevisionInfo revision,
       ActionInfo action) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml
index 52f3588..3710265 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.ui.xml
@@ -88,9 +88,6 @@
   .addContainer:hover {
     background-color: selectionColor;
   }
-  .addSelector {
-    font-size: 80%;
-  }
 
   .deleteIcon {
     position: absolute;
@@ -139,9 +136,7 @@
       ui:field='permissionContainer'
       styleName='{style.permissionList}'/>
   <div ui:field='addContainer' class='{style.addContainer}'>
-    <g:ValueListBox
-        ui:field='permissionSelector'
-        styleName='{style.addSelector}' />
+    <g:ValueListBox ui:field='permissionSelector'/>
   </div>
 </div>
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
index cd72686..a71dffe 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
@@ -211,7 +211,9 @@
   protected void display(final GroupInfo group, final boolean canModify) {
     groupUUIDLabel.setText(group.getGroupUUID().get());
     groupNameTxt.setText(group.name());
-    ownerTxt.setText(group.owner() != null?group.owner():Util.M.deletedReference(group.getOwnerUUID().get()));
+    ownerTxt.setText(group.owner() != null
+        ? group.owner()
+        : Util.M.deletedReference(group.getOwnerUUID().get()));
     descTxt.setText(group.description());
     visibleToAllCheckBox.setValue(group.options().isVisibleToAll());
     setMembersTabVisible(AccountGroup.isInternalGroup(group.getGroupUUID()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
index ad3b3f8..8c00ba7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
@@ -48,9 +48,8 @@
   private String getTabToken(final String token, final String tab) {
     if (token.startsWith("/admin/groups/uuid-")) {
       return toGroup(group.getGroupUUID(), tab);
-    } else {
-      return toGroup(group.getGroupId(), tab);
     }
+    return toGroup(group.getGroupId(), tab);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index 511be5f..984c5a3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -45,6 +45,7 @@
   String enableSignedPush();
   String requireSignedPush();
   String requireChangeID();
+  String rejectImplicitMerges();
   String headingMaxObjectSizeLimit();
   String headingGroupOptions();
   String isVisibleToAll();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 7a8888c..2fe5978 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -27,6 +27,7 @@
 enableSignedPush = Enable signed push
 requireSignedPush = Require signed push
 requireChangeID = Require <code>Change-Id</code> in commit message
+rejectImplicitMerges = Reject implicit merges when changes are pushed for review
 headingMaxObjectSizeLimit = Maximum Git object size limit
 headingGroupOptions = Group Options
 isVisibleToAll = Make group visible to all registered users.
@@ -120,6 +121,7 @@
 # Permission Names
 permissionNames = \
 	abandon, \
+	addPatchSet, \
 	create, \
 	deleteDrafts, \
 	editHashtags, \
@@ -141,6 +143,7 @@
 	viewDrafts
 
 abandon = Abandon
+addPatchSet = Add Patch Set
 create = Create Reference
 deleteDrafts = Delete Drafts
 editHashtags = Edit Hashtags
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
index f1ac27f..688a59f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
@@ -19,17 +19,24 @@
 import com.google.gwt.resources.client.ImageResource;
 
 public interface AdminResources extends ClientBundle {
-  public static final AdminResources I = GWT.create(AdminResources.class);
+  AdminResources I = GWT.create(AdminResources.class);
 
   @Source("admin.css")
   AdminCss css();
 
+  /**
+   * unknown origin
+   * TODO replace icons
+   */
   @Source("deleteNormal.png")
-  public ImageResource deleteNormal();
+  ImageResource deleteNormal();
 
   @Source("deleteHover.png")
-  public ImageResource deleteHover();
+  ImageResource deleteHover();
 
-  @Source("undoNormal.png")
-  public ImageResource undoNormal();
+  /**
+   * silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/
+   */
+  @Source("arrow_undo.png")
+  ImageResource undoNormal();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
index bfe7787..86222ce 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
@@ -126,7 +126,7 @@
       @Override
       public void onKeyUp(KeyUpEvent event) {
         Query q = new Query(filterTxt.getValue())
-          .open(event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER);
+          .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
         if (match.equals(q.qMatch)) {
           q.start(start);
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
index c1082bc..d6d0fe3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
@@ -47,7 +47,7 @@
             toValue(event.getSelectedItem()));
       }
     });
-    suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>(){
+    suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
       @Override
       public void onClose(CloseEvent<RemoteSuggestBox> event) {
         suggestBox.setText("");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index aed1dc2..64fc0e5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -113,7 +113,7 @@
         return a.name().compareTo(b.name());
       }
     });
-    for(GroupInfo group : list.subList(fromIndex, toIndex)) {
+    for (GroupInfo group : list.subList(fromIndex, toIndex)) {
       final int row = table.getRowCount();
       table.insertRow(row);
       applyDataRowStyle(row);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
index 7678097..025176c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.groups.GroupMap;
 import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.SuggestUtil;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupInfo;
@@ -243,14 +243,16 @@
       // If the oracle didn't get to complete a UUID, resolve it now.
       //
       addRule.setEnabled(false);
-      SuggestUtil.SVC.suggestAccountGroupForProject(
-          projectName, ref.getName(), 1,
-          new GerritCallback<List<GroupReference>>() {
+      GroupMap.suggestAccountGroupForProject(
+          projectName.get(), ref.getName(), 1,
+          new GerritCallback<GroupMap>() {
             @Override
-            public void onSuccess(List<GroupReference> result) {
+            public void onSuccess(GroupMap result) {
               addRule.setEnabled(true);
-              if (result.size() == 1) {
-                addGroup(result.get(0));
+              if (result.values().length() == 1) {
+                addGroup(new GroupReference(
+                    result.values().get(0).getGroupUUID(),
+                    result.values().get(0).name()));
               } else {
                 groupToAdd.setFocus(true);
                 new ErrorDialog(Gerrit.M.noSuchGroupMessage(ref.getName()))
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
index 177faff0..f29619a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
@@ -15,16 +15,12 @@
 package com.google.gerrit.client.admin;
 
 import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.client.ui.ParentProjectBox;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ProjectAccess;
 import com.google.gerrit.common.data.WebLinkInfoCommon;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.DivElement;
 import com.google.gwt.dom.client.Style.Display;
@@ -156,32 +152,14 @@
   }
 
   private void setUpWebLinks() {
-    if (!value.isConfigVisible()) {
+    List<WebLinkInfoCommon> links = value.getFileHistoryLinks();
+    if (!value.isConfigVisible() || links == null || links.isEmpty()) {
       history.getStyle().setDisplay(Display.NONE);
-    } else {
-      GitwebInfo c = Gerrit.info().gitweb();
-      List<WebLinkInfoCommon> links = value.getFileHistoryLinks();
-      if (c == null && links == null) {
-        history.getStyle().setDisplay(Display.NONE);
-      }
-      if (c != null) {
-        webLinkPanel.add(toAnchor(c.toFileHistory(new Branch.NameKey(value.getProjectName(),
-            RefNames.REFS_CONFIG), "project.config"), c.getLinkName()));
-      }
-
-      if (links != null) {
-        for (WebLinkInfoCommon link : links) {
-          webLinkPanel.add(toAnchor(link));
-        }
-      }
+      return;
     }
-  }
-
-  private Anchor toAnchor(String href, String name) {
-    Anchor a = new Anchor();
-    a.setHref(href);
-    a.setText(name);
-    return a;
+    for (WebLinkInfoCommon link : links) {
+      webLinkPanel.add(toAnchor(link));
+    }
   }
 
   private static Anchor toAnchor(WebLinkInfoCommon info) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
index ebe6caf..7fbf70d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.ui.xml
@@ -48,7 +48,6 @@
 
   .addContainer {
     margin-top: 5px;
-    font-size: 80%;
   }
   .addContainer:hover {
     background-color: selectionColor;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
index b86d0a8..6345207 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
@@ -183,7 +183,7 @@
     driver.edit(mock);
   }
 
-  @UiHandler(value={"cancel1", "cancel2"})
+  @UiHandler(value = {"cancel1", "cancel2"})
   void onCancel(@SuppressWarnings("unused") ClickEvent event) {
     Gerrit.display(PageLinks.toProjectAcceess(getProjectKey()));
   }
@@ -231,19 +231,20 @@
 
           private Set<String> getDiffs(ProjectAccess wantedAccess,
               ProjectAccess newAccess) {
-            final List<AccessSection> wantedSections =
+            List<AccessSection> wantedSections =
                 mergeSections(removeEmptyPermissionsAndSections(wantedAccess.getLocal()));
-            final HashSet<AccessSection> same = new HashSet<>(wantedSections);
-            final HashSet<AccessSection> different =
-                new HashSet<>(wantedSections.size()
-                    + newAccess.getLocal().size());
+            List<AccessSection> newSections =
+                removeEmptyPermissionsAndSections(newAccess.getLocal());
+            HashSet<AccessSection> same = new HashSet<>(wantedSections);
+            HashSet<AccessSection> different =
+                new HashSet<>(wantedSections.size() + newSections.size());
             different.addAll(wantedSections);
-            different.addAll(newAccess.getLocal());
-            same.retainAll(newAccess.getLocal());
+            different.addAll(newSections);
+            same.retainAll(newSections);
             different.removeAll(same);
 
-            final Set<String> differentNames = new HashSet<>();
-            for (final AccessSection s : different) {
+            Set<String> differentNames = new HashSet<>();
+            for (AccessSection s : different) {
               differentNames.add(s.getName());
             }
             return differentNames;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
index 606a2ea..31edefc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.client.access.ProjectAccessInfo;
 import com.google.gerrit.client.actions.ActionButton;
 import com.google.gerrit.client.info.ActionInfo;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.projects.BranchInfo;
 import com.google.gerrit.client.projects.ProjectApi;
@@ -39,7 +38,6 @@
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.client.ui.PagingHyperlink;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gwt.core.client.JsArray;
@@ -54,7 +52,6 @@
 import com.google.gwt.event.dom.client.KeyUpHandler;
 import com.google.gwt.event.logical.shared.ValueChangeEvent;
 import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
@@ -408,8 +405,6 @@
     }
 
     void populate(int row, BranchInfo k) {
-      GitwebInfo c = Gerrit.info().gitweb();
-
       if (k.canDelete()) {
         CheckBox sel = new CheckBox();
         sel.addValueChangeHandler(updateDeleteHandler);
@@ -432,10 +427,6 @@
       }
 
       FlowPanel actionsPanel = new FlowPanel();
-      if (c != null) {
-        actionsPanel.add(new Anchor(c.getLinkName(), false,
-            c.toBranch(new Branch.NameKey(getProjectKey(), k.ref()))));
-      }
       if (k.webLinks() != null) {
         for (WebLinkInfo webLink : Natives.asList(k.webLinks())) {
           actionsPanel.add(webLink.toAnchor());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index c6bd1d1..e1cfa90 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -85,6 +85,7 @@
   private ListBox newChangeForAllNotInTarget;
   private ListBox enableSignedPush;
   private ListBox requireSignedPush;
+  private ListBox rejectImplicitMerges;
   private NpTextBox maxObjectSizeLimit;
   private Label effectiveMaxObjectSizeLimit;
   private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
@@ -184,6 +185,7 @@
     contributorAgreements.setEnabled(isOwner);
     signedOffBy.setEnabled(isOwner);
     requireChangeID.setEnabled(isOwner);
+    rejectImplicitMerges.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
 
     if (pluginConfigWidgets != null) {
@@ -253,6 +255,10 @@
       grid.add(Util.C.requireSignedPush(), requireSignedPush);
     }
 
+    rejectImplicitMerges = newInheritedBooleanBox();
+    saveEnabler.listenTo(rejectImplicitMerges);
+    grid.addHtml(Util.C.rejectImplicitMerges(), rejectImplicitMerges);
+
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
     effectiveMaxObjectSizeLimit = new Label();
@@ -383,6 +389,7 @@
       setBool(enableSignedPush, result.enableSignedPush());
       setBool(requireSignedPush, result.requireSignedPush());
     }
+    setBool(rejectImplicitMerges, result.rejectImplicitMerges());
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
@@ -659,7 +666,7 @@
     ProjectApi.setConfig(getProjectKey(), descTxt.getText().trim(),
         getBool(contributorAgreements), getBool(contentMerge),
         getBool(signedOffBy), getBool(newChangeForAllNotInTarget), getBool(requireChangeID),
-        esp, rsp,
+        esp, rsp, getBool(rejectImplicitMerges),
         maxObjectSizeLimit.getText().trim(),
         SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
         ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
@@ -727,7 +734,7 @@
   private static class LabeledWidgetsGrid extends FlexTable {
     private String labelSuffix;
 
-    public LabeledWidgetsGrid() {
+    LabeledWidgetsGrid() {
       super();
       labelSuffix = ":";
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
index 4503265..98eeb02 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.projects.ProjectInfo;
 import com.google.gerrit.client.projects.ProjectMap;
@@ -34,7 +33,6 @@
 import com.google.gwt.event.dom.client.KeyUpEvent;
 import com.google.gwt.event.dom.client.KeyUpHandler;
 import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HorizontalPanel;
 import com.google.gwt.user.client.ui.Image;
@@ -124,6 +122,7 @@
             state.setTitle(Util.toLongString(k.state()));
             table.setWidget(row, ProjectsTable.C_STATE, state);
             break;
+          case ACTIVE:
           default:
             // Intentionally left blank, do not show an icon when active.
             break;
@@ -140,22 +139,12 @@
       }
 
       private void addWebLinks(int row, ProjectInfo k) {
-        GitwebInfo gitwebLink = Gerrit.info().gitweb();
         List<WebLinkInfo> webLinks = Natives.asList(k.webLinks());
-        if (gitwebLink != null || (webLinks != null && !webLinks.isEmpty())) {
+        if (webLinks != null && !webLinks.isEmpty()) {
           FlowPanel p = new FlowPanel();
           table.setWidget(row, ProjectsTable.C_REPO_BROWSER, p);
-
-          if (gitwebLink != null) {
-            Anchor a = new Anchor();
-            a.setText(gitwebLink.getLinkName());
-            a.setHref(gitwebLink.toProject(k.name_key()));
-            p.add(a);
-          }
-          if (webLinks != null) {
-            for (WebLinkInfo weblink : webLinks) {
-              p.add(weblink.toAnchor());
-            }
+          for (WebLinkInfo weblink : webLinks) {
+            p.add(weblink.toAnchor());
           }
         }
       }
@@ -182,7 +171,7 @@
       @Override
       public void onKeyUp(KeyUpEvent event) {
         Query q = new Query(filterTxt.getValue())
-          .open(event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER);
+          .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
         if (match.equals(q.qMatch)) {
           q.start(start);
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
index 088899e..b549528 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
@@ -33,9 +33,8 @@
     public String render(Integer object) {
       if (0 <= object) {
         return "+" + object;
-      } else {
-        return String.valueOf(object);
       }
+      return String.valueOf(object);
     }
 
     @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png
new file mode 100644
index 0000000..6972c5e
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/arrow_undo.png
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/undoNormal.png b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/undoNormal.png
deleted file mode 100644
index b780f75..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/undoNormal.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
index 18fb833..626252a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
@@ -17,10 +17,11 @@
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.info.ServerInfo;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
+import com.google.gwt.dom.client.Element;
 import com.google.gwt.user.client.History;
 import com.google.gwt.user.client.Window;
 
@@ -235,14 +236,14 @@
     }
   }
 
-  private static final String getPluginName() {
+  private static String getPluginName() {
     if (pluginName != null) {
       return pluginName;
     }
     return PluginName.fromUrl(PluginName.getCallerUrl());
   }
 
-  private static final void go(String urlOrToken) {
+  private static void go(String urlOrToken) {
     if (urlOrToken.startsWith("http:")
         || urlOrToken.startsWith("https:")
         || urlOrToken.startsWith("//")) {
@@ -252,35 +253,35 @@
     }
   }
 
-  private static final void refresh() {
+  private static void refresh() {
     Gerrit.display(History.getToken());
   }
 
-  private static final ServerInfo getServerInfo() {
+  private static ServerInfo getServerInfo() {
     return Gerrit.info();
   }
 
-  private static final AccountInfo getCurrentUser() {
+  private static AccountInfo getCurrentUser() {
     return Gerrit.getUserAccount();
   }
 
-  private static final AccountPreferencesInfo getUserPreferences() {
+  private static GeneralPreferences getUserPreferences() {
     return Gerrit.getUserPreferences();
   }
 
-  private static final void refreshUserPreferences() {
+  private static void refreshUserPreferences() {
     Gerrit.refreshUserPreferences();
   }
 
-  private static final void refreshMenuBar() {
+  private static void refreshMenuBar() {
     Gerrit.refreshMenuBar();
   }
 
-  private static final boolean isSignedIn() {
+  private static boolean isSignedIn() {
     return Gerrit.isSignedIn();
   }
 
-  private static final void showError(String message) {
+  private static void showError(String message) {
     new ErrorDialog(message).center();
   }
 
@@ -296,6 +297,13 @@
     }
   }
 
+  public static final void fireEvent(String event, Element e) {
+    JsArray<JavaScriptObject> h = getEventHandlers(event);
+    for (int i = 0; i < h.length(); i++) {
+      invoke(h.get(i), e);
+    }
+  }
+
   static final void fireEvent(String event, JavaScriptObject a, JavaScriptObject b) {
     JsArray<JavaScriptObject> h = getEventHandlers(event);
     for (int i = 0; i < h.length(); i++) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
index 82b6810..1e77773 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
@@ -55,11 +55,11 @@
     }
   }
 
-  private static final native JavaScriptObject get(String id) /*-{
+  private static native JavaScriptObject get(String id) /*-{
     return $wnd.Gerrit.change_actions[id];
   }-*/;
 
-  private static final native boolean invoke(JavaScriptObject h,
+  private static native boolean invoke(JavaScriptObject h,
       ChangeInfo a, RevisionInfo r)
   /*-{ return h(a,r) }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
index 49b150a..b7e3df3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
@@ -45,7 +45,7 @@
     }
   }
 
-  private static final native JavaScriptObject get(String id) /*-{
+  private static native JavaScriptObject get(String id) /*-{
     return $wnd.Gerrit.edit_actions[id];
   }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
index 0702cba..e20c577 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
@@ -130,14 +130,14 @@
     final native void putBoolean(String k, boolean v) /*-{ this.p[k] = v; }-*/;
     final native void putObject(String k, JavaScriptObject v) /*-{ this.p[k] = v; }-*/;
 
-    private static final native Context create(
+    private static native Context create(
         JavaScriptObject T,
         Definition d,
         Element e)
     /*-{ return new T(d,e) }-*/;
 
     private static final JavaScriptObject TYPE = init();
-    private static final native JavaScriptObject init() /*-{
+    private static native JavaScriptObject init() /*-{
       var T = function(d,e) {
         this._d = d;
         this._u = [];
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
index 98b06bb..19dbe06 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
@@ -109,7 +109,7 @@
     final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
     final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
 
-    private static final native Context create(
+    private static native Context create(
         JavaScriptObject T,
         Definition d,
         ExtensionScreen s,
@@ -118,7 +118,7 @@
     /*-{ return new T(d,s,e,m) }-*/;
 
     private static final JavaScriptObject TYPE = init();
-    private static final native JavaScriptObject init() /*-{
+    private static native JavaScriptObject init() /*-{
       var T = function(d,s,e,m) {
         this._d = d;
         this._s = s;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
index c351bbf..1d7259b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
@@ -91,7 +91,7 @@
       return Natives.keys(settingsScreens());
     }
 
-    private static final native NativeMap<NativeString> settingsScreens()
+    private static native NativeMap<NativeString> settingsScreens()
     /*-{ return $wnd.Gerrit.settingsScreens; }-*/;
 
     public final native String getPath() /*-{ return this.path; }-*/;
@@ -114,7 +114,7 @@
     final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
     final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
 
-    private static final native Context create(
+    private static native Context create(
         JavaScriptObject T,
         Definition d,
         ExtensionSettingsScreen s,
@@ -122,7 +122,7 @@
     /*-{ return new T(d,s,e) }-*/;
 
     private static final JavaScriptObject TYPE = init();
-    private static final native JavaScriptObject init() /*-{
+    private static native JavaScriptObject init() /*-{
       var T = function(d,s,e) {
         this._d = d;
         this._s = s;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
index 95757ab..6af244a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
@@ -62,19 +62,19 @@
     };
   }-*/;
 
-  private static final String css(String css) {
+  private static String css(String css) {
     String name = DOM.createUniqueId();
     StyleInjector.inject("." + name + "{" + css + "}");
     return name;
   }
 
-  private static final String id(IdMap idMap, String key) {
+  private static String id(IdMap idMap, String key) {
     String id = DOM.createUniqueId();
     idMap.put(id, key);
     return " id='" + id + "'";
   }
 
-  private static final String html(ReplacementMap opts, String id) {
+  private static String html(ReplacementMap opts, String id) {
     int d = id.indexOf('.');
     if (0 < d) {
       String name = id.substring(0, d);
@@ -84,7 +84,7 @@
     return new SafeHtmlBuilder().append(opts.str(id)).asString();
   }
 
-  private static final Node parseHtml(
+  private static Node parseHtml(
       String html,
       IdMap ids,
       ReplacementMap opts,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
index 04d4ce5..fb549ee 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
@@ -26,14 +26,14 @@
     return create(TYPE, url, name);
   }
 
-  final native String url() /*-{ return this._scriptUrl }-*/;
-  final native String name() /*-{ return this.name }-*/;
+  native String url() /*-{ return this._scriptUrl }-*/;
+  native String name() /*-{ return this.name }-*/;
 
-  final native boolean loaded() /*-{ return this._success || this._failure != null }-*/;
-  final native Exception failure() /*-{ return this._failure }-*/;
-  final native void failure(Exception e) /*-{ this._failure = e }-*/;
-  final native boolean success() /*-{ return this._success || false }-*/;
-  final native void _initialized() /*-{ this._success = true }-*/;
+  native boolean loaded() /*-{ return this._success || this._failure != null }-*/;
+  native Exception failure() /*-{ return this._failure }-*/;
+  native void failure(Exception e) /*-{ this._failure = e }-*/;
+  native boolean success() /*-{ return this._success || false }-*/;
+  native void _initialized() /*-{ this._success = true }-*/;
 
   private static native Plugin create(JavaScriptObject T, String u, String n)
   /*-{ return new T(u,n) }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
index a560711..330ec15 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
@@ -76,7 +76,7 @@
   protected static final native JavaScriptException makeException()
   /*-{ try { null.a() } catch (e) { return e } }-*/;
 
-  private static final native boolean hasStack(JavaScriptException e)
+  private static native boolean hasStack(JavaScriptException e)
   /*-{ return !!e.stack }-*/;
 
   /** Extracts URL from the stack frame. */
@@ -103,7 +103,7 @@
       return UNKNOWN;
     }
 
-    private static final native JsArrayString getStack(JavaScriptException e)
+    private static native JsArrayString getStack(JavaScriptException e)
     /*-{ return e.stack ? e.stack.split('\n') : [] }-*/;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
index 69887e7..f9084d9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
@@ -61,11 +61,11 @@
     }
   }
 
-  private static final native JavaScriptObject projectAction(String id) /*-{
+  private static native JavaScriptObject projectAction(String id) /*-{
     return $wnd.Gerrit.project_actions[id];
   }-*/;
 
-  private static final native JavaScriptObject branchAction(String id) /*-{
+  private static native JavaScriptObject branchAction(String id) /*-{
     return $wnd.Gerrit.branch_actions[id];
   }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
index 914ef85..50ebce7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
@@ -46,7 +46,7 @@
     }
   }
 
-  private static final native JavaScriptObject get(String id) /*-{
+  private static native JavaScriptObject get(String id) /*-{
     return $wnd.Gerrit.revision_actions[id];
   }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
new file mode 100644
index 0000000..bbd939a
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.blame;
+
+import com.google.gerrit.client.RangeInfo;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+
+public class BlameInfo extends JavaScriptObject {
+  public final native String author() /*-{ return this.author; }-*/;
+  public final native String id() /*-{ return this.id; }-*/;
+  public final native String commitMsg() /*-{ return this.commit_msg; }-*/;
+  public final native int time() /*-{ return this.time; }-*/;
+  public final native JsArray<RangeInfo> ranges() /*-{ return this.ranges; }-*/;
+
+  protected BlameInfo() {
+  }
+
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
index 28943ab..396bc8a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
@@ -35,7 +35,7 @@
   interface Binder extends UiBinder<HTMLPanel, ActionMessageBox> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  static interface Style extends CssResource {
+  interface Style extends CssResource {
     String popup();
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index 033a3a8..36107ee 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -37,7 +37,7 @@
 class Actions extends Composite {
   private static final String[] CORE = {
     "abandon", "cherrypick", "followup", "hashtags", "publish",
-    "rebase", "restore", "revert", "submit", "topic", "/"};
+    "rebase", "restore", "revert", "submit", "topic", "/",};
 
   interface Binder extends UiBinder<FlowPanel, Actions> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
index f5b26d1..63de389 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
@@ -16,7 +16,7 @@
 
 import com.google.gwt.i18n.client.Constants;
 
-interface ChangeConstants extends Constants {
+public interface ChangeConstants extends Constants {
   String previousChange();
   String nextChange();
   String openChange();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 2673f49..87997d1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
@@ -116,6 +115,7 @@
   interface Style extends CssResource {
     String avatar();
     String hashtagName();
+    String hashtagIcon();
     String highlight();
     String labelName();
     String label_may();
@@ -126,6 +126,7 @@
     String pushCertStatus();
     String replyBox();
     String selected();
+    String notCurrentPatchSet();
   }
 
   static ChangeScreen get(NativeEvent in) {
@@ -146,6 +147,7 @@
   private boolean hasDraftComments;
   private CommentLinkProcessor commentLinkProcessor;
   private EditInfo edit;
+  private LocalComments lc;
 
   private List<HandlerRegistration> handlers = new ArrayList<>(4);
   private UpdateCheckTimer updateCheck;
@@ -187,6 +189,8 @@
   @UiField Element actionText;
   @UiField Element actionDate;
   @UiField SimplePanel changeExtension;
+  @UiField SimplePanel relatedExtension;
+  @UiField SimplePanel commitExtension;
 
   @UiField Actions actions;
   @UiField Labels labels;
@@ -232,11 +236,14 @@
     this.revision = normalize(revision);
     this.openReplyBox = openReplyBox;
     this.fileTableMode = mode;
+    this.lc = new LocalComments(changeId);
     add(uiBinder.createAndBindUi(this));
   }
 
-  Change.Id getChangeId() {
-    return changeId;
+  PatchSet.Id getPatchSetId() {
+    return new PatchSet.Id(
+        changeInfo.legacyId(),
+        changeInfo.revisions().get(revision)._number());
   }
 
   @Override
@@ -271,30 +278,87 @@
     loadChangeInfo(true, group.addFinal(
         new GerritCallback<ChangeInfo>() {
           @Override
-          public void onSuccess(ChangeInfo info) {
+          public void onSuccess(final ChangeInfo info) {
             info.init();
-            addExtensionPoints(info);
-            loadConfigInfo(info, base);
+            addExtensionPoints(info, initCurrentRevision(info));
+
+            RevisionInfo rev = info.revision(revision);
+            CallbackGroup group = new CallbackGroup();
+            loadCommit(rev, group);
+
+            group.addListener(new GerritCallback<Void>() {
+              @Override
+              public void onSuccess(Void result) {
+                loadConfigInfo(info, base);
+              }
+            });
+            group.done();
           }
         }));
   }
 
-  private void addExtensionPoints(ChangeInfo change) {
+  private RevisionInfo initCurrentRevision(ChangeInfo info) {
+    info.revisions().copyKeysIntoChildren("name");
+    if (edit != null) {
+      edit.setName(edit.commit().commit());
+      info.setEdit(edit);
+      if (edit.hasFiles()) {
+        edit.files().copyKeysIntoChildren("path");
+      }
+      info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
+      JsArray<RevisionInfo> list = info.revisions().values();
+
+      // Edit is converted to a regular revision (with number = 0) and
+      // added to the list of revisions. Additionally under certain
+      // circumstances change edit is assigned to be the current revision
+      // and is selected to be shown on the change screen.
+      // We have two different strategies to assign edit to the current ps:
+      // 1. revision == null: no revision is selected, so use the edit only
+      //    if it is based on the latest patch set
+      // 2. edit was selected explicitly from ps drop down:
+      //    use the edit regardless of which patch set it is based on
+      if (revision == null) {
+        RevisionInfo.sortRevisionInfoByNumber(list);
+        RevisionInfo rev = list.get(list.length() - 1);
+        if (rev.isEdit()) {
+          info.setCurrentRevision(rev.name());
+        }
+      } else if (revision.equals("edit") || revision.equals("0")) {
+        for (int i = 0; i < list.length(); i++) {
+          RevisionInfo r = list.get(i);
+          if (r.isEdit()) {
+            info.setCurrentRevision(r.name());
+            break;
+          }
+        }
+      }
+    }
+    return resolveRevisionToDisplay(info);
+  }
+
+  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev) {
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER,
-        headerExtension, change);
+        headerExtension, change, rev);
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
-        headerExtensionMiddle, change);
+        headerExtensionMiddle, change, rev);
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
-        headerExtensionRight, change);
+        headerExtensionRight, change, rev);
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-        changeExtension, change);
+        changeExtension, change, rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
+        relatedExtension, change, rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
+        commitExtension, change, rev);
   }
 
   private void addExtensionPoint(GerritUiExtensionPoint extensionPoint,
-      Panel p, ChangeInfo change) {
+      Panel p, ChangeInfo change, RevisionInfo rev) {
     ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
+    extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
     p.add(extensionPanel);
   }
 
@@ -363,15 +427,16 @@
         .openDiv()
         .append(Gerrit.info().change().replyLabel())
         .closeDiv());
-      if (hasDraftComments) {
+      if (hasDraftComments || lc.hasReplyComment()) {
         reply.setStyleName(style.highlight());
       }
       reply.setVisible(true);
     }
   }
 
-  private void gotoSibling(final int offset) {
-    if (offset > 0 && changeInfo.currentRevision().equals(revision)) {
+  private void gotoSibling(int offset) {
+    if (offset > 0 && changeInfo.currentRevision() != null
+        && changeInfo.currentRevision().equals(revision)) {
       return;
     }
 
@@ -416,6 +481,14 @@
     }
   }
 
+  private void updatePatchSetsTextStyle(boolean isPatchSetCurrent) {
+    if (isPatchSetCurrent) {
+      patchSetsText.removeClassName(style.notCurrentPatchSet());
+    } else {
+      patchSetsText.addClassName(style.notCurrentPatchSet());
+    }
+  }
+
   private void initRevisionsAction(ChangeInfo info, String revision,
       NativeMap<ActionInfo> actions) {
     int currentPatchSet;
@@ -429,16 +502,22 @@
     }
 
     String currentlyViewedPatchSet;
-    if (info.revision(revision).id().equals("edit")) {
+    boolean isPatchSetCurrent = true;
+    String revisionId = info.revision(revision).id();
+    if (revisionId.equals("edit")) {
       currentlyViewedPatchSet =
           Resources.M.editPatchSet(RevisionInfo.findEditParent(info.revisions()
               .values()));
       currentPatchSet = info.revisions().values().length() - 1;
     } else {
-      currentlyViewedPatchSet = info.revision(revision).id();
+      currentlyViewedPatchSet = revisionId;
+      if (!currentlyViewedPatchSet.equals(Integer.toString(currentPatchSet))) {
+        isPatchSetCurrent = false;
+      }
     }
     patchSetsText.setInnerText(Resources.M.patchSets(
         currentlyViewedPatchSet, currentPatchSet));
+    updatePatchSetsTextStyle(isPatchSetCurrent);
     patchSetsAction = new PatchSetsAction(
         info.legacyId(), revision, edit,
         style, headerLine, patchSets);
@@ -491,9 +570,10 @@
   }
 
   private void initEditMode(ChangeInfo info, String revision) {
-    if (Gerrit.isSignedIn() && info.status().isOpen()) {
+    if (Gerrit.isSignedIn()) {
       RevisionInfo rev = info.revision(revision);
-      if (isEditModeEnabled(info, rev)) {
+      boolean isOpen = info.status().isOpen();
+      if (isOpen && isEditModeEnabled(info, rev)) {
         editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
         addFile.setVisible(!editMode.isVisible());
         deleteFile.setVisible(!editMode.isVisible());
@@ -515,10 +595,12 @@
       }
 
       if (rev.isEdit()) {
-        if (info.hasEditBasedOnCurrentPatchSet()) {
-          publishEdit.setVisible(true);
-        } else {
-          rebaseEdit.setVisible(true);
+        if (isOpen) {
+          if (info.hasEditBasedOnCurrentPatchSet()) {
+            publishEdit.setVisible(true);
+          } else {
+            rebaseEdit.setVisible(true);
+          }
         }
         deleteEdit.setVisible(true);
       }
@@ -579,24 +661,24 @@
     KeyCommandSet keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
     keysNavigation.add(new KeyCommand(0, 'u', Util.C.upToChangeList()) {
       @Override
-      public void onKeyPress(final KeyPressEvent event) {
+      public void onKeyPress(KeyPressEvent event) {
         Gerrit.displayLastChangeList();
       }
     });
     keysNavigation.add(new KeyCommand(0, 'R', Util.C.keyReloadChange()) {
       @Override
-      public void onKeyPress(final KeyPressEvent event) {
+      public void onKeyPress(KeyPressEvent event) {
         Gerrit.display(PageLinks.toChange(changeId));
       }
     });
     keysNavigation.add(new KeyCommand(0, 'n', Util.C.keyNextPatchSet()) {
         @Override
-        public void onKeyPress(final KeyPressEvent event) {
+        public void onKeyPress(KeyPressEvent event) {
           gotoSibling(1);
         }
       }, new KeyCommand(0, 'p', Util.C.keyPreviousPatchSet()) {
         @Override
-        public void onKeyPress(final KeyPressEvent event) {
+        public void onKeyPress(KeyPressEvent event) {
           gotoSibling(-1);
         }
       });
@@ -839,44 +921,9 @@
     }
   }
 
-  private void loadConfigInfo(final ChangeInfo info, final String base) {
-    info.revisions().copyKeysIntoChildren("name");
-    if (edit != null) {
-      edit.setName(edit.commit().commit());
-      info.setEdit(edit);
-      if (edit.hasFiles()) {
-        edit.files().copyKeysIntoChildren("path");
-      }
-      info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-      JsArray<RevisionInfo> list = info.revisions().values();
-
-      // Edit is converted to a regular revision (with number = 0) and
-      // added to the list of revisions. Additionally under certain
-      // circumstances change edit is assigned to be the current revision
-      // and is selected to be shown on the change screen.
-      // We have two different strategies to assign edit to the current ps:
-      // 1. revision == null: no revision is selected, so use the edit only
-      //    if it is based on the latest patch set
-      // 2. edit was selected explicitly from ps drop down:
-      //    use the edit regardless of which patch set it is based on
-      if (revision == null) {
-        RevisionInfo.sortRevisionInfoByNumber(list);
-        RevisionInfo rev = list.get(list.length() - 1);
-        if (rev.isEdit()) {
-          info.setCurrentRevision(rev.name());
-        }
-      } else if (revision.equals("edit") || revision.equals("0")) {
-        for (int i = 0; i < list.length(); i++) {
-          RevisionInfo r = list.get(i);
-          if (r.isEdit()) {
-            info.setCurrentRevision(r.name());
-            break;
-          }
-        }
-      }
-    }
-    final RevisionInfo rev = resolveRevisionToDisplay(info);
-    final RevisionInfo b = resolveRevisionOrPatchSetId(info, base, null);
+  private void loadConfigInfo(final ChangeInfo info, String base) {
+    RevisionInfo rev = info.revision(revision);
+    RevisionInfo b = resolveRevisionOrPatchSetId(info, base, null);
 
     CallbackGroup group = new CallbackGroup();
     Timestamp lastReply = myLastReply(info);
@@ -890,7 +937,6 @@
     } else {
       loadDiff(b, rev, lastReply, group);
     }
-    loadCommit(rev, group);
 
     if (loaded) {
       group.done();
@@ -925,10 +971,10 @@
     return null;
   }
 
-  private void loadDiff(final RevisionInfo base, final RevisionInfo rev,
-      final Timestamp myLastReply, CallbackGroup group) {
-    final List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(rev, group);
-    final List<NativeMap<JsArray<CommentInfo>>> drafts = loadDrafts(rev, group);
+  private void loadDiff(RevisionInfo base, RevisionInfo rev,
+      Timestamp myLastReply, CallbackGroup group) {
+    List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(rev, group);
+    List<NativeMap<JsArray<CommentInfo>>> drafts = loadDrafts(rev, group);
     loadFileList(base, rev, myLastReply, group, comments, drafts);
 
     if (Gerrit.isSignedIn() && fileTableMode == FileTable.Mode.REVIEW) {
@@ -953,24 +999,25 @@
       final List<NativeMap<JsArray<CommentInfo>>> comments,
       final List<NativeMap<JsArray<CommentInfo>>> drafts) {
     DiffApi.list(changeId.get(),
-      base != null ? base.name() : null,
-      rev.name(),
-      group.add(new AsyncCallback<NativeMap<FileInfo>>() {
-        @Override
-        public void onSuccess(NativeMap<FileInfo> m) {
-          files.set(
-              base != null ? new PatchSet.Id(changeId, base._number()) : null,
-              new PatchSet.Id(changeId, rev._number()),
-              style, reply, fileTableMode, edit != null);
-          files.setValue(m, myLastReply,
-              comments != null ? comments.get(0) : null,
-              drafts != null ? drafts.get(0) : null);
-        }
+        rev.name(),
+        base,
+        group.add(
+            new AsyncCallback<NativeMap<FileInfo>>() {
+              @Override
+              public void onSuccess(NativeMap<FileInfo> m) {
+                files.set(
+                    base != null ? new PatchSet.Id(changeId, base._number()) : null,
+                    new PatchSet.Id(changeId, rev._number()),
+                    style, reply, fileTableMode, edit != null);
+                files.setValue(m, myLastReply,
+                    comments != null ? comments.get(0) : null,
+                    drafts != null ? drafts.get(0) : null);
+              }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      }));
+              @Override
+              public void onFailure(Throwable caught) {
+              }
+            }));
   }
 
   private List<NativeMap<JsArray<CommentInfo>>> loadComments(
@@ -1036,7 +1083,7 @@
   }
 
   private void loadCommit(final RevisionInfo rev, CallbackGroup group) {
-    if (rev.isEdit()) {
+    if (rev.isEdit() || rev.commit() != null) {
       return;
     }
 
@@ -1053,33 +1100,16 @@
         }));
   }
 
-  private void loadSubmitType(final Change.Status status, final boolean canSubmit) {
-    if (canSubmit) {
-      if (status == Change.Status.NEW) {
-        statusText.setInnerText(Util.C.readyToSubmit());
-      }
+  private void renderSubmitType(Change.Status status, boolean canSubmit,
+      SubmitType submitType) {
+    if (canSubmit && status == Change.Status.NEW) {
+      statusText.setInnerText(changeInfo.mergeable()
+          ? Util.C.readyToSubmit()
+          : Util.C.mergeConflict());
     }
-    ChangeApi.revision(changeId.get(), revision)
-      .view("submit_type")
-      .get(new AsyncCallback<NativeString>() {
-        @Override
-        public void onSuccess(NativeString result) {
-          if (canSubmit) {
-            if (status == Change.Status.NEW) {
-              statusText.setInnerText(changeInfo.mergeable()
-                  ? Util.C.readyToSubmit()
-                  : Util.C.mergeConflict());
-            }
-          }
-          setVisible(notMergeable, !changeInfo.mergeable());
-
-          renderSubmitType(result.asString());
-        }
-
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      });
+    setVisible(notMergeable, !changeInfo.mergeable());
+    submitActionText.setInnerText(
+        com.google.gerrit.client.admin.Util.toLongString(submitType));
   }
 
   private RevisionInfo resolveRevisionToDisplay(ChangeInfo info) {
@@ -1099,15 +1129,13 @@
       rev = revisions.get(revisions.length() - 1);
       revision = rev.name();
       return rev;
-    } else {
-      new ErrorDialog(
-          Resources.M.changeWithNoRevisions(info.legacyId().get())).center();
-      throw new IllegalStateException("no revision, cannot proceed");
     }
+    new ErrorDialog(
+        Resources.M.changeWithNoRevisions(info.legacyId().get())).center();
+    throw new IllegalStateException("no revision, cannot proceed");
   }
 
   /**
-   *
    * Resolve a revision or patch set id string to RevisionInfo.
    * When this view is created from the changes table, revision
    * is passed as a real revision.
@@ -1116,13 +1144,20 @@
    *
    * @param info change info
    * @param revOrId revision or patch set id
-   * @param defaultValue value returned when rev is null
+   * @param defaultValue value returned when revOrId is null
    * @return resolved revision or default value
    */
   private RevisionInfo resolveRevisionOrPatchSetId(ChangeInfo info,
       String revOrId, String defaultValue) {
+    int parentNum;
     if (revOrId == null) {
       revOrId = defaultValue;
+    } else if ((parentNum = toParentNum(revOrId)) > 0) {
+      CommitInfo commitInfo = info.revision(revision).commit();
+      JsArray<CommitInfo> parents = commitInfo.parents();
+      if (parents.length() >= parentNum) {
+        return RevisionInfo.forParent(-parentNum, parents.get(parentNum - 1));
+      }
     } else if (!info.revisions().containsKey(revOrId)) {
       JsArray<RevisionInfo> list = info.revisions().values();
       for (int i = 0; i < list.length(); i++) {
@@ -1146,16 +1181,18 @@
         LabelInfo label = info.label(name);
         switch (label.status()) {
           case NEED:
-            statusText.setInnerText("Needs " + name);
+            statusText.setInnerText(Util.M.needs(name));
             canSubmit = false;
             break;
           case REJECT:
           case IMPOSSIBLE:
             if (label.blocking()) {
-              statusText.setInnerText("Not " + name);
+              statusText.setInnerText(Util.M.blockedOn(name));
               canSubmit = false;
             }
             break;
+          case MAY:
+          case OK:
           default:
             break;
           }
@@ -1192,7 +1229,7 @@
     related.set(info, revision);
     reviewers.set(info);
     if (Gerrit.isNoteDbEnabled()) {
-      hashtags.set(info);
+      hashtags.set(info, revision);
     } else {
       setVisible(hashtagTableRow, false);
     }
@@ -1241,7 +1278,7 @@
 
     if (current && info.status().isOpen()) {
       quickApprove.set(info, revision, replyAction);
-      loadSubmitType(info.status(), isSubmittable(info));
+      renderSubmitType(info.status(), isSubmittable(info), info.submitType());
     } else {
       quickApprove.setVisible(false);
     }
@@ -1346,16 +1383,6 @@
     return sb.toString();
   }
 
-  private void renderSubmitType(String action) {
-    try {
-      SubmitType type = SubmitType.valueOf(action);
-      submitActionText.setInnerText(
-          com.google.gerrit.client.admin.Util.toLongString(type));
-    } catch (IllegalArgumentException e) {
-      submitActionText.setInnerText(action);
-    }
-  }
-
   private void renderActionTextDate(ChangeInfo info) {
     String action;
     if (info.created().equals(info.updated())) {
@@ -1387,9 +1414,20 @@
 
     RevisionInfo rev = info.revisions().get(revision);
     JsArray<CommitInfo> parents = rev.commit().parents();
-    diffBase.addItem(
-      parents.length() > 1 ? Util.C.autoMerge() : Util.C.baseDiffItem(),
-      "");
+    if (parents.length() > 1) {
+      diffBase.addItem(Util.C.autoMerge(), "");
+      for (int i = 0; i < parents.length(); i++) {
+        int parentNum = i + 1;
+        diffBase.addItem(Util.M.diffBaseParent(parentNum),
+            String.valueOf(-parentNum));
+      }
+      int parentNum = toParentNum(base);
+      if (parentNum > 0) {
+        selectedIdx = list.length() + parentNum;
+      }
+    } else {
+      diffBase.addItem(Util.C.baseDiffItem(), "");
+    }
 
     diffBase.setSelectedIndex(selectedIdx);
   }
@@ -1409,6 +1447,10 @@
       nm = JsArray.createArray().cast();
     }
 
+    if (om.length() == nm.length()) {
+      return;
+    }
+
     if (updateAvailable == null) {
       updateAvailable = new UpdateAvailableBar() {
         @Override
@@ -1441,4 +1483,22 @@
   private static String normalize(String r) {
     return r != null && !r.isEmpty() ? r : null;
   }
+
+  /**
+   * @param parentToken
+   * @return 1-based parentNum if parentToken is a String which can be parsed as
+   *     a negative integer i.e. "-1", "-2", etc. If parentToken cannot be
+   *     parsed as a negative integer, return zero.
+   */
+  private static int toParentNum(String parentToken) {
+    try {
+      int n = Integer.parseInt(parentToken);
+      if (n < 0) {
+        return -n;
+      }
+      return 0;
+    } catch (NumberFormatException e) {
+      return 0;
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index c643072..a0d5405 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -228,8 +228,9 @@
 
     .hashtagName {
       display: inline-block;
+      height: 15px;
       margin-bottom: 2px;
-      padding: 1px 3px 0px 3px;
+      padding: 1px 3px 1px 3px;
       border-radius: 5px;
       -webkit-border-radius: 5px;
       background: #E2F5FF;
@@ -237,6 +238,12 @@
       white-space: nowrap;
     }
 
+    .hashtagName a,
+    .hashtagName button {
+      position: relative;
+      top: -4px;
+    }
+
     .hashtagName button {
       cursor: pointer;
       padding: 0;
@@ -246,6 +253,11 @@
       white-space: nowrap;
     }
 
+    .hashtagIcon img {
+      position: relative;
+      top: 4px;
+    }
+
     .headerButtons button {
       margin: 5.286px 3px 0 0;
       text-align: center;
@@ -335,9 +347,21 @@
       padding-top: 5px;
     }
 
+    .relatedExtension {
+      padding-top: 5px;
+    }
+
+    .commitExtension {
+      padding-top: 5px;
+    }
+
     .pushCertStatus {
       padding-left: 5px;
     }
+
+    .notCurrentPatchSet {
+      background-color: #FFA62F;
+    }
   </ui:style>
 
   <g:HTMLPanel styleName='{style.cs2}'>
@@ -414,6 +438,7 @@
       <tr>
         <td class='{style.commitColumn}'>
           <c:CommitBox ui:field='commit'/>
+          <g:SimplePanel ui:field='commitExtension' styleName='{style.commitExtension}'/>
         </td>
         <td class='{style.infoColumn}'>
           <table id='change_infoTable'>
@@ -504,6 +529,7 @@
         </td>
         <td class='{style.relatedColumn}'>
           <c:RelatedChanges ui:field='related'/>
+          <g:SimplePanel ui:field='relatedExtension' styleName='{style.relatedExtension}'/>
         </td>
       </tr>
     </table>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
index f8ddef4..4eacc8c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
@@ -16,13 +16,11 @@
 
 import com.google.gerrit.client.AvatarImage;
 import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
 import com.google.gerrit.client.info.ChangeInfo.GitPerson;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
@@ -38,7 +36,6 @@
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.uibinder.client.UiHandler;
 import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
@@ -118,13 +115,13 @@
         committerDate, change);
     text.setHTML(commentLinkProcessor.apply(
         new SafeHtmlBuilder().append(commit.message()).linkify()));
-    setWebLinks(change, revision, revInfo);
+    setWebLinks(webLinkPanel, revInfo.commit());
 
     if (revInfo.commit().parents().length() > 1) {
       mergeCommit.setVisible(true);
     }
 
-    setParents(change.project(), revInfo.commit().parents());
+    setParents(revInfo.commit().parents());
   }
 
   void setParentNotCurrent(boolean parentNotCurrent) {
@@ -133,30 +130,16 @@
     parentNotCurrentText.setInnerText(parentNotCurrent ? "\u25CF" : "");
   }
 
-  private void setWebLinks(ChangeInfo change, String revision,
-      RevisionInfo revInfo) {
-    GitwebInfo gw = Gerrit.info().gitweb();
-    if (gw != null && gw.canLink(revInfo)) {
-      toAnchor(gw.toRevision(change.project(), revision),
-          gw.getLinkName());
-    }
-
-    JsArray<WebLinkInfo> links = revInfo.commit().webLinks();
+  private void setWebLinks(FlowPanel panel, CommitInfo commit) {
+    JsArray<WebLinkInfo> links = commit.webLinks();
     if (links != null) {
       for (WebLinkInfo link : Natives.asList(links)) {
-        webLinkPanel.add(link.toAnchor());
+        panel.add(link.toAnchor());
       }
     }
   }
 
-  private void toAnchor(String href, String name) {
-    Anchor a = new Anchor();
-    a.setHref(href);
-    a.setText(name);
-    webLinkPanel.add(a);
-  }
-
-  private void setParents(String project, JsArray<CommitInfo> commits) {
+  private void setParents(JsArray<CommitInfo> commits) {
     setVisible(firstParent, true);
     TableRowElement next = firstParent;
     TableRowElement previous = null;
@@ -164,7 +147,7 @@
       if (next == firstParent) {
         CopyableLabel copyLabel = getCommitLabel(c);
         parentCommits.add(copyLabel);
-        addLinks(project, c, parentWebLinks);
+        setWebLinks(parentWebLinks, c);
       } else {
         next.appendChild(DOM.createTD());
         Element td1 = DOM.createTD();
@@ -172,7 +155,7 @@
         next.appendChild(td1);
         FlowPanel linksPanel = new FlowPanel();
         linksPanel.addStyleName(style.parentWebLink());
-        addLinks(project, c, linksPanel);
+        setWebLinks(linksPanel, c);
         Element td2 = DOM.createTD();
         td2.appendChild(linksPanel.getElement());
         next.appendChild(td2);
@@ -183,22 +166,6 @@
     }
   }
 
-  private void addLinks(String project, CommitInfo c, FlowPanel panel) {
-    GitwebInfo gw = Gerrit.info().gitweb();
-    if (gw != null) {
-      Anchor a =
-          new Anchor(gw.getLinkName(), gw.toRevision(project, c.commit()));
-      a.setStyleName(style.parentWebLink());
-      panel.add(a);
-    }
-    JsArray<WebLinkInfo> links = c.webLinks();
-    if (links != null) {
-      for (WebLinkInfo link : Natives.asList(links)) {
-        panel.add(link.toAnchor());
-      }
-    }
-  }
-
   private CopyableLabel getCommitLabel(CommitInfo c) {
     CopyableLabel copyLabel;
     copyLabel = new CopyableLabel(c.commit());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
index c8326cc..e8707f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.client.account.AccountApi;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.EditInfo;
 import com.google.gerrit.client.info.ChangeInfo.FetchInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -236,10 +236,10 @@
 
   private void saveScheme() {
     String schemeStr = scheme.getValue(scheme.getSelectedIndex());
-    AccountPreferencesInfo prefs = Gerrit.getUserPreferences();
+    GeneralPreferences prefs = Gerrit.getUserPreferences();
     if (Gerrit.isSignedIn() && !schemeStr.equals(prefs.downloadScheme())) {
       prefs.downloadScheme(schemeStr);
-      AccountPreferencesInfo in = AccountPreferencesInfo.create();
+      GeneralPreferences in = GeneralPreferences.create();
       in.downloadScheme(schemeStr);
       AccountApi.self().view("preferences")
           .put(in, new AsyncCallback<JavaScriptObject>() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
index e73c70a..9afcd4f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
@@ -49,6 +49,6 @@
   }
 
   private static String url(PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toSideBySide(null, ps, info.path());
+    return Dispatcher.toPatch(null, ps, info.path());
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index d1ca517..f0a7ce3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.client.change;
 
 import static com.google.gerrit.client.FormatUtil.formatAbsBytes;
+import static com.google.gerrit.client.FormatUtil.formatAbsPercentage;
 import static com.google.gerrit.client.FormatUtil.formatBytes;
+import static com.google.gerrit.client.FormatUtil.formatPercentage;
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
@@ -34,6 +36,7 @@
 import com.google.gerrit.client.ui.NavigationTable;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -66,7 +69,7 @@
 import java.sql.Timestamp;
 
 public class FileTable extends FlowPanel {
-  static final FileTableResources R = GWT
+  private static final FileTableResources R = GWT
       .create(FileTableResources.class);
 
   interface FileTableResources extends ClientBundle {
@@ -93,7 +96,7 @@
     String restoreDelete();
   }
 
-  public static enum Mode {
+  public enum Mode {
     REVIEW,
     EDIT
   }
@@ -113,7 +116,7 @@
     init(DELETE, RESTORE, REVIEWED, OPEN);
   }
 
-  private static final native void init(String d, String t, String r, String o) /*-{
+  private static native void init(String d, String t, String r, String o) /*-{
     $wnd[d] = $entry(function(e,i) {
       @com.google.gerrit.client.change.FileTable::onDelete(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i)
     });
@@ -282,7 +285,7 @@
     return info.binary()
       ? Dispatcher.toUnified(base, curr, info.path())
       : mode == Mode.REVIEW
-            ? Dispatcher.toSideBySide(base, curr, info.path())
+            ? Dispatcher.toPatch(base, curr, info.path())
             : Dispatcher.toEditScreen(curr, info.path());
   }
 
@@ -464,6 +467,7 @@
     private boolean hasNonBinaryFile;
     private int inserted;
     private int deleted;
+    private long binOldSize;
     private long bytesInserted;
     private long bytesDeleted;
 
@@ -520,6 +524,7 @@
     private void computeInsertedDeleted() {
       inserted = 0;
       deleted = 0;
+      binOldSize = 0;
       bytesInserted = 0;
       bytesDeleted = 0;
       for (int i = 0; i < list.length(); i++) {
@@ -531,6 +536,7 @@
             deleted += info.linesDeleted();
           } else {
             hasBinaryFile = true;
+            binOldSize += info.size() - info.sizeDelta();
             if (info.sizeDelta() >= 0) {
               bytesInserted += info.sizeDelta();
             } else {
@@ -707,8 +713,8 @@
     }
 
     private void columnComments(SafeHtmlBuilder sb, FileInfo info) {
-      JsArray<CommentInfo> cList = get(info.path(), comments);
-      JsArray<CommentInfo> dList = get(info.path(), drafts);
+      JsArray<CommentInfo> cList = filterForParent(get(info.path(), comments));
+      JsArray<CommentInfo> dList = filterForParent(get(info.path(), drafts));
 
       sb.openTd().setStyleName(R.css().draftColumn());
       if (dList.length() > 0) {
@@ -742,6 +748,20 @@
       sb.closeTd();
     }
 
+    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
+      JsArray<CommentInfo> result = JsArray.createArray().cast();
+      for (CommentInfo c : Natives.asList(list)) {
+        if (c.side() == Side.REVISION) {
+          result.push(c);
+        } else if (base == null && !c.hasParent()) {
+          result.push(c);
+        } else if (base != null && c.parent() == -base.get()) {
+          result.push(c);
+        }
+      }
+      return result;
+    }
+
     private JsArray<CommentInfo> get(String p, NativeMap<JsArray<CommentInfo>> m) {
       JsArray<CommentInfo> r = null;
       if (m != null) {
@@ -771,6 +791,12 @@
         }
       } else if (info.binary()) {
         sb.append(formatBytes(info.sizeDelta()));
+        long oldSize = info.size() - info.sizeDelta();
+        if (oldSize != 0) {
+          sb.append(" (")
+            .append(formatPercentage(oldSize, info.sizeDelta()))
+            .append(")");
+        }
       }
       sb.closeTd();
     }
@@ -827,8 +853,17 @@
         if (hasNonBinaryFile) {
           sb.br();
         }
-        sb.append(Util.M.patchTableSize_ModifyBinaryFiles(
-            formatAbsBytes(bytesInserted), formatAbsBytes(bytesDeleted)));
+        if (binOldSize != 0) {
+          sb.append(Util.M.patchTableSize_ModifyBinaryFilesWithPercentages(
+              formatAbsBytes(bytesInserted),
+              formatAbsPercentage(binOldSize, bytesInserted),
+              formatAbsBytes(bytesDeleted),
+              formatAbsPercentage(binOldSize, bytesDeleted)));
+        } else {
+          sb.append(Util.M.patchTableSize_ModifyBinaryFiles(
+              formatAbsBytes(bytesInserted),
+              formatAbsBytes(bytesDeleted)));
+        }
       }
       sb.closeTh();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
index 55f66b7..44aae25 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
@@ -20,12 +20,14 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.NativeEvent;
 import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
@@ -34,9 +36,10 @@
 import com.google.gwt.uibinder.client.UiHandler;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.rpc.StatusCodeException;
-import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Image;
+import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
@@ -51,6 +54,7 @@
   private static final String REMOVE;
   private static final String DATA_ID = "data-id";
 
+  private PatchSet.Id psId;
   private boolean canEdit;
 
   static {
@@ -58,7 +62,7 @@
     init(REMOVE);
   }
 
-  private static final native void init(String r) /*-{
+  private static native void init(String r) /*-{
     $wnd[r] = $entry(function(e) {
       @com.google.gerrit.client.change.Hashtags::onRemove(Lcom/google/gwt/dom/client/NativeEvent;)(e)
     });
@@ -68,12 +72,13 @@
     String hashtags = getDataId(event);
     if (hashtags != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
-      ChangeApi.hashtags(screen.getChangeId().get()).post(
+      final PatchSet.Id psId = screen.getPatchSetId();
+      ChangeApi.hashtags(psId.getParentKey().get()).post(
           PostInput.create(null, hashtags), new GerritCallback<JavaScriptObject>() {
             @Override
             public void onSuccess(JavaScriptObject result) {
               if (screen.isCurrentView()) {
-                Gerrit.display(PageLinks.toChange(screen.getChangeId()));
+                Gerrit.display(PageLinks.toChange(psId));
               }
             }
           });
@@ -93,7 +98,7 @@
   }
 
   @UiField Element hashtagsText;
-  @UiField Button openForm;
+  @UiField Image addHashtagIcon;
   @UiField Element form;
   @UiField Element error;
   @UiField NpTextBox hashtagTextBox;
@@ -109,35 +114,43 @@
     hashtagTextBox.addKeyDownHandler(new KeyDownHandler() {
       @Override
       public void onKeyDown(KeyDownEvent e) {
-        if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ESCAPE) {
+        if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
           onCancel(null);
-        } else if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+        } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
           onAdd(null);
         }
       }
     });
+
+    addHashtagIcon.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            onOpenForm();
+          }
+        },
+        ClickEvent.getType());
   }
 
-  void init(ChangeScreen.Style style){
+  void init(ChangeScreen.Style style) {
     this.style = style;
   }
 
-  void set(ChangeInfo info) {
+  void set(ChangeInfo info, String revision) {
+    psId = new PatchSet.Id(
+        info.legacyId(),
+        info.revisions().get(revision)._number());
+
     canEdit = info.hasActions() && info.actions().containsKey("hashtags");
     this.changeId = info.legacyId();
     display(info);
-    openForm.setVisible(canEdit);
-  }
-
-  @UiHandler("openForm")
-  void onOpenForm(@SuppressWarnings("unused") ClickEvent e) {
-    onOpenForm();
+    addHashtagIcon.setVisible(canEdit);
   }
 
   void onOpenForm() {
     UIObject.setVisible(form, true);
     UIObject.setVisible(error, false);
-    openForm.setVisible(false);
+    addHashtagIcon.setVisible(false);
     hashtagTextBox.setFocus(true);
   }
 
@@ -167,7 +180,13 @@
           .setAttribute("href",
               "#" + PageLinks.toChangeQuery("hashtag:\"" + hashtagName + "\""))
           .setAttribute("role", "listitem")
-          .append("#").append(hashtagName)
+          .openSpan()
+            .setStyleName(style.hashtagIcon())
+            .append(new ImageResourceRenderer().render(
+                Gerrit.RESOURCES.hashtag()))
+          .closeSpan()
+          .append(" ")
+          .append(hashtagName)
           .closeAnchor();
       if (canEdit) {
         html.openElement("button")
@@ -186,7 +205,7 @@
 
   @UiHandler("cancel")
   void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    openForm.setVisible(true);
+    addHashtagIcon.setVisible(true);
     UIObject.setVisible(form, false);
     hashtagTextBox.setFocus(false);
   }
@@ -205,14 +224,9 @@
         new GerritCallback<JsArrayString>() {
           @Override
           public void onSuccess(JsArrayString result) {
-            hashtagTextBox.setEnabled(true);
-            UIObject.setVisible(error, false);
-            error.setInnerText("");
-            hashtagTextBox.setText("");
-
-            if (result != null && result.length() > 0) {
-              updateHashtagList(result);
-            }
+            Gerrit.display(PageLinks.toChange(
+                psId.getParentKey(),
+                String.valueOf(psId.get())));
           }
 
           @Override
@@ -226,32 +240,18 @@
         });
   }
 
-  protected void updateHashtagList() {
-    ChangeApi.detail(changeId.get(),
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            display(result);
-          }
-        });
-  }
-
-  protected void updateHashtagList(JsArrayString hashtags){
-    display(hashtags);
-  }
-
   public static class PostInput extends JavaScriptObject {
     public static PostInput create(String add, String remove) {
       PostInput input = createObject().cast();
       input.init(toJsArrayString(add), toJsArrayString(remove));
       return input;
     }
-    private static JsArrayString toJsArrayString(String commaSeparated){
+    private static JsArrayString toJsArrayString(String commaSeparated) {
       if (commaSeparated == null || commaSeparated.equals("")) {
         return null;
       }
       JsArrayString array = JsArrayString.createArray().cast();
-      for (String hashtag : commaSeparated.split(",")){
+      for (String hashtag : commaSeparated.split(",")) {
         array.push(hashtag.trim());
       }
       return array;
@@ -265,12 +265,4 @@
     protected PostInput() {
     }
   }
-
-  public static class Result extends JavaScriptObject {
-    public final native JsArrayString hashtags() /*-{ return this.hashtags; }-*/;
-    public final native String error() /*-{ return this.error; }-*/;
-
-    protected Result() {
-    }
-  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml
index ba4d6cc..c0bfd1c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.ui.xml
@@ -18,6 +18,7 @@
     xmlns:ui='urn:ui:com.google.gwt.uibinder'
     xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
     xmlns:g='urn:import:com.google.gwt.user.client.ui'>
+  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
   <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
   <ui:style gss='false'>
     button.openAdd {
@@ -47,21 +48,19 @@
       font-weight: bold;
     }
 
+    .addHashtag,
     .cancel {
+      cursor: pointer;
       float: right;
     }
   </ui:style>
   <g:HTMLPanel>
     <div>
       <span ui:field='hashtagsText'/>
-      <g:Button ui:field='openForm'
-         title='Add hashtags to this change'
-         styleName='{res.style.button}'
-         addStyleNames='{style.openAdd}'
-         visible='false'>
-       <ui:attribute name='title'/>
-       <div><ui:msg>Add #...</ui:msg></div>
-      </g:Button>
+      <g:Image ui:field='addHashtagIcon'
+        resource='{ico.addHashtag}'
+        styleName='{style.addHashtag}'
+        title='Add Hashtag'/>
     </div>
     <div ui:field='form' style='display: none' aria-hidden='true'>
       <c:NpTextBox ui:field='hashtagTextBox' styleName='{style.hashtagTextBox}'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index 4139348..89550c6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.NativeEvent;
@@ -48,29 +49,54 @@
 /** Displays a table of label and reviewer scores. */
 class Labels extends Grid {
   private static final String DATA_ID = "data-id";
-  private static final String REMOVE;
+  private static final String DATA_VOTE = "data-vote";
+  private static final String REMOVE_REVIEWER;
+  private static final String REMOVE_VOTE;
 
   static {
-    REMOVE = DOM.createUniqueId().replace('-', '_');
-    init(REMOVE);
+    REMOVE_REVIEWER = DOM.createUniqueId().replace('-', '_');
+    REMOVE_VOTE = DOM.createUniqueId().replace('-', '_');
+    init(REMOVE_REVIEWER, REMOVE_VOTE);
   }
 
-  private static final native void init(String r) /*-{
+  private static native void init(String r, String v) /*-{
     $wnd[r] = $entry(function(e) {
-      @com.google.gerrit.client.change.Labels::onRemove(Lcom/google/gwt/dom/client/NativeEvent;)(e)
+      @com.google.gerrit.client.change.Labels::onRemoveReviewer(Lcom/google/gwt/dom/client/NativeEvent;)(e)
+    });
+    $wnd[v] = $entry(function(e) {
+      @com.google.gerrit.client.change.Labels::onRemoveVote(Lcom/google/gwt/dom/client/NativeEvent;)(e)
     });
   }-*/;
 
-  private static void onRemove(NativeEvent event) {
+  private static void onRemoveReviewer(NativeEvent event) {
     Integer user = getDataId(event);
     if (user != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
-      ChangeApi.reviewer(screen.getChangeId().get(), user).delete(
+      final Change.Id changeId = screen.getPatchSetId().getParentKey();
+      ChangeApi.reviewer(changeId.get(), user).delete(
           new GerritCallback<JavaScriptObject>() {
             @Override
             public void onSuccess(JavaScriptObject result) {
               if (screen.isCurrentView()) {
-                Gerrit.display(PageLinks.toChange(screen.getChangeId()));
+                Gerrit.display(PageLinks.toChange(changeId));
+              }
+            }
+          });
+    }
+  }
+
+  private static void onRemoveVote(NativeEvent event) {
+    Integer user = getDataId(event);
+    String vote = getVoteId(event);
+    if (user != null && vote != null) {
+      final ChangeScreen screen = ChangeScreen.get(event);
+      final Change.Id changeId = screen.getPatchSetId().getParentKey();
+      ChangeApi.vote(changeId.get(), user, vote).delete(
+          new GerritCallback<JavaScriptObject>() {
+            @Override
+            public void onSuccess(JavaScriptObject result) {
+              if (screen.isCurrentView()) {
+                Gerrit.display(PageLinks.toChange(changeId));
               }
             }
           });
@@ -89,6 +115,18 @@
     return null;
   }
 
+  private static String getVoteId(NativeEvent event) {
+    Element e = event.getEventTarget().cast();
+    while (e != null) {
+      String v = e.getAttribute(DATA_VOTE);
+      if (!v.isEmpty()) {
+        return v;
+      }
+      e = e.getParentElement();
+    }
+    return null;
+  }
+
   private ChangeScreen.Style style;
 
   void init(ChangeScreen.Style style) {
@@ -97,6 +135,7 @@
 
   void set(ChangeInfo info) {
     List<String> names = new ArrayList<>(info.labels());
+    Set<Integer> removable = info.removableReviewerIds();
     Collections.sort(names);
 
     resize(names.size(), 2);
@@ -106,14 +145,14 @@
       LabelInfo label = info.label(name);
       setText(row, 0, name);
       if (label.all() != null) {
-        setWidget(row, 1, renderUsers(label));
+        setWidget(row, 1, renderUsers(label, removable));
       }
       getCellFormatter().setStyleName(row, 0, style.labelName());
       getCellFormatter().addStyleName(row, 0, getStyleForLabel(label));
     }
   }
 
-  private Widget renderUsers(LabelInfo label) {
+  private Widget renderUsers(LabelInfo label, Set<Integer> removable) {
     Map<Integer, List<ApprovalInfo>> m = new HashMap<>(4);
     int approved = 0;
     int rejected = 0;
@@ -150,8 +189,8 @@
         html.setStyleName(style.label_reject());
       }
       html.append(val).append(" ");
-      html.append(formatUserList(style, m.get(v),
-          Collections.<Integer> emptySet(), null));
+      html.append(formatUserList(style, m.get(v), removable,
+          label.name(), null));
       html.closeSpan();
     }
     return html.toBlockWidget();
@@ -198,6 +237,7 @@
   static SafeHtml formatUserList(ChangeScreen.Style style,
       Collection<? extends AccountInfo> in,
       Set<Integer> removable,
+      String label,
       Map<Integer, VotableInfo> votable) {
     List<AccountInfo> users = new ArrayList<>(in);
     Collections.sort(users, new Comparator<AccountInfo>() {
@@ -239,17 +279,21 @@
 
       String votableCategories = "";
       if (votable != null) {
-        Set<String> s = votable.get(ai._accountId()).votableLabels();
-        if (!s.isEmpty()) {
-          StringBuilder sb = new StringBuilder(Util.C.votable());
-          sb.append(" ");
-          for (Iterator<String> it = s.iterator(); it.hasNext();) {
-            sb.append(it.next());
-            if (it.hasNext()) {
-              sb.append(", ");
+        VotableInfo vi = votable.get(ai._accountId());
+        if (vi != null) {
+          Set<String> s = vi.votableLabels();
+          if (!s.isEmpty()) {
+            StringBuilder sb = new StringBuilder(Util.C.votable());
+            sb.append(" ");
+            for (Iterator<String> it = vi.votableLabels().iterator();
+                it.hasNext();) {
+              sb.append(it.next());
+              if (it.hasNext()) {
+                sb.append(", ");
+              }
             }
+            votableCategories = sb.toString();
           }
-          votableCategories = sb.toString();
         }
       }
       html.openSpan()
@@ -257,6 +301,9 @@
           .setAttribute(DATA_ID, ai._accountId())
           .setAttribute("title", getTitle(ai, votableCategories))
           .setStyleName(style.label_user());
+      if (label != null) {
+        html.setAttribute(DATA_VOTE, label);
+      }
       if (img != null) {
         html.openElement("img")
             .setStyleName(style.avatar())
@@ -271,10 +318,15 @@
       }
       html.append(name);
       if (removable.contains(ai._accountId())) {
-        html.openElement("button")
-            .setAttribute("title", Util.M.removeReviewer(name))
-            .setAttribute("onclick", REMOVE + "(event)")
-            .append("×")
+        html.openElement("button");
+        if (label != null) {
+          html.setAttribute("title", Util.M.removeVote(label))
+              .setAttribute("onclick", REMOVE_VOTE + "(event)");
+        } else {
+          html.setAttribute("title", Util.M.removeReviewer(name))
+              .setAttribute("onclick", REMOVE_REVIEWER + "(event)");
+        }
+        html.append("×")
             .closeElement("button");
       }
       html.closeSpan();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
index fc14587..a0f8858 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.change;
 
 import com.google.gerrit.client.Dispatcher;
+import com.google.gerrit.client.api.ApiGlue;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.diff.DisplaySide;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
@@ -56,14 +57,14 @@
       ps = defaultPs;
       psLoc.removeFromParent();
       psLoc = null;
-      psNum= null;
+      psNum = null;
     } else {
       ps = defaultPs;
       sideLoc.removeFromParent();
       sideLoc = null;
       psLoc.removeFromParent();
       psLoc = null;
-      psNum= null;
+      psNum = null;
     }
 
     if (info.hasLine()) {
@@ -82,11 +83,12 @@
     if (info.message() != null) {
       message.setInnerSafeHtml(clp.apply(new SafeHtmlBuilder()
           .append(info.message().trim()).wikify()));
+      ApiGlue.fireEvent("comment", message);
     }
   }
 
   private static String url(PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toSideBySide(null, ps, info.path(),
+    return Dispatcher.toPatch(null, ps, info.path(),
         info.side() == Side.PARENT ? DisplaySide.A : DisplaySide.B,
         info.line());
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
new file mode 100644
index 0000000..f6022f9
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.changes.CommentApi;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.diff.CommentRange;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.storage.client.Storage;
+import com.google.gwt.user.client.Cookies;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+public class LocalComments {
+  private final Change.Id changeId;
+  private final PatchSet.Id psId;
+  private final StorageBackend storage;
+
+  private static class InlineComment {
+    final PatchSet.Id psId;
+    final CommentInfo commentInfo;
+
+    InlineComment(PatchSet.Id psId, CommentInfo commentInfo) {
+      this.psId = psId;
+      this.commentInfo = commentInfo;
+    }
+  }
+
+  private static class StorageBackend {
+    private final Storage storageBackend;
+
+    StorageBackend() {
+      storageBackend = (Storage.isLocalStorageSupported())
+          ? Storage.getLocalStorageIfSupported()
+          : Storage.getSessionStorageIfSupported();
+    }
+
+    String getItem(String key) {
+      if (storageBackend == null) {
+        return Cookies.getCookie(key);
+      }
+      return storageBackend.getItem(key);
+    }
+
+    void setItem(String key, String value) {
+      if (storageBackend == null) {
+        Cookies.setCookie(key, value);
+        return;
+      }
+      storageBackend.setItem(key, value);
+    }
+
+    void removeItem(String key) {
+      if (storageBackend == null) {
+        Cookies.removeCookie(key);
+        return;
+      }
+      storageBackend.removeItem(key);
+    }
+
+    Collection<String> getKeys() {
+      if (storageBackend == null) {
+        return Cookies.getCookieNames();
+      }
+      ArrayList<String> result = new ArrayList<>(storageBackend.getLength());
+      for (int i = 0; i < storageBackend.getLength(); i++) {
+        result.add(storageBackend.key(i));
+      }
+      return result;
+    }
+  }
+
+  public LocalComments(Change.Id changeId) {
+    this.changeId = changeId;
+    this.psId = null;
+    this.storage = new StorageBackend();
+  }
+
+  public LocalComments(PatchSet.Id psId) {
+    this.changeId = psId.getParentKey();
+    this.psId = psId;
+    this.storage = new StorageBackend();
+  }
+
+  public String getReplyComment() {
+    String comment = storage.getItem(getReplyCommentName());
+    storage.removeItem(getReplyCommentName());
+    return comment;
+  }
+
+  public void setReplyComment(String comment) {
+    storage.setItem(getReplyCommentName(), comment.trim());
+  }
+
+  public boolean hasReplyComment() {
+    return storage.getKeys().contains(getReplyCommentName());
+  }
+
+  public void removeReplyComment() {
+    if (hasReplyComment()) {
+      storage.removeItem(getReplyCommentName());
+    }
+  }
+
+  private String getReplyCommentName() {
+    return "savedReplyComment-" + changeId.toString();
+  }
+
+  public static void saveInlineComments() {
+    final StorageBackend storage = new StorageBackend();
+    for (final String cookie : storage.getKeys()) {
+      if (isInlineComment(cookie)) {
+        InlineComment input = getInlineComment(cookie);
+        if (input.commentInfo.id() == null) {
+          CommentApi.createDraft(input.psId, input.commentInfo,
+              new GerritCallback<CommentInfo>() {
+                @Override
+                public void onSuccess(CommentInfo result) {
+                  storage.removeItem(cookie);
+                }
+              });
+        } else {
+          CommentApi.updateDraft(input.psId, input.commentInfo.id(),
+              input.commentInfo, new GerritCallback<CommentInfo>() {
+                @Override
+                public void onSuccess(CommentInfo result) {
+                  storage.removeItem(cookie);
+                }
+
+                @Override
+                public void onFailure(Throwable caught) {
+                  if (RestApi.isNotFound(caught)) {
+                    // the draft comment, that was supposed to be updated,
+                    // was deleted in the meantime
+                    storage.removeItem(cookie);
+                  } else {
+                    super.onFailure(caught);
+                  }
+                }
+              });
+        }
+      }
+    }
+  }
+
+  public void setInlineComment(CommentInfo comment) {
+    String name = getInlineCommentName(comment);
+    if (name == null) {
+      // Failed to get the store key -- so we can't continue.
+      return;
+    }
+    storage.setItem(name, comment.message().trim());
+  }
+
+  public boolean hasInlineComments() {
+    for (String cookie : storage.getKeys()) {
+      if (isInlineComment(cookie)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static boolean isInlineComment(String key) {
+    return key.startsWith("patchCommentEdit-") || key.startsWith("patchReply-")
+        || key.startsWith("patchComment-");
+  }
+
+  private static InlineComment getInlineComment(String key) {
+    String path;
+    Side side = Side.PARENT;
+    int line = 0;
+    CommentRange range;
+    StorageBackend storage = new StorageBackend();
+
+    String[] elements = key.split("-");
+    int offset = 1;
+    if (key.startsWith("patchReply-") || key.startsWith("patchCommentEdit-")) {
+      offset = 2;
+    }
+    Change.Id changeId = new Change.Id(Integer.parseInt(elements[offset + 0]));
+    PatchSet.Id psId =
+        new PatchSet.Id(changeId, Integer.parseInt(elements[offset + 1]));
+    path = atob(elements[offset + 2]);
+    side = (Side.PARENT.toString().equals(elements[offset + 3])) ? Side.PARENT
+        : Side.REVISION;
+    range = null;
+    if (elements[offset + 4].startsWith("R")) {
+      String rangeStart = elements[offset + 4].substring(1);
+      String rangeEnd = elements[offset + 5];
+      String[] split = rangeStart.split(",");
+      int sl = Integer.parseInt(split[0]);
+      int sc = Integer.parseInt(split[1]);
+      split = rangeEnd.split(",");
+      int el = Integer.parseInt(split[0]);
+      int ec = Integer.parseInt(split[1]);
+      range = CommentRange.create(sl, sc, el, ec);
+      line = sl;
+    } else {
+      line = Integer.parseInt(elements[offset + 4]);
+    }
+    CommentInfo info = CommentInfo.create(path, side, line, range);
+    info.message(storage.getItem(key));
+    if (key.startsWith("patchReply-")) {
+      info.inReplyTo(elements[1]);
+    } else if (key.startsWith("patchCommentEdit-")) {
+      info.id(elements[1]);
+    }
+    InlineComment inlineComment = new InlineComment(psId, info);
+    return inlineComment;
+  }
+
+  private String getInlineCommentName(CommentInfo comment) {
+    if (psId == null) {
+      return null;
+    }
+    String result = "patchComment-";
+    if (comment.id() != null) {
+      result = "patchCommentEdit-" + comment.id() + "-";
+    } else if (comment.inReplyTo() != null) {
+      result = "patchReply-" + comment.inReplyTo() + "-";
+    }
+    result += changeId + "-" + psId.getId() + "-" + btoa(comment.path()) + "-"
+        + comment.side() + "-";
+    if (comment.hasRange()) {
+      result += "R" + comment.range().startLine() + ","
+          + comment.range().startCharacter() + "-" + comment.range().endLine()
+          + "," + comment.range().endCharacter();
+    } else {
+      result += comment.line();
+    }
+    return result;
+  }
+
+  private static native String btoa(String a) /*-{ return btoa(a); }-*/;
+
+  private static native String atob(String b) /*-{ return atob(b); }-*/;
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
index 8b559e3..6c27ed9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.client.AvatarImage;
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.api.ApiGlue;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.info.ChangeInfo.MessageInfo;
@@ -49,7 +50,7 @@
   interface Binder extends UiBinder<HTMLPanel, Message> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  static interface Style extends CssResource {
+  interface Style extends CssResource {
     String closed();
   }
 
@@ -96,6 +97,7 @@
       summary.setInnerText(msg);
       message.setInnerSafeHtml(history.getCommentLinkProcessor()
         .apply(new SafeHtmlBuilder().append(msg).wikify()));
+      ApiGlue.fireEvent("comment", message);
     } else {
       reply.getElement().getStyle().setVisibility(Visibility.HIDDEN);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
index 1d76612..1753c7b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
@@ -63,7 +63,7 @@
     init(OPEN);
   }
 
-  private static final native void init(String o) /*-{
+  private static native void init(String o) /*-{
     $wnd[o] = $entry(function(e,i) {
       return @com.google.gerrit.client.change.PatchSetsBox::onOpen(Lcom/google/gwt/dom/client/NativeEvent;I)(e,i);
     });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
index 3be3486..0d0dba7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
@@ -137,7 +137,7 @@
     abstract String getTitle(int count);
     abstract String getTitle(String count);
 
-    private Tab(String defaultTitle, String tooltip) {
+    Tab(String defaultTitle, String tooltip) {
       this.defaultTitle = defaultTitle;
       this.tooltip = tooltip;
     }
@@ -215,10 +215,12 @@
         EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
         new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
 
-    // TODO(sbeller): show only on latest revision
-    ChangeApi.change(info.legacyId().get()).view("submitted_together")
-        .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
-            info.project(), revision));
+    if (info.currentRevision() != null
+        && info.currentRevision().equals(revision)) {
+      ChangeApi.change(info.legacyId().get()).view("submitted_together")
+          .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
+              info.project(), revision));
+    }
 
     if (!Gerrit.info().change().isSubmitWholeTopicEnabled()
         && info.topic() != null && !"".equals(info.topic())) {
@@ -385,7 +387,7 @@
       String s = statusRaw();
       return s != null ? Change.Status.valueOf(s) : null;
     }
-    private final native String statusRaw() /*-{ return this.status; }-*/;
+    private native String statusRaw() /*-{ return this.status; }-*/;
 
     final native void setId(String i)
     /*-{ if(i)this.change_id=i; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
index 23959e7..791effc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
@@ -18,10 +18,8 @@
 import com.google.gerrit.client.change.RelatedChanges.ChangeAndCommit;
 import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
@@ -68,7 +66,7 @@
   private static final SafeHtml POINTER_HTML =
       AbstractImagePrototype.create(Gerrit.RESOURCES.arrowRight()).getSafeHtml();
 
-  private static final native String init(String o) /*-{
+  private static native String init(String o) /*-{
     $wnd[o] = $entry(@com.google.gerrit.client.change.RelatedChangesTab::onOpen(
       Lcom/google/gwt/dom/client/NativeEvent;Lcom/google/gwt/dom/client/Element;));
     return o + '(event,this)';
@@ -286,6 +284,8 @@
       } else {
         sb.openSpan().setStyleName(RelatedChanges.R.css().subject());
       }
+      sb.setAttribute("data-branch", info.branch());
+      sb.setAttribute("data-project", info.project());
       String url = url();
       if (url != null) {
         sb.openAnchor().setAttribute("href", url);
@@ -307,12 +307,7 @@
       sb.closeSpan();
 
       sb.openSpan();
-      GitwebInfo gw = Gerrit.info().gitweb();
-      if (gw != null && (!info.hasChangeNumber() || !info.hasRevisionNumber())) {
-        sb.setStyleName(RelatedChanges.R.css().gitweb());
-        sb.setAttribute("title", gw.getLinkName());
-        sb.append('\u25CF'); // Unicode 'BLACK CIRCLE'
-      } else if (info.status() != null && !info.status().isOpen()) {
+      if (info.status() != null && !info.status().isOpen()) {
         sb.setStyleName(RelatedChanges.R.css().gitweb());
         sb.setAttribute("title", Util.toLongString(info.status()));
         sb.append('\u25CF'); // Unicode 'BLACK CIRCLE'
@@ -339,15 +334,7 @@
 
     private String url() {
       if (info.hasChangeNumber() && info.hasRevisionNumber()) {
-        PatchSet.Id id = info.patchSetId();
-        return "#" + PageLinks.toChange(
-            id.getParentKey(),
-            id.getId());
-      }
-
-      GitwebInfo gw = Gerrit.info().gitweb();
-      if (gw != null && project != null) {
-        return gw.toRevision(project, info.commit().commit());
+        return "#" + PageLinks.toChange(info.patchSetId());
       }
       return null;
     }
@@ -603,7 +590,7 @@
     }
   }
 
-  private static final native Node createDocumentFragment() /*-{
+  private static native Node createDocumentFragment() /*-{
     return $doc.createDocumentFragment();
   }-*/;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
index 2ec4b6b..e29048a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.change;
 
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_ENTER;
+import static com.google.gwt.event.dom.client.KeyCodes.KEY_MAC_ENTER;
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.ChangeApi;
@@ -28,6 +29,7 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.LabelValue;
@@ -42,6 +44,8 @@
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.KeyDownEvent;
+import com.google.gwt.event.dom.client.KeyDownHandler;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.event.dom.client.KeyPressHandler;
 import com.google.gwt.event.dom.client.MouseOutEvent;
@@ -75,7 +79,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 
-class ReplyBox extends Composite {
+public class ReplyBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, ReplyBox> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
@@ -90,6 +94,7 @@
   private final String revision;
   private ReviewInput in = ReviewInput.create();
   private int labelHelpColumn;
+  private LocalComments lc;
 
   @UiField Styles style;
   @UiField TextArea message;
@@ -109,6 +114,7 @@
     this.clp = clp;
     this.psId = psId;
     this.revision = revision;
+    this.lc = new LocalComments(psId.getParentKey());
     initWidget(uiBinder.createAndBindUi(this));
 
     List<String> names = new ArrayList<>(permitted.keySet());
@@ -120,12 +126,13 @@
     }
 
     addDomHandler(
-      new KeyPressHandler() {
+      new KeyDownHandler() {
         @Override
-        public void onKeyPress(KeyPressEvent e) {
+        public void onKeyDown(KeyDownEvent e) {
           e.stopPropagation();
-          if ((e.getCharCode() == '\n' || e.getCharCode() == KEY_ENTER)
-              && e.isControlKeyDown()) {
+          if ((e.getNativeKeyCode() == KEY_ENTER
+              || e.getNativeKeyCode() == KEY_MAC_ENTER)
+              && (e.isControlKeyDown() || e.isMetaKeyDown())) {
             e.preventDefault();
             if (post.isEnabled()) {
               onPost(null);
@@ -133,6 +140,14 @@
           }
         }
       },
+      KeyDownEvent.getType());
+    addDomHandler(
+      new KeyPressHandler() {
+        @Override
+        public void onKeyPress(KeyPressEvent e) {
+          e.stopPropagation();
+        }
+      },
       KeyPressEvent.getType());
   }
 
@@ -140,6 +155,10 @@
   protected void onLoad() {
     commentsPanel.setVisible(false);
     post.setEnabled(false);
+    if (lc.hasReplyComment()) {
+      message.setText(lc.getReplyComment());
+      lc.removeReplyComment();
+    }
     ChangeApi.drafts(psId.getParentKey().get())
         .get(new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
           @Override
@@ -197,9 +216,14 @@
       .post(in, new GerritCallback<ReviewInput>() {
         @Override
         public void onSuccess(ReviewInput result) {
-          Gerrit.display(PageLinks.toChange(
-              psId.getParentKey(),
-              String.valueOf(psId.get())));
+          Gerrit.display(PageLinks.toChange(psId));
+        }
+        @Override
+        public void onFailure(final Throwable caught) {
+          if (RestApi.isNotSignedIn(caught)) {
+            lc.setReplyComment(message.getText());
+          }
+          super.onFailure(caught);
         }
       });
     hide();
@@ -214,7 +238,7 @@
   void replyTo(MessageInfo msg) {
     if (msg.message() != null) {
       String t = message.getText();
-      String m = quote(msg);
+      String m = quote(removePatchSetHeaderLine(msg.message()));
       if (t == null || t.isEmpty()) {
         t = m;
       } else if (t.endsWith("\n\n")) {
@@ -224,20 +248,25 @@
       } else {
         t += "\n\n" + m;
       }
-      message.setText(t + "\n\n");
+      message.setText(t);
     }
   }
 
-  private static String quote(MessageInfo msg) {
-    String m = msg.message().trim();
-    if (m.startsWith("Patch Set ")) {
-      int i = m.indexOf('\n');
+  private static String removePatchSetHeaderLine(String msg) {
+    msg = msg.trim();
+    if (msg.startsWith("Patch Set ")) {
+      int i = msg.indexOf('\n');
       if (i > 0) {
-        m = m.substring(i + 1).trim();
+        msg = msg.substring(i + 1).trim();
       }
     }
+    return msg;
+  }
+
+  public static String quote(String msg) {
+    msg = msg.trim();
     StringBuilder quotedMsg = new StringBuilder();
-    for (String line : m.split("\\n")) {
+    for (String line : msg.split("\\n")) {
       line = line.trim();
       while (line.length() > 67) {
         int i = line.lastIndexOf(' ', 67);
@@ -253,7 +282,8 @@
       }
       quotedMsg.append(" > ").append(line).append("\n");
     }
-    return quotedMsg.toString().substring(0, quotedMsg.length() - 1); // remove last '\n'
+    quotedMsg.append("\n");
+    return quotedMsg.toString();
   }
 
   private void hide() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
index 52f6b6a..6903b91 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
@@ -38,7 +38,10 @@
       position: absolute;
       bottom: 5px;
       right: 5px;
+      background-color: #eee;
+      background-image: -webkit-linear-gradient(top, #eee, #eee);
     }
+    .cancel div { color: #444; }
     .comments {
       max-height: 275px;
       width: 526px;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
index 4fc76c1..fdfaf61 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
@@ -19,9 +19,9 @@
 import com.google.gwt.resources.client.CssResource;
 
 public interface Resources extends ClientBundle {
-  public static final Resources I = GWT.create(Resources.class);
-  static final ChangeConstants C = GWT.create(ChangeConstants.class);
-  static final ChangeMessages M = GWT.create(ChangeMessages.class);
+  Resources I = GWT.create(Resources.class);
+  ChangeConstants C = GWT.create(ChangeConstants.class);
+  ChangeMessages M = GWT.create(ChangeMessages.class);
 
   @Source("common.css") Style style();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
index 1c0486d..a852fa0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.client.change;
 
-import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.admin.Util;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.groups.GroupBaseInfo;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.AccountSuggestOracle;
 import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
@@ -42,7 +42,7 @@
           public void onSuccess(JsArray<SuggestReviewerInfo> result) {
             List<RestReviewerSuggestion> r = new ArrayList<>(result.length());
             for (SuggestReviewerInfo reviewer : Natives.asList(result)) {
-              r.add(new RestReviewerSuggestion(reviewer));
+              r.add(new RestReviewerSuggestion(reviewer, req.getQuery()));
             }
             cb.onSuggestionsReady(req, new Response(r));
           }
@@ -60,29 +60,29 @@
   }
 
   private static class RestReviewerSuggestion implements Suggestion {
-    private final SuggestReviewerInfo reviewer;
+    private final String displayString;
+    private final String replacementString;
 
-    RestReviewerSuggestion(final SuggestReviewerInfo reviewer) {
-      this.reviewer = reviewer;
+    RestReviewerSuggestion(SuggestReviewerInfo reviewer, String query) {
+      if (reviewer.account() != null) {
+        this.replacementString = AccountSuggestOracle.AccountSuggestion
+            .format(reviewer.account(), query);
+        this.displayString = replacementString;
+      } else {
+        this.replacementString = reviewer.group().name();
+        this.displayString =
+            replacementString + " (" + Util.C.suggestedGroupLabel() + ")";
+      }
     }
 
     @Override
     public String getDisplayString() {
-      if (reviewer.account() != null) {
-        return FormatUtil.nameEmail(reviewer.account());
-      }
-      return reviewer.group().name()
-          + " ("
-          + Util.C.suggestedGroupLabel()
-          + ")";
+      return displayString;
     }
 
     @Override
     public String getReplacementString() {
-      if (reviewer.account() != null) {
-        return FormatUtil.nameEmail(reviewer.account());
-      }
-      return reviewer.group().name();
+      return replacementString;
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index 3ba7ea5..b69d1c0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.client.ConfirmationCallback;
 import com.google.gerrit.client.ConfirmationDialog;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.NotSignedInDialog;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.info.AccountInfo;
@@ -28,12 +29,14 @@
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.RemoteSuggestBox;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.event.logical.shared.CloseHandler;
 import com.google.gwt.event.logical.shared.SelectionEvent;
@@ -45,12 +48,14 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Image;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 
+import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -60,7 +65,7 @@
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Element reviewersText;
-  @UiField Button openForm;
+  @UiField Image addReviewerIcon;
   @UiField Button addMe;
   @UiField Element form;
   @UiField Element error;
@@ -92,6 +97,14 @@
     });
 
     initWidget(uiBinder.createAndBindUi(this));
+    addReviewerIcon.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            onOpenForm();
+          }
+        },
+        ClickEvent.getType());
   }
 
   void init(ChangeScreen.Style style, Element ccText) {
@@ -103,18 +116,13 @@
     this.changeId = info.legacyId();
     display(info);
     reviewerSuggestOracle.setChange(changeId);
-    openForm.setVisible(Gerrit.isSignedIn());
-  }
-
-  @UiHandler("openForm")
-  void onOpenForm(@SuppressWarnings("unused") ClickEvent e) {
-    onOpenForm();
+    addReviewerIcon.setVisible(Gerrit.isSignedIn());
   }
 
   void onOpenForm() {
     UIObject.setVisible(form, true);
     UIObject.setVisible(error, false);
-    openForm.setVisible(false);
+    addReviewerIcon.setVisible(false);
     suggestBox.setFocus(true);
   }
 
@@ -131,7 +139,7 @@
 
   @UiHandler("cancel")
   void onCancel(@SuppressWarnings("unused") ClickEvent e) {
-    openForm.setVisible(true);
+    addReviewerIcon.setVisible(true);
     UIObject.setVisible(form, false);
     suggestBox.setFocus(false);
     suggestBox.setText("");
@@ -178,10 +186,14 @@
 
           @Override
           public void onFailure(Throwable err) {
-            UIObject.setVisible(error, true);
-            error.setInnerText(err instanceof StatusCodeException
-                ? ((StatusCodeException) err).getEncodedResponse()
-                : err.getMessage());
+            if (isSigninFailure(err)) {
+              new NotSignedInDialog().center();
+            } else {
+              UIObject.setVisible(error, true);
+              error.setInnerText(err instanceof StatusCodeException
+                  ? ((StatusCodeException) err).getEncodedResponse()
+                  : err.getMessage());
+            }
           }
         });
   }
@@ -197,33 +209,20 @@
   }
 
   private void display(ChangeInfo info) {
-    Map<Integer, AccountInfo> r = new HashMap<>();
-    Map<Integer, AccountInfo> cc = new HashMap<>();
-    for (LabelInfo label : Natives.asList(info.allLabels().values())) {
-      if (label.all() != null) {
-        for (ApprovalInfo ai : Natives.asList(label.all())) {
-          (ai.value() != 0 ? r : cc).put(ai._accountId(), ai);
-        }
-      }
-    }
+    Map<ReviewerState, List<AccountInfo>> reviewers = info.reviewers();
+    Map<Integer, AccountInfo> r = byAccount(reviewers, ReviewerState.REVIEWER);
+    Map<Integer, AccountInfo> cc = byAccount(reviewers, ReviewerState.CC);
     for (Integer i : r.keySet()) {
       cc.remove(i);
     }
     cc.remove(info.owner()._accountId());
-
-    Set<Integer> removable = new HashSet<>();
-    if (info.removableReviewers() != null) {
-      for (AccountInfo a : Natives.asList(info.removableReviewers())) {
-        removable.add(a._accountId());
-      }
-    }
-
+    Set<Integer> removable = info.removableReviewerIds();
     Map<Integer, VotableInfo> votable = votable(info);
 
     SafeHtml rHtml = Labels.formatUserList(style,
-        r.values(), removable, votable);
+        r.values(), removable, null, votable);
     SafeHtml ccHtml = Labels.formatUserList(style,
-        cc.values(), removable, votable);
+        cc.values(), removable, null, votable);
 
     reviewersText.setInnerSafeHtml(rHtml);
     ccText.setInnerSafeHtml(ccHtml);
@@ -236,6 +235,19 @@
     }
   }
 
+  private static Map<Integer, AccountInfo> byAccount(
+      Map<ReviewerState, List<AccountInfo>> reviewers, ReviewerState state) {
+    List<AccountInfo> accounts = reviewers.get(state);
+    if (accounts == null) {
+      return Collections.emptyMap();
+    }
+    Map<Integer, AccountInfo> result = new HashMap<>();
+    for (AccountInfo a : accounts) {
+      result.put(a._accountId(), a);
+    }
+    return result;
+  }
+
   private static Map<Integer, VotableInfo> votable(ChangeInfo change) {
     Map<Integer, VotableInfo> d = new HashMap<>();
     for (String name : change.labels()) {
@@ -281,7 +293,7 @@
       return Natives.keys(_approvals());
     }
     final native String approval(String l) /*-{ return this.approvals[l]; }-*/;
-    private final native NativeMap<NativeString> _approvals() /*-{ return this.approvals; }-*/;
+    private native NativeMap<NativeString> _approvals() /*-{ return this.approvals; }-*/;
 
     protected ReviewerInfo() {
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
index 22e35e2..cf506e5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.ui.xml
@@ -19,26 +19,9 @@
     xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
     xmlns:g='urn:import:com.google.gwt.user.client.ui'
     xmlns:u='urn:import:com.google.gerrit.client.ui'>
+  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
   <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
   <ui:style gss='false'>
-    button.openAdd {
-      margin: 3px 3px 0 0;
-      float: right;
-      color: rgba(0, 0, 0, 0.15);
-      background-color: #f5f5f5;
-      background-image: none;
-      -webkit-border-radius: 2px;
-      -moz-border-radius: 2px;
-      border-radius: 2px;
-      -webkit-box-sizing: content-box;
-      -moz-box-sizing: content-box;
-      box-sizing: content-box;
-    }
-    button.openAdd div {
-      width: auto;
-      color: #444;
-    }
-
     .suggestBox {
       margin-bottom: 2px;
     }
@@ -48,21 +31,19 @@
       font-weight: bold;
     }
 
+    .addReviewer,
     .cancel {
+      cursor: pointer;
       float: right;
     }
   </ui:style>
   <g:HTMLPanel>
     <div>
       <span ui:field='reviewersText'/>
-      <g:Button ui:field='openForm'
-         title='Add reviewers to this change'
-         styleName='{res.style.button}'
-         addStyleNames='{style.openAdd}'
-         visible='false'>
-       <ui:attribute name='title'/>
-       <div><ui:msg>Add&#8230;</ui:msg></div>
-      </g:Button>
+      <g:Image ui:field='addReviewerIcon'
+          resource='{ico.addUser}'
+          styleName='{style.addReviewer}'
+          title='Add Reviewer'/>
     </div>
     <div ui:field='form' style='display: none' aria-hidden='true'>
       <u:RemoteSuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
index bd9bec3..025668f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
@@ -83,10 +83,9 @@
 
   private void initTopicLink(ChangeInfo info) {
     if (info.topic() != null && !info.topic().isEmpty()) {
-      text.setText(info.topic());
-      text.setTargetHistoryToken(
-          PageLinks.toChangeQuery(
-              PageLinks.op("topic", info.topic())));
+      String topic = info.topic();
+      text.setText(topic);
+      text.setTargetHistoryToken(PageLinks.topicQuery(info.status(), topic));
     }
   }
 
@@ -130,9 +129,7 @@
         new GerritCallback<String>() {
           @Override
           public void onSuccess(String result) {
-            Gerrit.display(PageLinks.toChange(
-                psId.getParentKey(),
-                String.valueOf(psId.get())));
+            Gerrit.display(PageLinks.toChange(psId));
           }
         });
     onCancel(null);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml
index e7e24b4..c2a6fbd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.ui.xml
@@ -22,11 +22,11 @@
   <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
   <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
   <ui:style gss='false'>
-    .show { cursor: pointer; }
+    .edit { cursor: pointer; }
     .edit, .cancel { float: right; }
   </ui:style>
   <g:HTMLPanel>
-    <div ui:field='show' styleName='{style.show}'>
+    <div ui:field='show'>
       <x:InlineHyperlink ui:field='text'
           title='Search for changes on this topic'/>
       <g:Image ui:field='editIcon'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index f53ecf8..62c14cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -95,7 +95,7 @@
   }
 
   private static String queryIncoming(String who) {
-    return "is:open reviewer:" + who + " -owner:" + who;
+    return "is:open reviewer:" + who + " -owner:" + who + " -star:ignore";
   }
 
   private static String queryClosed(String who) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index 9cafeec..b181341 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -97,6 +97,14 @@
     return call(id, "detail");
   }
 
+  public static RestApi blame(PatchSet.Id id, String path, boolean base) {
+    return revision(id)
+        .view("files")
+        .id(path)
+        .view("blame")
+        .addParameter("base", base);
+  }
+
   public static RestApi actions(int id, String revision) {
     if (revision == null || revision.equals("")) {
       revision = "current";
@@ -155,6 +163,10 @@
         .addParameter("n", n);
   }
 
+  public static RestApi vote(int id, int reviewer, String vote) {
+    return reviewer(id, reviewer).view("votes").id(vote);
+  }
+
   public static RestApi reviewer(int id, int reviewer) {
     return change(id).view("reviewers").id(reviewer);
   }
@@ -166,7 +178,7 @@
   public static RestApi hashtags(int changeId) {
     return change(changeId).view("hashtags");
   }
-  public static RestApi hashtag(int changeId, String hashtag){
+  public static RestApi hashtag(int changeId, String hashtag) {
     return change(changeId).view("hashtags").id(hashtag);
   }
 
@@ -188,8 +200,7 @@
 
   /** Submit a specific revision of a change. */
   public static void submit(int id, String commit, AsyncCallback<SubmitInfo> cb) {
-    SubmitInput in = SubmitInput.create();
-    in.waitForMerge(true);
+    JavaScriptObject in = JavaScriptObject.createObject();
     call(id, commit, "submit").post(in, cb);
   }
 
@@ -283,17 +294,6 @@
     }
   }
 
-  private static class SubmitInput extends JavaScriptObject {
-    final native void waitForMerge(boolean b) /*-{ this.wait_for_merge=b; }-*/;
-
-    static SubmitInput create() {
-      return (SubmitInput) createObject();
-    }
-
-    protected SubmitInput() {
-    }
-  }
-
   private static RestApi call(int id, String action) {
     return change(id).view(action);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index 1fb997f..b2334d1d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -33,18 +33,10 @@
   String outgoingReviews();
   String recentlyClosed();
 
-  String starredHeading();
-  String watchedHeading();
-  String draftsHeading();
-  String allOpenChanges();
-  String allAbandonedChanges();
-  String allMergedChanges();
-
   String changeTableColumnSubject();
   String changeTableColumnSize();
   String changeTableColumnStatus();
   String changeTableColumnOwner();
-  String changeTableColumnReviewers();
   String changeTableColumnProject();
   String changeTableColumnBranch();
   String changeTableColumnLastUpdate();
@@ -57,9 +49,6 @@
   String changeTablePagePrev();
   String changeTablePageNext();
   String upToChangeList();
-  String expandCollapseDependencies();
-  String previousPatchSet();
-  String nextPatchSet();
   String keyReloadChange();
   String keyNextPatchSet();
   String keyPreviousPatchSet();
@@ -73,87 +62,27 @@
   String patchTableColumnName();
   String patchTableColumnComments();
   String patchTableColumnSize();
-  String patchTableColumnDiff();
-  String patchTableDiffSideBySide();
-  String patchTableDiffUnified();
-  String patchTableDownloadPreImage();
-  String patchTableDownloadPostImage();
-  String patchTableBinary();
   String commitMessage();
-  String fileCommentHeader();
 
   String patchTablePrev();
   String patchTableNext();
   String patchTableOpenDiff();
-  String patchTableOpenUnifiedDiff();
-  String upToChangeIconLink();
-  String prevPatchLinkIcon();
-  String nextPatchLinkIcon();
 
-  String changeScreenIncludedIn();
-  String changeScreenDependencies();
-  String changeScreenDependsOn();
-  String changeScreenNeededBy();
-  String changeScreenComments();
-  String changeScreenAddComment();
-
-  String approvalTableReviewer();
-  String approvalTableAddReviewer();
-  String approvalTableRemoveNotPermitted();
-  String approvalTableCouldNotRemove();
   String approvalTableAddReviewerHint();
   String approvalTableAddManyReviewersConfirmationDialogTitle();
 
-  String changeInfoBlockOwner();
-  String changeInfoBlockProject();
-  String changeInfoBlockBranch();
-  String changeInfoBlockTopic();
-  String changeInfoBlockTopicAlterTopicToolTip();
   String changeInfoBlockUploaded();
   String changeInfoBlockUpdated();
-  String changeInfoBlockStatus();
-  String changeInfoBlockSubmitType();
-  String changePermalink();
-  String changeInfoBlockCanMerge();
-  String changeInfoBlockCanMergeYes();
-  String changeInfoBlockCanMergeNo();
-
-  String buttonAlterTopic();
-  String buttonAlterTopicBegin();
-  String buttonAlterTopicSend();
-  String buttonAlterTopicCancel();
-  String headingAlterTopicMessage();
-  String alterTopicTitle();
-  String alterTopicLabel();
-
-  String includedInTableBranch();
-  String includedInTableTag();
 
   String messageNoAuthor();
-  String messageExpandMostRecent();
-  String messageExpandRecent();
-  String messageExpandAll();
-  String messageCollapseAll();
-  String messageNeedsRebaseOrHasDependency();
 
   String sideBySide();
   String unifiedDiff();
 
-  String patchSetInfoAuthor();
-  String patchSetInfoCommitter();
-  String patchSetInfoDownload();
-  String patchSetInfoParents();
-  String patchSetWithDraftCommentsToolTip();
-  String initialCommit();
-
-  String buttonRebaseChange();
-
-  String buttonRevertChangeBegin();
   String buttonRevertChangeSend();
   String headingRevertMessage();
   String revertChangeTitle();
 
-  String buttonCherryPickChangeBegin();
   String buttonCherryPickChangeSend();
   String headingCherryPickBranch();
   String cherryPickCommitMessage();
@@ -165,41 +94,13 @@
   String rebasePlaceholderMessage();
   String rebaseTitle();
 
-  String buttonAbandonChangeBegin();
-  String buttonAbandonChangeSend();
-  String headingAbandonMessage();
-  String abandonChangeTitle();
-  String referenceVersion();
   String baseDiffItem();
   String autoMerge();
 
-  String buttonReview();
-  String buttonPublishCommentsSend();
-  String buttonPublishCommentsCancel();
-  String headingCoverMessage();
-  String headingPatchComments();
-
-  String buttonRestoreChangeBegin();
-  String restoreChangeTitle();
-  String headingRestoreMessage();
-  String buttonRestoreChangeSend();
-
-  String buttonPublishPatchSet();
-
-  String buttonDeleteDraftChange();
-  String buttonDeleteDraftPatchSet();
-
   String pagedChangeListPrev();
   String pagedChangeListNext();
 
-  String draftPatchSetLabel();
-
-  String reviewed();
   String submitFailed();
-  String buttonClose();
-
-  String diffAllSideBySide();
-  String diffAllUnified();
 
   String votable();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index a5fa7b4..b7e2677 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -8,23 +8,16 @@
 notCurrent = Not Current
 changeEdit = Change Edit
 
-starredHeading = Starred Changes
-watchedHeading = Open Changes of Watched Projects
-draftsHeading = Changes with unpublished drafts
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
 incomingReviews = Incoming reviews
 outgoingReviews = Outgoing reviews
 recentlyClosed = Recently closed
-allOpenChanges = All open changes
-allAbandonedChanges = All abandoned changes
-allMergedChanges = All merged changes
 
 changeTableColumnSubject = Subject
 changeTableColumnSize = Size
 changeTableColumnStatus = Status
 changeTableColumnOwner = Owner
-changeTableColumnReviewers = Reviewers
 changeTableColumnProject = Project
 changeTableColumnBranch = Branch
 changeTableColumnLastUpdate = Updated
@@ -37,9 +30,6 @@
 changeTablePagePrev = Previous page of changes
 changeTablePageNext = Next page of changes
 upToChangeList = Up to change list
-expandCollapseDependencies = Expands / Collapses dependencies section
-previousPatchSet = Previous patch set
-nextPatchSet = Next patch set
 keyReloadChange = Reload change
 keyNextPatchSet = Next patch set
 keyPreviousPatchSet = Previous patch set
@@ -54,92 +44,30 @@
 patchTableColumnName = File Path
 patchTableColumnComments = Comments
 patchTableColumnSize = Size
-patchTableColumnDiff = Diff
-patchTableDiffSideBySide = Side-by-Side
-patchTableDiffUnified = Unified
-patchTableDownloadPreImage = old
-patchTableDownloadPostImage = new
-patchTableBinary = Binary
 commitMessage = Commit Message
-fileCommentHeader = File Comment:
 
 patchTablePrev = Previous file
 patchTableNext = Next file
 patchTableOpenDiff = Open diff
-patchTableOpenUnifiedDiff = Open unified diff
 
-changeScreenIncludedIn =  Included in
-changeScreenDependencies =  Dependencies
-changeScreenDependsOn = Depends On
-changeScreenNeededBy = Needed By
-changeScreenComments = Comments
-changeScreenAddComment = Add Comment
-
-approvalTableReviewer = Reviewer
-approvalTableAddReviewer = Add Reviewer
-approvalTableRemoveNotPermitted = Not allowed to remove reviewer
-approvalTableCouldNotRemove = Could not remove reviewer
 approvalTableAddReviewerHint = Name or Email or Group
 approvalTableAddManyReviewersConfirmationDialogTitle = Adding Group Members as Reviewers
 
-changeInfoBlockOwner = Owner
-changeInfoBlockProject = Project
-changeInfoBlockBranch = Branch
-changeInfoBlockTopic = Topic
-changeInfoBlockTopicAlterTopicToolTip = Edit Topic
 changeInfoBlockUploaded = Uploaded
 changeInfoBlockUpdated = Updated
-changeInfoBlockStatus = Status
-changeInfoBlockSubmitType = Submit Type
-changePermalink = Permalink
-changeInfoBlockCanMerge = Can Merge
-changeInfoBlockCanMergeYes = Yes
-changeInfoBlockCanMergeNo = No
-
-buttonAlterTopic = Edit Topic
-buttonAlterTopicBegin = Edit Topic
-buttonAlterTopicSend = Update Topic
-buttonAlterTopicCancel = Cancel
-headingAlterTopicMessage = Edit Topic Message:
-alterTopicTitle = Code Review - Edit Topic
-alterTopicLabel = New Topic Name:
-
-includedInTableBranch = Branch Name
-includedInTableTag = Tag Name
 
 messageNoAuthor = Gerrit Code Review
-messageExpandMostRecent = Expand Most Recent
-messageExpandRecent = Expand Recent
-messageExpandAll = Expand All
-messageCollapseAll = Collapse All
-messageNeedsRebaseOrHasDependency = Need Rebase or Has Dependency
 
 sideBySide = Side by Side
 unifiedDiff = Unified Diff
 
-patchSetInfoAuthor = Author
-patchSetInfoCommitter = Committer
-patchSetInfoDownload = Download
-patchSetInfoParents = Parent(s)
-patchSetWithDraftCommentsToolTip = Draft comment(s) inside
-initialCommit = Initial Commit
-
-buttonAbandonChangeBegin = Abandon Change
-buttonAbandonChangeSend = Abandon Change
-headingAbandonMessage = Abandon Message:
-abandonChangeTitle = Code Review - Abandon Change
-referenceVersion = Reference Version:
 baseDiffItem = Base
 autoMerge = Auto Merge
 
-buttonRebaseChange = Rebase Change
-
-buttonRevertChangeBegin = Revert Change
 buttonRevertChangeSend = Revert Change
 headingRevertMessage = Revert Commit Message:
 revertChangeTitle = Code Review - Revert Merged Change
 
-buttonCherryPickChangeBegin = Cherry Pick To
 buttonCherryPickChangeSend = Cherry Pick Change
 headingCherryPickBranch = Cherry Pick to Branch:
 cherryPickCommitMessage = Cherry Pick Commit Message:
@@ -151,37 +79,10 @@
 rebasePlaceholderMessage = (subject, change number, or leave empty)
 rebaseTitle = Code Review - Rebase Change
 
-buttonRestoreChangeBegin = Restore Change
-restoreChangeTitle = Code Review - Restore Change
-headingRestoreMessage = Restore Message:
-buttonRestoreChangeSend = Restore Change
-
-buttonReview = Review
-buttonPublishCommentsSend = Publish Comments
-buttonPublishCommentsCancel = Cancel
-headingCoverMessage = Cover Message:
-headingPatchComments = Patch Comments:
-
-buttonPublishPatchSet = Publish
-
-buttonDeleteDraftChange = Delete Draft Change
-buttonDeleteDraftPatchSet = Delete Draft Patch Set
-
 pagedChangeListPrev = &#x21e6;Prev
 pagedChangeListNext = Next&#x21e8;
 
-draftPatchSetLabel = (DRAFT)
-
-upToChangeIconLink = &#x21e7;Up to change
-prevPatchLinkIcon = &#x21e6;
-nextPatchLinkIcon = &#x21e8;
-
-reviewed = Reviewed
 submitFailed = Submit Failed
-buttonClose = Close
-
-diffAllSideBySide = All Side-by-Side
-diffAllUnified = All Unified
 
 votable = Votable:
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
index 0928cd8..f86ddf7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
@@ -28,7 +28,7 @@
 /** REST API helpers to remotely edit a change. */
 public class ChangeEditApi {
   /** Get file (or commit message) contents. */
-  public static void get(PatchSet.Id id, String path,
+  public static void get(PatchSet.Id id, String path, boolean base,
       HttpCallback<NativeString> cb) {
     RestApi api;
     if (id.get() != 0) {
@@ -36,13 +36,19 @@
       // exist for the caller, or is not currently active.
       api = ChangeApi.revision(id).view("files").id(path).view("content");
     } else if (Patch.COMMIT_MSG.equals(path)) {
-      api = editMessage(id.getParentKey().get());
+      api = editMessage(id.getParentKey().get()).addParameter("base", base);
     } else {
-      api = editFile(id.getParentKey().get(), path);
+      api = editFile(id.getParentKey().get(), path).addParameter("base", base);
     }
     api.get(cb);
   }
 
+  /** Get file (or commit message) contents of the edit. */
+  public static void get(PatchSet.Id id, String path,
+      HttpCallback<NativeString> cb) {
+    get(id, path, false, cb);
+  }
+
   /** Get meta info for change edit. */
   public static void getMeta(PatchSet.Id id, String path,
       AsyncCallback<EditFileInfo> cb) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
index ef74a65..b192bd5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
@@ -18,49 +18,30 @@
 
 public interface ChangeMessages extends Messages {
   String accountDashboardTitle(String fullName);
-  String changesOpenInProject(String string);
-  String changesMergedInProject(String string);
-  String changesAbandonedInProject(String string);
 
   String revertChangeDefaultMessage(String commitMsg, String commitId);
 
   String cherryPickedChangeDefaultMessage(String commitMsg, String commitId);
   String changeScreenTitleId(String changeId);
-  String outdatedHeader(int outdated);
-  String patchSetHeader(int id);
   String loadingPatchSet(int id);
-  String submitPatchSet(int id);
 
-  String patchTableComments(@PluralCount int count);
-  String patchTableDrafts(@PluralCount int count);
   String patchTableSize_Modify(int insertions, int deletions);
   String patchTableSize_ModifyBinaryFiles(String bytesInserted,
       String bytesDeleted);
+  String patchTableSize_ModifyBinaryFilesWithPercentages(String bytesInserted,
+      String percentageInserted, String bytesDeleted, String percentageDeleted);
   String patchTableSize_LongModify(int insertions, int deletions);
-  String patchTableSize_Lines(@PluralCount int insertions);
 
-  String removeHashtag(String name);
   String removeReviewer(String fullName);
-  String messageWrittenOn(String date);
+  String removeVote(String label);
 
-  String renamedFrom(String sourcePath);
-  String copiedFrom(String sourcePath);
-  String otherFrom(String sourcePath);
-
-  String needApproval(String labelName);
-  String publishComments(String changeId, int ps);
-  String lineHeader(int line);
+  String blockedOn(String labelName);
+  String needs(String labelName);
 
   String changeQueryWindowTitle(String query);
   String changeQueryPageTitle(String query);
 
-  String reviewerNotFound(String who);
-  String accountInactive(String who);
-  String changeNotVisibleTo(String who);
-  String groupIsEmpty(String group);
-  String groupIsNotAllowed(String group);
-  String groupHasTooManyMembers(String group);
-  String groupManyMembersConfirmation(String group, int memberCount);
-
   String insertionsAndDeletions(int insertions, int deletions);
+
+  String diffBaseParent(int parentNum);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
index 67ef2c3..2b68492 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
@@ -1,47 +1,27 @@
 # Changes to this file should also be made in
 # gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
 accountDashboardTitle = Code Review Dashboard for {0}
-changesOpenInProject = Open Changes In {0}
-changesMergedInProject = Merged Changes In {0}
-changesAbandonedInProject = Abandoned Changes In {0}
 
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
 cherryPickedChangeDefaultMessage = {0}\n(cherry picked from commit {1})
 
 changeScreenTitleId = Change {0}
-outdatedHeader = Change depends on {0} outdated change(s) and should be rebased on the latest patch sets.
-patchSetHeader = Patch Set {0}
 loadingPatchSet = Loading Patch Set {0} ...
-submitPatchSet = Submit Patch Set {0}
 
-patchTableComments = {0} comments
-patchTableDrafts = {0} drafts
 patchTableSize_Modify = +{0}, -{1}
 patchTableSize_ModifyBinaryFiles = +{0}, -{1}
+patchTableSize_ModifyBinaryFilesWithPercentages = +{0} (+{1}), -{2} (-{3})
 patchTableSize_LongModify = {0} inserted, {1} deleted
-patchTableSize_Lines = {0} lines
 
-removeHashtag = Remove hashtag {0}
 removeReviewer = Remove reviewer {0}
-messageWrittenOn = on {0}
+removeVote = Remove vote {0}
 
-renamedFrom = renamed from {0}
-copiedFrom = copied from {0}
-otherFrom = from {0}
-
-needApproval = Need {0}
-publishComments = Change {0} - Patch Set {1}: Publish Comments
-lineHeader = Line {0}:
+blockedOn = Blocked on {0} Label
+needs = Needs {0} Label
 
 changeQueryWindowTitle = {0}
 changeQueryPageTitle = Search for {0}
 
-reviewerNotFound = {0} is neither a registered user nor a group.
-accountInactive = {0} is not an active user.
-changeNotVisibleTo = {0} cannot access the change.
-groupIsEmpty = The group {0} does not have any members to add as reviewers.
-groupIsNotAllowed =  The group {0} cannot be added as reviewer.
-groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
-groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
-
 insertionsAndDeletions = +{0}, -{1}
+
+diffBaseParent = Parent {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages_en.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages_en.properties
deleted file mode 100644
index d9c7059..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages_en.properties
+++ /dev/null
@@ -1,8 +0,0 @@
-patchTableComments[one] = 1 comment
-patchTableComments = {0} comments
-
-patchTableDrafts[one] = 1 draft
-patchTableDrafts = {0} drafts
-
-patchTableSize_Lines[one] = 1 line
-patchTableSize_Lines = {0} lines
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index 6136825..9c78955 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.client.ui.NeedsSignInKeyCommand;
 import com.google.gerrit.client.ui.ProjectLink;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -350,6 +350,7 @@
         return accountInfo.username();
       case ABBREV:
         return getAbbreviation(accountInfo.name(), " ");
+      case NONE:
       default:
         return null;
     }
@@ -492,7 +493,7 @@
       if (titleText != null) {
         setTitleText(titleText);
         return true;
-      } else if(titleWidget != null) {
+      } else if (titleWidget != null) {
         setTitleWidget(titleWidget);
         return true;
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
index 9088c1c..d42c344 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
@@ -25,9 +25,15 @@
 public class CommentInfo extends JavaScriptObject {
   public static CommentInfo create(String path, Side side,
       int line, CommentRange range) {
+    return create(path, side, 0, line, range);
+  }
+
+  public static CommentInfo create(String path, Side side, int parent,
+      int line, CommentRange range) {
     CommentInfo n = createObject().cast();
     n.path(path);
     n.side(side);
+    n.parent(parent);
     if (range != null) {
       n.line(range.endLine());
       n.range(range);
@@ -41,6 +47,7 @@
     CommentInfo n = createObject().cast();
     n.path(r.path());
     n.side(r.side());
+    n.parent(r.parent());
     n.inReplyTo(r.id());
     if (r.hasRange()) {
       n.line(r.range().endLine());
@@ -55,6 +62,7 @@
     CommentInfo n = createObject().cast();
     n.path(s.path());
     n.side(s.side());
+    n.parent(s.parent());
     n.id(s.id());
     n.inReplyTo(s.inReplyTo());
     n.message(s.message());
@@ -77,7 +85,9 @@
   public final void side(Side side) {
     sideRaw(side.toString());
   }
-  private final native void sideRaw(String s) /*-{ this.side = s }-*/;
+  private native void sideRaw(String s) /*-{ this.side = s }-*/;
+  public final native void parent(int n) /*-{ this.parent = n }-*/;
+  public final native boolean hasParent() /*-{ return this.hasOwnProperty('parent') }-*/;
 
   public final native String path() /*-{ return this.path }-*/;
   public final native String id() /*-{ return this.id }-*/;
@@ -90,7 +100,8 @@
         ? Side.valueOf(s)
         : Side.REVISION;
   }
-  private final native String sideRaw() /*-{ return this.side }-*/;
+  private native String sideRaw() /*-{ return this.side }-*/;
+  public final native int parent() /*-{ return this.parent }-*/;
 
   public final Timestamp updated() {
     Timestamp r = updatedTimestamp();
@@ -103,9 +114,9 @@
     }
     return r;
   }
-  private final native String updatedRaw() /*-{ return this.updated }-*/;
-  private final native Timestamp updatedTimestamp() /*-{ return this._ts }-*/;
-  private final native void updatedTimestamp(Timestamp t) /*-{ this._ts = t }-*/;
+  private native String updatedRaw() /*-{ return this.updated }-*/;
+  private native Timestamp updatedTimestamp() /*-{ return this._ts }-*/;
+  private native void updatedTimestamp(Timestamp t) /*-{ this._ts = t }-*/;
 
   public final native AccountInfo author() /*-{ return this.author }-*/;
   public final native int line() /*-{ return this.line || 0 }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
index 2b3c6ae..80117be 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
@@ -20,11 +20,23 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gwt.regexp.shared.RegExp;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwtorm.client.KeyUtil;
 
 public class QueryScreen extends PagedSingleListScreen implements
     ChangeListScreen {
+  // Legacy numeric identifier.
+  private static final RegExp NUMERIC_ID = RegExp.compile("^[1-9][0-9]*$");
+  // Commit SHA1 hash
+  private static final RegExp COMMIT_SHA1 =
+      RegExp.compile("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
+  // Change-Id
+  private static final String ID_PATTERN = "[iI][0-9a-f]{4,}$";
+  private static final RegExp CHANGE_ID = RegExp.compile("^" + ID_PATTERN);
+  private static final RegExp CHANGE_ID_TRIPLET =
+      RegExp.compile("^(.)+~(.)+~" + ID_PATTERN);
+
   public static QueryScreen forQuery(String query) {
     return forQuery(query, 0);
   }
@@ -51,7 +63,7 @@
   protected AsyncCallback<ChangeList> loadCallback() {
     return new GerritCallback<ChangeList>() {
       @Override
-      public final void onSuccess(ChangeList result) {
+      public void onSuccess(ChangeList result) {
         if (isAttached()) {
           if (result.length() == 1 && isSingleQuery(query)) {
             ChangeInfo c = result.get(0);
@@ -80,24 +92,9 @@
   }
 
   private static boolean isSingleQuery(String query) {
-    if (query.matches("^[1-9][0-9]*$")) {
-      // Legacy numeric identifier.
-      //
-      return true;
-    }
-
-    if (query.matches("^[iI][0-9a-f]{4,}$")) {
-      // Newer style Change-Id.
-      //
-      return true;
-    }
-
-    if (query.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
-      // Commit SHA-1 of any change.
-      //
-      return true;
-    }
-
-    return false;
+    return NUMERIC_ID.test(query)
+        || CHANGE_ID.test(query)
+        || CHANGE_ID_TRIPLET.test(query)
+        || COMMIT_SHA1.test(query);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
index 096dbd0..a127e7f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
@@ -19,11 +19,11 @@
 import com.google.gwt.core.client.JsArray;
 
 public class ReviewInput extends JavaScriptObject {
-  public static enum NotifyHandling {
+  public enum NotifyHandling {
     NONE, OWNER, OWNER_REVIEWERS, ALL
   }
 
-  public static enum DraftHandling {
+  public enum DraftHandling {
     DELETE, PUBLISH, KEEP, PUBLISH_ALL_REVISIONS
   }
 
@@ -42,14 +42,14 @@
   public final void notify(NotifyHandling e) {
     _notify(e.name());
   }
-  private final native void _notify(String n) /*-{ this.notify=n; }-*/;
+  private native void _notify(String n) /*-{ this.notify=n; }-*/;
 
   public final void drafts(DraftHandling e) {
     _drafts(e.name());
   }
-  private final native void _drafts(String n) /*-{ this.drafts=n; }-*/;
+  private native void _drafts(String n) /*-{ this.drafts=n; }-*/;
 
-  private final native void init() /*-{
+  private native void init() /*-{
     this.labels = {};
     this.strict_labels = true;
   }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
index be64a3d..43b3b80 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
@@ -37,8 +37,8 @@
   private static final Event.Type<ChangeStarHandler> TYPE = new Event.Type<>();
 
   /** Handler that can receive notifications of a change's starred status. */
-  public static interface ChangeStarHandler {
-    public void onChangeStar(ChangeStarEvent event);
+  public interface ChangeStarHandler {
+    void onChangeStar(ChangeStarEvent event);
   }
 
   /** Event fired when a star changes status. The new status is reported. */
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
index 6d8bc30..7a24774 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
@@ -22,7 +22,7 @@
     return Change.Status.valueOf(statusRaw());
   }
 
-  private final native String statusRaw() /*-{ return this.status; }-*/;
+  private native String statusRaw() /*-{ return this.status; }-*/;
 
   protected SubmitInfo() {
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
index 28812ac..47393a7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.client.config;
 
 import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.info.ServerInfo;
 import com.google.gerrit.client.info.TopMenuList;
 import com.google.gerrit.client.rpc.NativeMap;
@@ -37,7 +37,7 @@
     new RestApi("/config/server/top-menus").get(cb);
   }
 
-  public static void defaultPreferences(AsyncCallback<AccountPreferencesInfo> cb) {
+  public static void defaultPreferences(AsyncCallback<GeneralPreferences> cb) {
     new RestApi("/config/server/preferences").get(cb);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
index 3b168e1..47c0359 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
@@ -84,7 +84,7 @@
     });
 
     String ref = null;
-    for(DashboardInfo d : list) {
+    for (DashboardInfo d : list) {
       if (!d.ref().equals(ref)) {
         ref = d.ref();
         insertTitleRow(table.getRowCount(), ref);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
index 8ff11e8..5257ae0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
@@ -15,230 +15,63 @@
 package com.google.gerrit.client.diff;
 
 import static com.google.gerrit.client.diff.DisplaySide.A;
-import static com.google.gerrit.client.diff.DisplaySide.B;
 
-import com.google.gerrit.client.diff.DiffInfo.Region;
-import com.google.gerrit.client.diff.DiffInfo.Span;
-import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.EventListener;
 
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineClassWhere;
-import net.codemirror.lib.Configuration;
-import net.codemirror.lib.LineWidget;
 import net.codemirror.lib.Pos;
 import net.codemirror.lib.TextMarker;
 
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
-/** Colors modified regions for {@link SideBySide}. */
-class ChunkManager {
-  private static final String DATA_LINES = "_cs2h";
-  private static double guessedLineHeightPx = 15;
-  private static final JavaScriptObject focusA = initOnClick(A);
-  private static final JavaScriptObject focusB = initOnClick(B);
-  private static final native JavaScriptObject initOnClick(DisplaySide s) /*-{
-    return $entry(function(e){
-      @com.google.gerrit.client.diff.ChunkManager::focus(
-        Lcom/google/gwt/dom/client/NativeEvent;
-        Lcom/google/gerrit/client/diff/DisplaySide;)(e,s)
-    });
-  }-*/;
-
-  private static void focus(NativeEvent event, DisplaySide side) {
-    Element e = Element.as(event.getEventTarget());
-    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
-      EventListener l = DOM.getEventListener(e);
-      if (l instanceof SideBySide) {
-        ((SideBySide) l).getCmFromSide(side).focus();
-        event.stopPropagation();
-      }
-    }
-  }
-
-  static void focusOnClick(Element e, DisplaySide side) {
-    onClick(e, side == A ? focusA : focusB);
-  }
-
-  private static final native void onClick(Element e, JavaScriptObject f)
+/** Colors modified regions for {@link SideBySide} and {@link Unified}. */
+abstract class ChunkManager {
+  static final native void onClick(Element e, JavaScriptObject f)
   /*-{ e.onclick = f }-*/;
 
-  private final SideBySide host;
-  private final CodeMirror cmA;
-  private final CodeMirror cmB;
-  private final Scrollbar scrollbar;
-  private final LineMapper mapper;
+  final Scrollbar scrollbar;
+  final LineMapper lineMapper;
 
-  private List<DiffChunkInfo> chunks;
   private List<TextMarker> markers;
   private List<Runnable> undo;
-  private List<LineWidget> padding;
-  private List<Element> paddingDivs;
 
-  ChunkManager(SideBySide host,
-      CodeMirror cmA,
-      CodeMirror cmB,
-      Scrollbar scrollbar) {
-    this.host = host;
-    this.cmA = cmA;
-    this.cmB = cmB;
+  ChunkManager(Scrollbar scrollbar) {
     this.scrollbar = scrollbar;
-    this.mapper = new LineMapper();
+    this.lineMapper = new LineMapper();
   }
 
-  LineMapper getLineMapper() {
-    return mapper;
-  }
+  abstract DiffChunkInfo getFirst();
 
-  DiffChunkInfo getFirst() {
-    return !chunks.isEmpty() ? chunks.get(0) : null;
+  List<TextMarker> getMarkers() {
+    return markers;
   }
 
   void reset() {
-    mapper.reset();
+    lineMapper.reset();
     for (TextMarker m : markers) {
       m.clear();
     }
     for (Runnable r : undo) {
       r.run();
     }
-    for (LineWidget w : padding) {
-      w.clear();
-    }
   }
 
-  void render(DiffInfo diff) {
-    chunks = new ArrayList<>();
+  abstract void render(DiffInfo diff);
+
+  void render() {
     markers = new ArrayList<>();
     undo = new ArrayList<>();
-    padding = new ArrayList<>();
-    paddingDivs = new ArrayList<>();
-
-    String diffColor = diff.metaA() == null || diff.metaB() == null
-        ? DiffTable.style.intralineBg()
-        : DiffTable.style.diff();
-
-    for (Region current : Natives.asList(diff.content())) {
-      if (current.ab() != null) {
-        mapper.appendCommon(current.ab().length());
-      } else if (current.skip() > 0) {
-        mapper.appendCommon(current.skip());
-      } else if (current.common()) {
-        mapper.appendCommon(current.b().length());
-      } else {
-        render(current, diffColor);
-      }
-    }
-
-    if (paddingDivs.isEmpty()) {
-      paddingDivs = null;
-    }
   }
 
-  void adjustPadding() {
-    if (paddingDivs != null) {
-      double h = cmB.extras().lineHeightPx();
-      for (Element div : paddingDivs) {
-        int lines = div.getPropertyInt(DATA_LINES);
-        div.getStyle().setHeight(lines * h, Unit.PX);
-      }
-      for (LineWidget w : padding) {
-        w.changed();
-      }
-      paddingDivs = null;
-      guessedLineHeightPx = h;
-    }
-  }
-
-  private void render(Region region, String diffColor) {
-    int startA = mapper.getLineA();
-    int startB = mapper.getLineB();
-
-    JsArrayString a = region.a();
-    JsArrayString b = region.b();
-    int aLen = a != null ? a.length() : 0;
-    int bLen = b != null ? b.length() : 0;
-
-    String color = a == null || b == null
-        ? diffColor
-        : DiffTable.style.intralineBg();
-
-    colorLines(cmA, color, startA, aLen);
-    colorLines(cmB, color, startB, bLen);
-    markEdit(cmA, startA, a, region.editA());
-    markEdit(cmB, startB, b, region.editB());
-    addPadding(cmA, startA + aLen - 1, bLen - aLen);
-    addPadding(cmB, startB + bLen - 1, aLen - bLen);
-    addGutterTag(region, startA, startB);
-    mapper.appendReplace(aLen, bLen);
-
-    int endA = mapper.getLineA() - 1;
-    int endB = mapper.getLineB() - 1;
-    if (aLen > 0) {
-      addDiffChunk(cmB, endA, aLen, bLen > 0);
-    }
-    if (bLen > 0) {
-      addDiffChunk(cmA, endB, bLen, aLen > 0);
-    }
-  }
-
-  private void addGutterTag(Region region, int startA, int startB) {
-    if (region.a() == null) {
-      scrollbar.insert(cmB, startB, region.b().length());
-    } else if (region.b() == null) {
-      scrollbar.delete(cmA, cmB, startA, region.a().length());
-    } else {
-      scrollbar.edit(cmB, startB, region.b().length());
-    }
-  }
-
-  private void markEdit(CodeMirror cm, int startLine,
-      JsArrayString lines, JsArray<Span> edits) {
-    if (lines == null || edits == null) {
-      return;
-    }
-
-    EditIterator iter = new EditIterator(lines, startLine);
-    Configuration bg = Configuration.create()
-        .set("className", DiffTable.style.intralineBg())
-        .set("readOnly", true);
-
-    Configuration diff = Configuration.create()
-        .set("className", DiffTable.style.diff())
-        .set("readOnly", true);
-
-    Pos last = Pos.create(0, 0);
-    for (Span span : Natives.asList(edits)) {
-      Pos from = iter.advance(span.skip());
-      Pos to = iter.advance(span.mark());
-      if (from.line() == last.line()) {
-        markers.add(cm.markText(last, from, bg));
-      } else {
-        markers.add(cm.markText(Pos.create(from.line(), 0), from, bg));
-      }
-      markers.add(cm.markText(from, to, diff));
-      last = to;
-      colorLines(cm, LineClassWhere.BACKGROUND,
-          DiffTable.style.diff(),
-          from.line(), to.line());
-    }
-  }
-
-  private void colorLines(CodeMirror cm, String color, int line, int cnt) {
+  void colorLines(CodeMirror cm, String color, int line, int cnt) {
     colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
   }
 
-  private void colorLines(final CodeMirror cm, final LineClassWhere where,
+  void colorLines(final CodeMirror cm, final LineClassWhere where,
       final String className, final int start, final int end) {
     if (start < end) {
       for (int line = start; line < end; line++) {
@@ -255,78 +88,38 @@
     }
   }
 
-  /**
-   * Insert a new padding div below the given line.
-   *
-   * @param cm parent CodeMirror to add extra space into.
-   * @param line line to put the padding below.
-   * @param len number of lines to pad. Padding is inserted only if
-   *        {@code len >= 1}.
-   */
-  private void addPadding(CodeMirror cm, int line, final int len) {
-    if (0 < len) {
-      Element pad = DOM.createDiv();
-      pad.setClassName(DiffTable.style.padding());
-      pad.setPropertyInt(DATA_LINES, len);
-      pad.getStyle().setHeight(guessedLineHeightPx * len, Unit.PX);
-      focusOnClick(pad, cm.side());
-      paddingDivs.add(pad);
-      padding.add(cm.addLineWidget(
-        line == -1 ? 0 : line,
-        pad,
-        Configuration.create()
-          .set("coverGutter", true)
-          .set("noHScroll", true)
-          .set("above", line == -1)));
+  abstract Runnable diffChunkNav(final CodeMirror cm, final Direction dir);
+
+  void diffChunkNavHelper(List<? extends DiffChunkInfo> chunks,
+      DiffScreen host, int res, Direction dir) {
+    if (res < 0) {
+      res = -res - (dir == Direction.PREV ? 1 : 2);
     }
-  }
+    res = res + (dir == Direction.PREV ? -1 : 1);
+    if (res < 0 || chunks.size() <= res) {
+      return;
+    }
 
-  private void addDiffChunk(CodeMirror cmToPad, int lineOnOther,
-      int chunkSize, boolean edit) {
-    chunks.add(new DiffChunkInfo(host.otherCm(cmToPad).side(),
-        lineOnOther - chunkSize + 1, lineOnOther, edit));
-  }
-
-  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine()
-            ? cm.getLineNumber(cm.extras().activeLine())
-            : 0;
-        int res = Collections.binarySearch(
-                chunks,
-                new DiffChunkInfo(cm.side(), line, 0, false),
-                getDiffChunkComparator());
-        if (res < 0) {
-          res = -res - (dir == Direction.PREV ? 1 : 2);
-        }
-        res = res + (dir == Direction.PREV ? -1 : 1);
-        if (res < 0 || chunks.size() <= res) {
-          return;
-        }
-
-        DiffChunkInfo lookUp = chunks.get(res);
-        // If edit, skip the deletion chunk and set focus on the insertion one.
-        if (lookUp.isEdit() && lookUp.getSide() == A) {
-          res = res + (dir == Direction.PREV ? -1 : 1);
-          if (res < 0 || chunks.size() <= res) {
-            return;
-          }
-        }
-
-        DiffChunkInfo target = chunks.get(res);
-        CodeMirror targetCm = host.getCmFromSide(target.getSide());
-        targetCm.setCursor(Pos.create(target.getStart(), 0));
-        targetCm.focus();
-        targetCm.scrollToY(
-            targetCm.heightAtLine(target.getStart(), "local") -
-            0.5 * cmB.scrollbarV().getClientHeight());
+    DiffChunkInfo lookUp = chunks.get(res);
+    // If edit, skip the deletion chunk and set focus on the insertion one.
+    if (lookUp.isEdit() && lookUp.getSide() == A) {
+      res = res + (dir == Direction.PREV ? -1 : 1);
+      if (res < 0 || chunks.size() <= res) {
+        return;
       }
-    };
+    }
+
+    DiffChunkInfo target = chunks.get(res);
+    CodeMirror targetCm = host.getCmFromSide(target.getSide());
+    int cmLine = getCmLine(target.getStart(), target.getSide());
+    targetCm.setCursor(Pos.create(cmLine));
+    targetCm.focus();
+    targetCm.scrollToY(
+        targetCm.heightAtLine(cmLine, "local")
+        - 0.5 * targetCm.scrollbarV().getClientHeight());
   }
 
-  private Comparator<DiffChunkInfo> getDiffChunkComparator() {
+  Comparator<DiffChunkInfo> getDiffChunkComparator() {
     // Chunks are ordered by their starting line. If it's a deletion,
     // use its corresponding line on the revision side for comparison.
     // In the edit case, put the deletion chunk right before the
@@ -337,35 +130,17 @@
         if (a.getSide() == b.getSide()) {
           return a.getStart() - b.getStart();
         } else if (a.getSide() == A) {
-          int comp = mapper.lineOnOther(a.getSide(), a.getStart())
+          int comp = lineMapper.lineOnOther(a.getSide(), a.getStart())
               .getLine() - b.getStart();
           return comp == 0 ? -1 : comp;
         } else {
           int comp = a.getStart() -
-              mapper.lineOnOther(b.getSide(), b.getStart()).getLine();
+              lineMapper.lineOnOther(b.getSide(), b.getStart()).getLine();
           return comp == 0 ? 1 : comp;
         }
       }
     };
   }
 
-  DiffChunkInfo getDiffChunk(DisplaySide side, int line) {
-    int res = Collections.binarySearch(
-        chunks,
-        new DiffChunkInfo(side, line, 0, false), // Dummy DiffChunkInfo
-        getDiffChunkComparator());
-    if (res >= 0) {
-      return chunks.get(res);
-    } else { // The line might be within a DiffChunk
-      res = -res - 1;
-      if (res > 0) {
-        DiffChunkInfo info = chunks.get(res - 1);
-        if (info.getSide() == side && info.getStart() <= line &&
-            line <= info.getEnd()) {
-          return info;
-        }
-      }
-    }
-    return null;
-  }
+  abstract int getCmLine(int line, DisplaySide side);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
index 0e85a2f..70ef947 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
@@ -24,6 +24,7 @@
 
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.Configuration;
+import net.codemirror.lib.Pos;
 import net.codemirror.lib.TextMarker;
 import net.codemirror.lib.TextMarker.FromTo;
 
@@ -56,12 +57,18 @@
   CommentBox(CommentGroup group, CommentRange range) {
     this.group = group;
     if (range != null) {
-      fromTo = FromTo.create(range);
+      DiffScreen screen = group.getManager().host;
+      int startCmLine =
+          screen.getCmLine(range.startLine() - 1, group.getSide());
+      int endCmLine = screen.getCmLine(range.endLine() - 1, group.getSide());
+      fromTo = FromTo.create(
+          Pos.create(startCmLine, range.startCharacter()),
+          Pos.create(endCmLine, range.endCharacter()));
       rangeMarker = group.getCm().markText(
           fromTo.from(),
           fromTo.to(),
           Configuration.create()
-              .set("className", DiffTable.style.range()));
+              .set("className", Resources.I.diffTableStyle().range()));
     }
     addDomHandler(new MouseOverHandler() {
       @Override
@@ -109,7 +116,7 @@
             fromTo.from(),
             fromTo.to(),
             Configuration.create()
-                .set("className", DiffTable.style.rangeHighlight()));
+                .set("className", Resources.I.diffTableStyle().rangeHighlight()));
       } else if (!highlight && rangeHighlightMarker != null) {
         rangeHighlightMarker.clear();
         rangeHighlightMarker = null;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
index a8a56fc..1d198ec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.client.diff;
 
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.Style.Unit;
-import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.Timer;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
@@ -30,39 +27,29 @@
 /**
  * LineWidget attached to a CodeMirror container.
  *
- * When a comment is placed on a line a CommentWidget is created on both sides.
- * The group tracks all comment boxes on that same line, and also includes an
- * empty padding element to keep subsequent lines vertically aligned.
+ * When a comment is placed on a line a CommentWidget is created.
  */
-class CommentGroup extends Composite {
-  static void pair(CommentGroup a, CommentGroup b) {
-    a.peer = b;
-    b.peer = a;
-  }
+abstract class CommentGroup extends Composite {
+
+  final DisplaySide side;
+  final int line;
 
   private final CommentManager manager;
   private final CodeMirror cm;
-  private final int line;
   private final FlowPanel comments;
-  private final Element padding;
   private LineWidget lineWidget;
   private Timer resizeTimer;
-  private CommentGroup peer;
 
-  CommentGroup(CommentManager manager, CodeMirror cm, int line) {
+  CommentGroup(CommentManager manager, CodeMirror cm, DisplaySide side, int line) {
     this.manager = manager;
     this.cm = cm;
+    this.side = side;
     this.line = line;
 
     comments = new FlowPanel();
     comments.setStyleName(Resources.I.style().commentWidgets());
     comments.setVisible(false);
     initWidget(new SimplePanel(comments));
-
-    padding = DOM.createDiv();
-    padding.setClassName(DiffTable.style.padding());
-    ChunkManager.focusOnClick(padding, cm.side());
-    getElement().appendChild(padding);
   }
 
   CommentManager getCommentManager() {
@@ -73,14 +60,14 @@
     return cm;
   }
 
-  CommentGroup getPeer() {
-    return peer;
-  }
-
   int getLine() {
     return line;
   }
 
+  DisplaySide getSide() {
+    return side;
+  }
+
   void add(PublishedBox box) {
     comments.add(box);
     comments.setVisible(true);
@@ -138,33 +125,19 @@
   void remove(DraftBox box) {
     comments.remove(box);
     comments.setVisible(0 < getBoxCount());
-
-    if (0 < getBoxCount() || 0 < peer.getBoxCount()) {
-      resize();
-    } else {
-      detach();
-      peer.detach();
-    }
   }
 
-  private void detach() {
+  void detach() {
     if (lineWidget != null) {
       lineWidget.clear();
       lineWidget = null;
       updateSelection();
     }
-    manager.clearLine(cm.side(), line, this);
+    manager.clearLine(side, line, this);
     removeFromParent();
   }
 
-  void attachPair(DiffTable parent) {
-    if (lineWidget == null && peer.lineWidget == null) {
-      this.attach(parent);
-      peer.attach(parent);
-    }
-  }
-
-  private void attach(DiffTable parent) {
+  void attach(DiffTable parent) {
     parent.add(this);
     lineWidget = cm.addLineWidget(Math.max(0, line - 1), getElement(),
         Configuration.create()
@@ -174,33 +147,6 @@
           .set("insertAt", 0));
   }
 
-  void handleRedraw() {
-    lineWidget.onRedraw(new Runnable() {
-      @Override
-      public void run() {
-        if (canComputeHeight() && peer.canComputeHeight()) {
-          if (resizeTimer != null) {
-            resizeTimer.cancel();
-            resizeTimer = null;
-          }
-          adjustPadding(CommentGroup.this, peer);
-        } else if (resizeTimer == null) {
-          resizeTimer = new Timer() {
-            @Override
-            public void run() {
-              if (canComputeHeight() && peer.canComputeHeight()) {
-                cancel();
-                resizeTimer = null;
-                adjustPadding(CommentGroup.this, peer);
-              }
-            }
-          };
-          resizeTimer.scheduleRepeating(5);
-        }
-      }
-    });
-  }
-
   @Override
   protected void onUnload() {
     super.onUnload();
@@ -209,13 +155,7 @@
     }
   }
 
-  void resize() {
-    if (lineWidget != null) {
-      adjustPadding(this, peer);
-    }
-  }
-
-  private void updateSelection() {
+  void updateSelection() {
     if (cm.somethingSelected()) {
       FromTo r = cm.getSelectedRange();
       if (r.to().line() >= line) {
@@ -224,27 +164,37 @@
     }
   }
 
-  private boolean canComputeHeight() {
+  boolean canComputeHeight() {
     return !comments.isVisible() || comments.getOffsetHeight() > 0;
   }
 
-  private int computeHeight() {
-    if (comments.isVisible()) {
-      // Include margin-bottom: 5px from CSS class.
-      return comments.getOffsetHeight() + 5;
-    }
-    return 0;
+  LineWidget getLineWidget() {
+    return lineWidget;
   }
 
-  private static void adjustPadding(CommentGroup a, CommentGroup b) {
-    int apx = a.computeHeight();
-    int bpx = b.computeHeight();
-    int h = Math.max(apx, bpx);
-    a.padding.getStyle().setHeight(Math.max(0, h - apx), Unit.PX);
-    b.padding.getStyle().setHeight(Math.max(0, h - bpx), Unit.PX);
-    a.lineWidget.changed();
-    b.lineWidget.changed();
-    a.updateSelection();
-    b.updateSelection();
+  void setLineWidget(LineWidget widget) {
+    lineWidget = widget;
   }
+
+  Timer getResizeTimer() {
+    return resizeTimer;
+  }
+
+  void setResizeTimer(Timer timer) {
+    resizeTimer = timer;
+  }
+
+  FlowPanel getComments() {
+    return comments;
+  }
+
+  CommentManager getManager() {
+    return manager;
+  }
+
+  abstract void init(DiffTable parent);
+
+  abstract void handleRedraw();
+
+  abstract void resize();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 4e1a3e1..2f3ead3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2014 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -25,11 +25,11 @@
 import com.google.gwt.core.client.JsArray;
 
 import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.LineHandle;
 import net.codemirror.lib.Pos;
 import net.codemirror.lib.TextMarker.FromTo;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -38,24 +38,25 @@
 import java.util.SortedMap;
 import java.util.TreeMap;
 
-/** Tracks comment widgets for {@link SideBySide}. */
-class CommentManager {
-  private final SideBySide host;
+/** Tracks comment widgets for {@link DiffScreen}. */
+abstract class CommentManager {
   private final PatchSet.Id base;
   private final PatchSet.Id revision;
   private final String path;
   private final CommentLinkProcessor commentLinkProcessor;
-
+  final SortedMap<Integer, CommentGroup> sideA;
+  final SortedMap<Integer, CommentGroup> sideB;
   private final Map<String, PublishedBox> published;
-  private final SortedMap<Integer, CommentGroup> sideA;
-  private final SortedMap<Integer, CommentGroup> sideB;
   private final Set<DraftBox> unsavedDrafts;
+  final DiffScreen host;
   private boolean attached;
   private boolean expandAll;
   private boolean open;
 
-  CommentManager(SideBySide host,
-      PatchSet.Id base, PatchSet.Id revision,
+  CommentManager(
+      DiffScreen host,
+      PatchSet.Id base,
+      PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
@@ -67,17 +68,194 @@
     this.open = open;
 
     published = new HashMap<>();
+    unsavedDrafts = new HashSet<>();
     sideA = new TreeMap<>();
     sideB = new TreeMap<>();
-    unsavedDrafts = new HashSet<>();
   }
 
-  SideBySide getSideBySide() {
-    return host;
+  void setAttached(boolean attached) {
+    this.attached = attached;
+  }
+
+  boolean isAttached() {
+    return attached;
+  }
+
+  void setExpandAll(boolean expandAll) {
+    this.expandAll = expandAll;
+  }
+
+  boolean isExpandAll() {
+    return expandAll;
+  }
+
+  boolean isOpen() {
+    return open;
+  }
+
+  String getPath() {
+    return path;
+  }
+
+  Map<String, PublishedBox> getPublished() {
+    return published;
+  }
+
+  CommentLinkProcessor getCommentLinkProcessor() {
+    return commentLinkProcessor;
+  }
+
+  void renderDrafts(DisplaySide forSide, JsArray<CommentInfo> in) {
+    for (CommentInfo info : Natives.asList(in)) {
+      DisplaySide side = displaySide(info, forSide);
+      if (side != null) {
+        addDraftBox(side, info);
+      }
+    }
+  }
+
+  void setUnsaved(DraftBox box, boolean isUnsaved) {
+    if (isUnsaved) {
+      unsavedDrafts.add(box);
+    } else {
+      unsavedDrafts.remove(box);
+    }
+  }
+
+  void saveAllDrafts(CallbackGroup cb) {
+    for (DraftBox box : unsavedDrafts) {
+      box.save(cb);
+    }
+  }
+
+  Side getStoredSideFromDisplaySide(DisplaySide side) {
+    if (side == DisplaySide.A && (base == null || base.get() < 0)) {
+      return Side.PARENT;
+    }
+    return Side.REVISION;
+  }
+
+  int getParentNumFromDisplaySide(DisplaySide side) {
+    if (side == DisplaySide.A && base != null && base.get() < 0) {
+      return -base.get();
+    }
+    return 0;
+  }
+
+  PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
+    if (side == DisplaySide.A && base != null && base.get() >= 0) {
+      return base;
+    }
+    return revision;
+  }
+
+  DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
+    if (info.side() == Side.PARENT) {
+      return (base == null || base.get() < 0) ? DisplaySide.A : null;
+    }
+    return forSide;
+  }
+
+  static FromTo adjustSelection(CodeMirror cm) {
+    FromTo fromTo = cm.getSelectedRange();
+    Pos to = fromTo.to();
+    if (to.ch() == 0) {
+      to.line(to.line() - 1);
+      to.ch(cm.getLine(to.line()).length());
+    }
+    return fromTo;
+  }
+
+  abstract CommentGroup group(DisplaySide side, int cmLinePlusOne);
+
+  /**
+   * Create a new {@link DraftBox} at the specified line and focus it.
+   *
+   * @param side which side the draft will appear on.
+   * @param line the line the draft will be at. Lines are 1-based. Line 0 is a
+   *        special case creating a file level comment.
+   */
+  void insertNewDraft(DisplaySide side, int line) {
+    if (line == 0) {
+      host.skipManager.ensureFirstLineIsVisible();
+    }
+
+    CommentGroup group = group(side, line);
+    if (0 < group.getBoxCount()) {
+      CommentBox last = group.getCommentBox(group.getBoxCount() - 1);
+      if (last instanceof DraftBox) {
+        ((DraftBox)last).setEdit(true);
+      } else {
+        ((PublishedBox)last).doReply();
+      }
+    } else {
+      addDraftBox(side, CommentInfo.create(
+          getPath(),
+          getStoredSideFromDisplaySide(side),
+          getParentNumFromDisplaySide(side),
+          line,
+          null)).setEdit(true);
+    }
+  }
+
+  abstract String getTokenSuffixForActiveLine(CodeMirror cm);
+
+  Runnable signInCallback(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        String token = host.getToken();
+        if (cm.extras().hasActiveLine()) {
+          token += "@" + getTokenSuffixForActiveLine(cm);
+        }
+        Gerrit.doSignIn(token);
+      }
+    };
+  }
+
+  abstract void newDraft(CodeMirror cm);
+
+  Runnable newDraftCallback(final CodeMirror cm) {
+    if (!Gerrit.isSignedIn()) {
+      return signInCallback(cm);
+    }
+
+    return new Runnable() {
+      @Override
+      public void run() {
+        if (cm.extras().hasActiveLine()) {
+          newDraft(cm);
+        }
+      }
+    };
+  }
+
+  DraftBox addDraftBox(DisplaySide side, CommentInfo info) {
+    int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
+    CommentGroup group = group(side, cmLinePlusOne);
+    DraftBox box = new DraftBox(
+        group,
+        getCommentLinkProcessor(),
+        getPatchSetIdFromSide(side),
+        info,
+        isExpandAll());
+
+    if (info.inReplyTo() != null) {
+      PublishedBox r = getPublished().get(info.inReplyTo());
+      if (r != null) {
+        r.setReplyBox(box);
+      }
+    }
+
+    group.add(box);
+    box.setAnnotation(host.getDiffTable().scrollbar.draft(
+        host.getCmFromSide(side),
+        Math.max(0, cmLinePlusOne - 1)));
+    return box;
   }
 
   void setExpandAllComments(boolean b) {
-    expandAll = b;
+    setExpandAll(b);
     for (CommentGroup g : sideA.values()) {
       g.setOpenAll(b);
     }
@@ -86,6 +264,8 @@
     }
   }
 
+  abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
+
   Runnable commentNav(final CodeMirror src, final Direction dir) {
     return new Runnable() {
       @Override
@@ -93,27 +273,38 @@
         // Every comment appears in both side maps as a linked pair.
         // It is only necessary to search one side to find a comment
         // on either side of the editor pair.
-        SortedMap<Integer, CommentGroup> map = map(src.side());
+        SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
         int line = src.extras().hasActiveLine()
             ? src.getLineNumber(src.extras().activeLine()) + 1
             : 0;
+
+        CommentGroup g;
         if (dir == Direction.NEXT) {
           map = map.tailMap(line + 1);
           if (map.isEmpty()) {
             return;
           }
-          line = map.firstKey();
+          g = map.get(map.firstKey());
+          while (g.getBoxCount() == 0) {
+            map = map.tailMap(map.firstKey() + 1);
+            if (map.isEmpty()) {
+              return;
+            }
+            g = map.get(map.firstKey());
+          }
         } else {
           map = map.headMap(line);
           if (map.isEmpty()) {
             return;
           }
-          line = map.lastKey();
-        }
-
-        CommentGroup g = map.get(line);
-        if (g.getBoxCount() == 0) {
-          g = g.getPeer();
+          g = map.get(map.lastKey());
+          while (g.getBoxCount() == 0) {
+            map = map.headMap(map.lastKey());
+            if (map.isEmpty()) {
+              return;
+            }
+            g = map.get(map.lastKey());
+          }
         }
 
         CodeMirror cm = g.getCm();
@@ -125,6 +316,13 @@
     };
   }
 
+  void clearLine(DisplaySide side, int line, CommentGroup group) {
+    SortedMap<Integer, CommentGroup> map = map(side);
+    if (map.get(line) == group) {
+      map.remove(line);
+    }
+  }
+
   void render(CommentsCollections in, boolean expandAll) {
     if (in.publishedBase != null) {
       renderPublished(DisplaySide.A, in.publishedBase);
@@ -142,119 +340,60 @@
       setExpandAllComments(true);
     }
     for (CommentGroup g : sideA.values()) {
-      g.attachPair(host.diffTable);
+      g.init(host.getDiffTable());
     }
     for (CommentGroup g : sideB.values()) {
-      g.attachPair(host.diffTable);
+      g.init(host.getDiffTable());
       g.handleRedraw();
     }
-    attached = true;
+    setAttached(true);
   }
 
-  private void renderPublished(DisplaySide forSide, JsArray<CommentInfo> in) {
+  void renderPublished(DisplaySide forSide, JsArray<CommentInfo> in) {
     for (CommentInfo info : Natives.asList(in)) {
       DisplaySide side = displaySide(info, forSide);
       if (side != null) {
-        CommentGroup group = group(side, info.line());
+        int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
+        CommentGroup group = group(side, cmLinePlusOne);
         PublishedBox box = new PublishedBox(
             group,
-            commentLinkProcessor,
+            getCommentLinkProcessor(),
             getPatchSetIdFromSide(side),
             info,
-            open);
+            side,
+            isOpen());
         group.add(box);
-        box.setAnnotation(host.diffTable.scrollbar.comment(
+        box.setAnnotation(host.getDiffTable().scrollbar.comment(
             host.getCmFromSide(side),
-            Math.max(0, info.line() - 1)));
-        published.put(info.id(), box);
+            cmLinePlusOne - 1));
+        getPublished().put(info.id(), box);
       }
     }
   }
 
-  private void renderDrafts(DisplaySide forSide, JsArray<CommentInfo> in) {
-    for (CommentInfo info : Natives.asList(in)) {
-      DisplaySide side = displaySide(info, forSide);
-      if (side != null) {
-        addDraftBox(side, info);
-      }
+  abstract Collection<Integer> getLinesWithCommentGroups();
+
+  private static void checkAndAddSkip(List<SkippedLine> out, SkippedLine s) {
+    if (s.getSize() > 1) {
+      out.add(s);
     }
   }
 
-  /**
-   * Create a new {@link DraftBox} at the specified line and focus it.
-   *
-   * @param side which side the draft will appear on.
-   * @param line the line the draft will be at. Lines are 1-based. Line 0 is a
-   *        special case creating a file level comment.
-   */
-  void insertNewDraft(DisplaySide side, int line) {
-    if (line == 0) {
-      host.getSkipManager().ensureFirstLineIsVisible();
-    }
-
-    CommentGroup group = group(side, line);
-    if (0 < group.getBoxCount()) {
-      CommentBox last = group.getCommentBox(group.getBoxCount() - 1);
-      if (last instanceof DraftBox) {
-        ((DraftBox)last).setEdit(true);
-      } else {
-        ((PublishedBox)last).doReply();
-      }
-    } else {
-      addDraftBox(side, CommentInfo.create(
-          path,
-          getStoredSideFromDisplaySide(side),
-          line,
-          null)).setEdit(true);
-    }
-  }
-
-  DraftBox addDraftBox(DisplaySide side, CommentInfo info) {
-    CommentGroup group = group(side, info.line());
-    DraftBox box = new DraftBox(
-        group,
-        commentLinkProcessor,
-        getPatchSetIdFromSide(side),
-        info,
-        expandAll);
-
-    if (info.inReplyTo() != null) {
-      PublishedBox r = published.get(info.inReplyTo());
-      if (r != null) {
-        r.setReplyBox(box);
-      }
-    }
-
-    group.add(box);
-    box.setAnnotation(host.diffTable.scrollbar.draft(
-        host.getCmFromSide(side),
-        Math.max(0, info.line() - 1)));
-    return box;
-  }
-
-  private DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
-    if (info.side() == Side.PARENT) {
-      return base == null ? DisplaySide.A : null;
-    }
-    return forSide;
-  }
-
   List<SkippedLine> splitSkips(int context, List<SkippedLine> skips) {
-    if (sideB.containsKey(0)) {
+    if (sideA.containsKey(0) || sideB.containsKey(0)) {
       // Special case of file comment; cannot skip first line.
       for (SkippedLine skip : skips) {
-        if (skip.getStartB() == 0) {
+        if (skip.getStartA() == 0) {
           skip.incrementStart(1);
+          break;
         }
       }
     }
 
-    // TODO: This is not optimal, but shouldn't be too costly in most cases.
-    // Maybe rewrite after done keeping track of diff chunk positions.
-    for (int boxLine : sideB.tailMap(1).keySet()) {
+    for (int boxLine : getLinesWithCommentGroups()) {
       List<SkippedLine> temp = new ArrayList<>(skips.size() + 2);
       for (SkippedLine skip : skips) {
-        int startLine = skip.getStartB();
+        int startLine = host.getCmLine(skip.getStartB(), DisplaySide.B);
         int deltaBefore = boxLine - startLine;
         int deltaAfter = startLine + skip.getSize() - boxLine;
         if (deltaBefore < -context || deltaAfter < -context) {
@@ -282,29 +421,18 @@
     return skips;
   }
 
-  private static void checkAndAddSkip(List<SkippedLine> out, SkippedLine s) {
-    if (s.getSize() > 1) {
-      out.add(s);
-    }
-  }
+  abstract void newDraftOnGutterClick(CodeMirror cm, String gutterClass,
+      int line);
 
-  void clearLine(DisplaySide side, int line, CommentGroup group) {
-    SortedMap<Integer, CommentGroup> map = map(side);
-    if (map.get(line) == group) {
-      map.remove(line);
-    }
-  }
+  abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
   Runnable toggleOpenBox(final CodeMirror cm) {
     return new Runnable() {
       @Override
       public void run() {
-        if (cm.extras().hasActiveLine()) {
-          CommentGroup w = map(cm.side()).get(
-              cm.getLineNumber(cm.extras().activeLine()) + 1);
-          if (w != null) {
-            w.openCloseLast();
-          }
+        CommentGroup group = getCommentGroupOnActiveLine(cm);
+        if (group != null) {
+          group.openCloseLast();
         }
       }
     };
@@ -314,123 +442,15 @@
     return new Runnable() {
       @Override
       public void run() {
-        if (cm.extras().hasActiveLine()) {
-          CommentGroup w = map(cm.side()).get(
-              cm.getLineNumber(cm.extras().activeLine()) + 1);
-          if (w != null) {
-            w.openCloseAll();
-          }
+        CommentGroup group = getCommentGroupOnActiveLine(cm);
+        if (group != null) {
+          group.openCloseAll();
         }
       }
     };
   }
 
-  Runnable insertNewDraft(final CodeMirror cm) {
-    if (!Gerrit.isSignedIn()) {
-      return new Runnable() {
-        @Override
-        public void run() {
-          String token = host.getToken();
-          if (cm.extras().hasActiveLine()) {
-            LineHandle handle = cm.extras().activeLine();
-            int line = cm.getLineNumber(handle) + 1;
-            token += "@" + (cm.side() == DisplaySide.A ? "a" : "") + line;
-          }
-          Gerrit.doSignIn(token);
-        }
-     };
-    }
-
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.extras().hasActiveLine()) {
-          newDraft(cm, cm.getLineNumber(cm.extras().activeLine()) + 1);
-        }
-      }
-    };
-  }
-
-  void newDraft(CodeMirror cm, int line) {
-    if (cm.somethingSelected()) {
-      FromTo fromTo = cm.getSelectedRange();
-      Pos end = fromTo.to();
-      if (end.ch() == 0) {
-        end.line(end.line() - 1);
-        end.ch(cm.getLine(end.line()).length());
-      }
-
-      addDraftBox(cm.side(), CommentInfo.create(
-              path,
-              getStoredSideFromDisplaySide(cm.side()),
-              line,
-              CommentRange.create(fromTo))).setEdit(true);
-      cm.setSelection(cm.getCursor());
-    } else {
-      insertNewDraft(cm.side(), line);
-    }
-  }
-
-  void setUnsaved(DraftBox box, boolean isUnsaved) {
-    if (isUnsaved) {
-      unsavedDrafts.add(box);
-    } else {
-      unsavedDrafts.remove(box);
-    }
-  }
-
-  void saveAllDrafts(CallbackGroup cb) {
-    for (DraftBox box : unsavedDrafts) {
-      box.save(cb);
-    }
-  }
-
-  private CommentGroup group(DisplaySide side, int line) {
-    CommentGroup w = map(side).get(line);
-    if (w != null) {
-      return w;
-    }
-
-    int lineA;
-    int lineB;
-    if (line == 0) {
-      lineA = lineB = 0;
-    } else if (side == DisplaySide.A) {
-      lineA = line;
-      lineB = host.lineOnOther(side, line - 1).getLine() + 1;
-    } else {
-      lineA = host.lineOnOther(side, line - 1).getLine() + 1;
-      lineB = line;
-    }
-
-    CommentGroup a = newGroup(DisplaySide.A, lineA);
-    CommentGroup b = newGroup(DisplaySide.B, lineB);
-    CommentGroup.pair(a, b);
-
-    sideA.put(lineA, a);
-    sideB.put(lineB, b);
-
-    if (attached) {
-      a.attachPair(host.diffTable);
-      b.handleRedraw();
-    }
-
-    return side == DisplaySide.A ? a : b;
-  }
-
-  private CommentGroup newGroup(DisplaySide side, int line) {
-    return new CommentGroup(this, host.getCmFromSide(side), line);
-  }
-
-  private SortedMap<Integer, CommentGroup> map(DisplaySide side) {
+  SortedMap<Integer, CommentGroup> map(DisplaySide side) {
     return side == DisplaySide.A ? sideA : sideB;
   }
-
-  private Side getStoredSideFromDisplaySide(DisplaySide side) {
-    return side == DisplaySide.A && base == null ? Side.PARENT : Side.REVISION;
-  }
-
-  private PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
-    return side == DisplaySide.A && base != null ? base : revision;
-  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
index 9f46e9b..d3c150d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
@@ -43,7 +43,7 @@
   public final native int endLine() /*-{ return this.end_line; }-*/;
   public final native int endCharacter() /*-{ return this.end_character; }-*/;
 
-  private final native void set(int sl, int sc, int el, int ec) /*-{
+  private native void set(int sl, int sc, int el, int ec) /*-{
     this.start_line = sl;
     this.start_character = sc;
     this.end_line = el;
@@ -52,4 +52,4 @@
 
   protected CommentRange() {
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index b23a8cf..ce1d294 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -29,34 +30,55 @@
 
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
-  private String path;
-
+  private final String path;
+  private final PatchSet.Id base;
+  private final PatchSet.Id revision;
+  private NativeMap<JsArray<CommentInfo>> publishedBaseAll;
+  private NativeMap<JsArray<CommentInfo>> publishedRevisionAll;
   JsArray<CommentInfo> publishedBase;
   JsArray<CommentInfo> publishedRevision;
   JsArray<CommentInfo> draftsBase;
   JsArray<CommentInfo> draftsRevision;
 
-  void load(PatchSet.Id base, PatchSet.Id revision, String path,
-      CallbackGroup group) {
+  CommentsCollections(PatchSet.Id base, PatchSet.Id revision, String path) {
     this.path = path;
+    this.base = base;
+    this.revision = revision;
+  }
 
-    if (base != null) {
+  void load(CallbackGroup group) {
+    if (base != null && base.get() > 0) {
       CommentApi.comments(base, group.add(publishedBase()));
     }
     CommentApi.comments(revision, group.add(publishedRevision()));
 
     if (Gerrit.isSignedIn()) {
-      if (base != null) {
+      if (base != null && base.get() > 0) {
         CommentApi.drafts(base, group.add(draftsBase()));
       }
       CommentApi.drafts(revision, group.add(draftsRevision()));
     }
   }
 
+  boolean hasCommentForPath(String filePath) {
+    if (base != null && base.get() > 0) {
+      JsArray<CommentInfo> forBase = publishedBaseAll.get(filePath);
+      if (forBase != null && forBase.length() > 0) {
+        return true;
+      }
+    }
+    JsArray<CommentInfo> forRevision = publishedRevisionAll.get(filePath);
+    if (forRevision != null && forRevision.length() > 0) {
+      return true;
+    }
+    return false;
+  }
+
   private AsyncCallback<NativeMap<JsArray<CommentInfo>>> publishedBase() {
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+        publishedBaseAll = result;
         publishedBase = sort(result.get(path));
       }
 
@@ -70,6 +92,10 @@
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+        for (String k : result.keySet()) {
+          result.put(k, filterForParent(result.get(k)));
+        }
+        publishedRevisionAll = result;
         publishedRevision = sort(result.get(path));
       }
 
@@ -79,6 +105,20 @@
     };
   }
 
+    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
+      JsArray<CommentInfo> result = JsArray.createArray().cast();
+      for (CommentInfo c : Natives.asList(list)) {
+        if (c.side() == Side.REVISION) {
+          result.push(c);
+        } else if (base == null && !c.hasParent()) {
+          result.push(c);
+        } else if (base != null && c.parent() == -base.get()) {
+          result.push(c);
+        }
+      }
+      return result;
+    }
+
   private AsyncCallback<NativeMap<JsArray<CommentInfo>>> draftsBase() {
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
@@ -96,6 +136,9 @@
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+        for (String k : result.keySet()) {
+          result.put(k, filterForParent(result.get(k)));
+        }
         draftsRevision = sort(result.get(path));
       }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
index 6d795d3..e3720cc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.client.diff;
 
+import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_ALL;
+
 import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.FileInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
@@ -23,15 +26,15 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 public class DiffApi {
-  public enum IgnoreWhitespace {
-    NONE, TRAILING, CHANGED, ALL
-  }
-
-  public static void list(int id, String base, String revision,
+  public static void list(int id, String revision, RevisionInfo base,
       AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id, revision).view("files");
     if (base != null) {
-      api.addParameter("base", base);
+      if (base._number() < 0) {
+        api.addParameter("parent", -base._number());
+      } else {
+        api.addParameter("base", base.name());
+      }
     }
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
@@ -40,7 +43,11 @@
       AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id).view("files");
     if (base != null) {
-      api.addParameter("base", base.get());
+      if (base.get() < 0) {
+        api.addParameter("parent", -base.get());
+      } else {
+        api.addParameter("base", base.get());
+      }
     }
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
@@ -59,7 +66,11 @@
 
   public DiffApi base(PatchSet.Id id) {
     if (id != null) {
-      call.addParameter("base", id.get());
+      if (id.get() < 0) {
+        call.addParameter("parent", -id.get());
+      } else {
+        call.addParameter("base", id.get());
+      }
     }
     return this;
   }
@@ -70,22 +81,8 @@
   }
 
   public DiffApi ignoreWhitespace(DiffPreferencesInfo.Whitespace w) {
-    switch (w) {
-      default:
-      case IGNORE_NONE:
-        return ignoreWhitespace(IgnoreWhitespace.NONE);
-      case IGNORE_TRAILING:
-        return ignoreWhitespace(IgnoreWhitespace.TRAILING);
-      case IGNORE_LEADING_AND_TRAILING:
-        return ignoreWhitespace(IgnoreWhitespace.CHANGED);
-      case IGNORE_ALL:
-        return ignoreWhitespace(IgnoreWhitespace.ALL);
-    }
-  }
-
-  public DiffApi ignoreWhitespace(IgnoreWhitespace w) {
-    if (w != null && w != IgnoreWhitespace.NONE) {
-      call.addParameter("ignore-whitespace", w);
+    if (w != null && w != IGNORE_ALL) {
+      call.addParameter("whitespace", w);
     }
     return this;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java
index 1e5c5e5..3b1b346 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffChunkInfo.java
@@ -43,4 +43,4 @@
   boolean isEdit() {
     return edit;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
index 923a995..b7910f5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.client.DiffWebLinkInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
@@ -27,9 +27,6 @@
 import java.util.List;
 
 public class DiffInfo extends JavaScriptObject {
-  public static final String GITLINK = "x-git/gitlink";
-  public static final String SYMLINK = "x-git/symlink";
-
   public final native FileMeta metaA() /*-{ return this.meta_a; }-*/;
   public final native FileMeta metaB() /*-{ return this.meta_b; }-*/;
   public final native JsArrayString diffHeader() /*-{ return this.diff_header; }-*/;
@@ -45,7 +42,7 @@
     return filterWebLinks(DiffView.UNIFIED_DIFF);
   }
 
-  private final List<WebLinkInfo> filterWebLinks(DiffView diffView) {
+  private List<WebLinkInfo> filterWebLinks(DiffView diffView) {
     List<WebLinkInfo> filteredDiffWebLinks = new LinkedList<>();
     List<DiffWebLinkInfo> allDiffWebLinks = Natives.asList(webLinks());
     if (allDiffWebLinks != null) {
@@ -66,7 +63,7 @@
   public final ChangeType changeType() {
     return ChangeType.valueOf(changeTypeRaw());
   }
-  private final native String changeTypeRaw()
+  private native String changeTypeRaw()
   /*-{ return this.change_type }-*/;
 
   public final IntraLineStatus intralineStatus() {
@@ -75,7 +72,7 @@
         ? IntraLineStatus.valueOf(s)
         : IntraLineStatus.OFF;
   }
-  private final native String intralineStatusRaw()
+  private native String intralineStatusRaw()
   /*-{ return this.intraline_status }-*/;
 
   public final boolean hasSkip() {
@@ -118,6 +115,26 @@
     return s.toString();
   }
 
+  public final String textUnified() {
+    StringBuilder s = new StringBuilder();
+    JsArray<Region> c = content();
+    for (int i = 0; i < c.length(); i++) {
+      Region r = c.get(i);
+      if (r.ab() != null) {
+        append(s, r.ab());
+      } else {
+        if (r.a() != null) {
+          append(s, r.a());
+        }
+        if (r.b() != null) {
+          append(s, r.b());
+        }
+      }
+      // TODO skip may need to be handled
+    }
+    return s.toString();
+  }
+
   private static void append(StringBuilder s, JsArrayString lines) {
     for (int i = 0; i < lines.length(); i++) {
       s.append(lines.get(i)).append('\n');
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
new file mode 100644
index 0000000..8935e36
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -0,0 +1,1013 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or impl ied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
+import static java.lang.Double.POSITIVE_INFINITY;
+
+import com.google.gerrit.client.Dispatcher;
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.account.DiffPreferences;
+import com.google.gerrit.client.change.ChangeScreen;
+import com.google.gerrit.client.change.FileTable;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.ChangeList;
+import com.google.gerrit.client.diff.DiffInfo.FileMeta;
+import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
+import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
+import com.google.gerrit.client.info.ChangeInfo.EditInfo;
+import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
+import com.google.gerrit.client.info.FileInfo;
+import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gerrit.client.projects.ConfigInfoCache;
+import com.google.gerrit.client.rpc.CallbackGroup;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.ui.Screen;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.RepeatingCommand;
+import com.google.gwt.core.client.Scheduler.ScheduledCommand;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.NativeEvent;
+import com.google.gwt.event.dom.client.FocusHandler;
+import com.google.gwt.event.dom.client.KeyCodes;
+import com.google.gwt.event.dom.client.KeyPressEvent;
+import com.google.gwt.event.logical.shared.ResizeEvent;
+import com.google.gwt.event.logical.shared.ResizeHandler;
+import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwtexpui.globalkey.client.GlobalKey;
+import com.google.gwtexpui.globalkey.client.KeyCommand;
+import com.google.gwtexpui.globalkey.client.KeyCommandSet;
+import com.google.gwtexpui.globalkey.client.ShowHelpCommand;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.CodeMirror.BeforeSelectionChangeHandler;
+import net.codemirror.lib.CodeMirror.GutterClickHandler;
+import net.codemirror.lib.CodeMirror.LineHandle;
+import net.codemirror.lib.KeyMap;
+import net.codemirror.lib.Pos;
+import net.codemirror.mode.ModeInfo;
+import net.codemirror.mode.ModeInjector;
+import net.codemirror.theme.ThemeLoader;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+
+/** Base class for SideBySide and Unified */
+abstract class DiffScreen extends Screen {
+  private static final KeyMap RENDER_ENTIRE_FILE_KEYMAP = KeyMap.create()
+      .propagate("Ctrl-F").propagate("Ctrl-G").propagate("Shift-Ctrl-G");
+
+  enum FileSize {
+    SMALL(0),
+    LARGE(500),
+    HUGE(4000);
+
+    final int lines;
+
+    FileSize(int n) {
+      this.lines = n;
+    }
+  }
+
+  private final Change.Id changeId;
+  final PatchSet.Id base;
+  final PatchSet.Id revision;
+  final String path;
+  final DiffPreferences prefs;
+  final SkipManager skipManager;
+
+  private DisplaySide startSide;
+  private int startLine;
+  private Change.Status changeStatus;
+
+  private HandlerRegistration resizeHandler;
+  private DiffInfo diff;
+  private FileSize fileSize;
+  private EditInfo edit;
+
+  private KeyCommandSet keysNavigation;
+  private KeyCommandSet keysAction;
+  private KeyCommandSet keysComment;
+  private List<HandlerRegistration> handlers;
+  private PreferencesAction prefsAction;
+  private int reloadVersionId;
+  private int parents;
+
+  @UiField(provided = true)
+  Header header;
+
+  DiffScreen(
+      PatchSet.Id base,
+      PatchSet.Id revision,
+      String path,
+      DisplaySide startSide,
+      int startLine,
+      DiffView diffScreenType) {
+    this.base = base;
+    this.revision = revision;
+    this.changeId = revision.getParentKey();
+    this.path = path;
+    this.startSide = startSide;
+    this.startLine = startLine;
+
+    prefs = DiffPreferences.create(Gerrit.getDiffPreferences());
+    handlers = new ArrayList<>(6);
+    keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
+    header = new Header(
+        keysNavigation, base, revision, path, diffScreenType, prefs);
+    skipManager = new SkipManager(this);
+  }
+
+  @Override
+  protected void onInitUI() {
+    super.onInitUI();
+    setHeaderVisible(false);
+    setWindowTitle(FileInfo.getFileName(path));
+  }
+
+  @Override
+  protected void onLoad() {
+    super.onLoad();
+
+    CallbackGroup group1 = new CallbackGroup();
+    final CallbackGroup group2 = new CallbackGroup();
+
+    CodeMirror.initLibrary(group1.add(new AsyncCallback<Void>() {
+      final AsyncCallback<Void> themeCallback = group2.addEmpty();
+
+      @Override
+      public void onSuccess(Void result) {
+        // Load theme after CM library to ensure theme can override CSS.
+        ThemeLoader.loadTheme(prefs.theme(), themeCallback);
+      }
+
+      @Override
+      public void onFailure(Throwable caught) {
+      }
+    }));
+
+    DiffApi.diff(revision, path)
+      .base(base)
+      .wholeFile()
+      .intraline(prefs.intralineDifference())
+      .ignoreWhitespace(prefs.ignoreWhitespace())
+      .get(group1.addFinal(new GerritCallback<DiffInfo>() {
+        final AsyncCallback<Void> modeInjectorCb = group2.addEmpty();
+
+        @Override
+        public void onSuccess(DiffInfo diffInfo) {
+          diff = diffInfo;
+          fileSize = bucketFileSize(diffInfo);
+
+          if (prefs.syntaxHighlighting()) {
+            if (fileSize.compareTo(FileSize.SMALL) > 0) {
+              modeInjectorCb.onSuccess(null);
+            } else {
+              injectMode(diffInfo, modeInjectorCb);
+            }
+          } else {
+            modeInjectorCb.onSuccess(null);
+          }
+        }
+      }));
+
+    if (Gerrit.isSignedIn()) {
+      ChangeApi.edit(changeId.get(), group2.add(
+          new AsyncCallback<EditInfo>() {
+            @Override
+            public void onSuccess(EditInfo result) {
+              edit = result;
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+            }
+          }));
+    }
+
+    final CommentsCollections comments =
+        new CommentsCollections(base, revision, path);
+    comments.load(group2);
+
+    countParents(group2);
+
+    RestApi call = ChangeApi.detail(changeId.get());
+    ChangeList.addOptions(call, EnumSet.of(
+        ListChangesOption.ALL_REVISIONS));
+    call.get(group2.add(new AsyncCallback<ChangeInfo>() {
+      @Override
+      public void onSuccess(ChangeInfo info) {
+        changeStatus = info.status();
+        info.revisions().copyKeysIntoChildren("name");
+        if (edit != null) {
+          edit.setName(edit.commit().commit());
+          info.setEdit(edit);
+          info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
+        }
+        String currentRevision = info.currentRevision();
+        boolean current = currentRevision != null &&
+            revision.get() == info.revision(currentRevision)._number();
+        JsArray<RevisionInfo> list = info.revisions().values();
+        RevisionInfo.sortRevisionInfoByNumber(list);
+        getDiffTable().set(prefs, list, parents, diff, edit != null, current,
+            changeStatus.isOpen(), diff.binary());
+        header.setChangeInfo(info);
+      }
+
+      @Override
+      public void onFailure(Throwable caught) {
+      }
+    }));
+
+    ConfigInfoCache.get(changeId, group2.addFinal(
+        getScreenLoadCallback(comments)));
+  }
+
+  private void countParents(CallbackGroup cbg) {
+    ChangeApi.revision(changeId.get(), revision.getId())
+        .view("commit")
+        .get(cbg.add(new AsyncCallback<CommitInfo>() {
+          @Override
+          public void onSuccess(CommitInfo info) {
+            parents = info.parents().length();
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+            parents = 0;
+          }
+        }));
+  }
+
+  @Override
+  public void onShowView() {
+    super.onShowView();
+
+    Window.enableScrolling(false);
+    if (prefs.hideTopMenu()) {
+      Gerrit.setHeaderVisible(false);
+    }
+    resizeHandler = Window.addResizeHandler(new ResizeHandler() {
+      @Override
+      public void onResize(ResizeEvent event) {
+        resizeCodeMirror();
+      }
+    });
+  }
+
+  KeyCommandSet getKeysNavigation() {
+    return keysNavigation;
+  }
+
+  KeyCommandSet getKeysAction() {
+    return keysAction;
+  }
+
+  @Override
+  protected void onUnload() {
+    super.onUnload();
+
+    removeKeyHandlerRegistrations();
+    if (getCommentManager() != null) {
+      CallbackGroup group = new CallbackGroup();
+      getCommentManager().saveAllDrafts(group);
+      group.done();
+    }
+    if (resizeHandler != null) {
+      resizeHandler.removeHandler();
+      resizeHandler = null;
+    }
+    for (CodeMirror cm : getCms()) {
+      if (cm != null) {
+        cm.getWrapperElement().removeFromParent();
+      }
+    }
+    if (prefsAction != null) {
+      prefsAction.hide();
+    }
+
+    Window.enableScrolling(true);
+    Gerrit.setHeaderVisible(true);
+  }
+
+  private void removeKeyHandlerRegistrations() {
+    for (HandlerRegistration h : handlers) {
+      h.removeHandler();
+    }
+    handlers.clear();
+  }
+
+  void registerCmEvents(final CodeMirror cm) {
+    cm.on("cursorActivity", updateActiveLine(cm));
+    cm.on("focus", updateActiveLine(cm));
+    KeyMap keyMap = KeyMap.create()
+        .on("A", upToChange(true))
+        .on("U", upToChange(false))
+        .on("'['", header.navigate(Direction.PREV))
+        .on("']'", header.navigate(Direction.NEXT))
+        .on("R", header.toggleReviewed())
+        .on("O", getCommentManager().toggleOpenBox(cm))
+        .on("N", maybeNextVimSearch(cm))
+        .on("Ctrl-Alt-E", openEditScreen(cm))
+        .on("P", getChunkManager().diffChunkNav(cm, Direction.PREV))
+        .on("Shift-M", header.reviewedAndNext())
+        .on("Shift-N", maybePrevVimSearch(cm))
+        .on("Shift-P", getCommentManager().commentNav(cm, Direction.PREV))
+        .on("Shift-O", getCommentManager().openCloseAll(cm))
+        .on("I", new Runnable() {
+          @Override
+          public void run() {
+            switch (getIntraLineStatus()) {
+              case OFF:
+              case OK:
+                toggleShowIntraline();
+                break;
+              case FAILURE:
+              case TIMEOUT:
+              default:
+                break;
+            }
+          }
+        })
+        .on("','", new Runnable() {
+          @Override
+          public void run() {
+            prefsAction.show();
+          }
+        })
+        .on("Shift-/", new Runnable() {
+          @Override
+          public void run() {
+            new ShowHelpCommand().onKeyPress(null);
+          }
+        })
+        .on("Space", new Runnable() {
+          @Override
+          public void run() {
+            cm.vim().handleKey("<C-d>");
+          }
+        })
+        .on("Shift-Space", new Runnable() {
+          @Override
+          public void run() {
+            cm.vim().handleKey("<C-u>");
+          }
+        })
+        .on("Ctrl-F", new Runnable() {
+          @Override
+          public void run() {
+            cm.execCommand("find");
+          }
+        })
+        .on("Ctrl-G", new Runnable() {
+          @Override
+          public void run() {
+            cm.execCommand("findNext");
+          }
+        })
+        .on("Enter", maybeNextCmSearch(cm))
+        .on("Shift-Ctrl-G", new Runnable() {
+          @Override
+          public void run() {
+            cm.execCommand("findPrev");
+          }
+        })
+        .on("Shift-Enter", new Runnable() {
+          @Override
+          public void run() {
+            cm.execCommand("findPrev");
+          }
+        })
+        .on("Esc", new Runnable() {
+          @Override
+          public void run() {
+            cm.setCursor(cm.getCursor());
+            cm.execCommand("clearSearch");
+            cm.vim().handleEx("nohlsearch");
+          }
+        })
+        .on("Ctrl-A", new Runnable() {
+          @Override
+          public void run() {
+            cm.execCommand("selectAll");
+          }
+        })
+        .on("G O", new Runnable() {
+          @Override
+          public void run() {
+            Gerrit.display(PageLinks.toChangeQuery("status:open"));
+          }
+        })
+        .on("G M", new Runnable() {
+          @Override
+          public void run() {
+            Gerrit.display(PageLinks.toChangeQuery("status:merged"));
+          }
+        })
+        .on("G A", new Runnable() {
+          @Override
+          public void run() {
+            Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
+          }
+        });
+        if (Gerrit.isSignedIn()) {
+          keyMap.on("G I", new Runnable() {
+            @Override
+            public void run() {
+              Gerrit.display(PageLinks.MINE);
+            }
+          })
+          .on("G D", new Runnable() {
+            @Override
+            public void run() {
+              Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
+            }
+          })
+          .on("G C", new Runnable() {
+            @Override
+            public void run() {
+              Gerrit.display(PageLinks.toChangeQuery("has:draft"));
+            }
+          })
+          .on("G W", new Runnable() {
+            @Override
+            public void run() {
+              Gerrit.display(
+                  PageLinks.toChangeQuery("is:watched status:open"));
+            }
+          })
+          .on("G S", new Runnable() {
+            @Override
+            public void run() {
+              Gerrit.display(PageLinks.toChangeQuery("is:starred"));
+            }
+          });
+        }
+
+    if (revision.get() != 0) {
+      cm.on("beforeSelectionChange", onSelectionChange(cm));
+      cm.on("gutterClick", onGutterClick(cm));
+      keyMap.on("C", getCommentManager().newDraftCallback(cm));
+    }
+    CodeMirror.normalizeKeyMap(keyMap); // Needed to for multi-stroke keymaps
+    cm.addKeyMap(keyMap);
+  }
+
+  void maybeRegisterRenderEntireFileKeyMap(CodeMirror cm) {
+    if (renderEntireFile()) {
+      cm.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
+    }
+  }
+
+  private BeforeSelectionChangeHandler onSelectionChange(final CodeMirror cm) {
+    return new BeforeSelectionChangeHandler() {
+      private InsertCommentBubble bubble;
+
+      @Override
+      public void handle(CodeMirror cm, Pos anchor, Pos head) {
+        if (anchor.equals(head)) {
+          if (bubble != null) {
+            bubble.setVisible(false);
+          }
+          return;
+        } else if (bubble == null) {
+          init(anchor);
+        } else {
+          bubble.setVisible(true);
+        }
+        bubble.position(cm.charCoords(head, "local"));
+      }
+
+      private void init(Pos anchor) {
+        bubble = new InsertCommentBubble(getCommentManager(), cm);
+        add(bubble);
+        cm.addWidget(anchor, bubble.getElement());
+      }
+    };
+  }
+
+  @Override
+  public void registerKeys() {
+    super.registerKeys();
+
+    keysNavigation.add(new UpToChangeCommand(revision, 0, 'u'));
+    keysNavigation.add(
+        new NoOpKeyCommand(0, 'j', PatchUtil.C.lineNext()),
+        new NoOpKeyCommand(0, 'k', PatchUtil.C.linePrev()));
+    keysNavigation.add(
+        new NoOpKeyCommand(0, 'n', PatchUtil.C.chunkNext()),
+        new NoOpKeyCommand(0, 'p', PatchUtil.C.chunkPrev()));
+    keysNavigation.add(
+        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'n', PatchUtil.C.commentNext()),
+        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'p', PatchUtil.C.commentPrev()));
+    keysNavigation.add(
+        new NoOpKeyCommand(KeyCommand.M_CTRL, 'f', Gerrit.C.keySearch()));
+
+    keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
+    keysAction.add(new NoOpKeyCommand(0, KeyCodes.KEY_ENTER,
+        PatchUtil.C.expandComment()));
+    keysAction.add(new NoOpKeyCommand(0, 'o', PatchUtil.C.expandComment()));
+    keysAction.add(new NoOpKeyCommand(
+        KeyCommand.M_SHIFT, 'o', PatchUtil.C.expandAllCommentsOnCurrentLine()));
+    if (Gerrit.isSignedIn()) {
+      keysAction.add(new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
+        @Override
+        public void onKeyPress(KeyPressEvent event) {
+          header.toggleReviewed().run();
+        }
+      });
+      keysAction.add(new NoOpKeyCommand(KeyCommand.M_CTRL | KeyCommand.M_ALT,
+          'e', Gerrit.C.keyEditor()));
+    }
+    keysAction.add(new KeyCommand(
+        KeyCommand.M_SHIFT, 'm', PatchUtil.C.markAsReviewedAndGoToNext()) {
+      @Override
+      public void onKeyPress(KeyPressEvent event) {
+        header.reviewedAndNext().run();
+      }
+    });
+    keysAction.add(new KeyCommand(0, 'a', PatchUtil.C.openReply()) {
+      @Override
+      public void onKeyPress(KeyPressEvent event) {
+        upToChange(true).run();
+      }
+    });
+    keysAction.add(new KeyCommand(0, ',', PatchUtil.C.showPreferences()) {
+      @Override
+      public void onKeyPress(KeyPressEvent event) {
+        prefsAction.show();
+      }
+    });
+    if (getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF
+        || getIntraLineStatus() == DiffInfo.IntraLineStatus.OK) {
+      keysAction.add(new KeyCommand(0, 'i', PatchUtil.C.toggleIntraline()) {
+        @Override
+        public void onKeyPress(KeyPressEvent event) {
+          toggleShowIntraline();
+        }
+      });
+    }
+
+    if (Gerrit.isSignedIn()) {
+      keysAction.add(new NoOpKeyCommand(0, 'c', PatchUtil.C.commentInsert()));
+      keysComment = new KeyCommandSet(PatchUtil.C.commentEditorSet());
+      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's',
+          PatchUtil.C.commentSaveDraft()));
+      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE,
+          PatchUtil.C.commentCancelEdit()));
+    } else {
+      keysComment = null;
+    }
+  }
+
+  void registerHandlers() {
+    removeKeyHandlerRegistrations();
+    handlers.add(GlobalKey.add(this, keysAction));
+    handlers.add(GlobalKey.add(this, keysNavigation));
+    if (keysComment != null) {
+      handlers.add(GlobalKey.add(this, keysComment));
+    }
+    handlers.add(ShowHelpCommand.addFocusHandler(getFocusHandler()));
+  }
+
+  void setupSyntaxHighlighting() {
+    if (prefs.syntaxHighlighting() && fileSize.compareTo(FileSize.SMALL) > 0) {
+      Scheduler.get().scheduleFixedDelay(new RepeatingCommand() {
+        @Override
+        public boolean execute() {
+          if (prefs.syntaxHighlighting() && isAttached()) {
+            setSyntaxHighlighting(prefs.syntaxHighlighting());
+          }
+          return false;
+        }
+      }, 250);
+    }
+  }
+
+  abstract CodeMirror newCm(
+      DiffInfo.FileMeta meta, String contents, Element parent);
+
+  void render(DiffInfo diff) {
+    header.setNoDiff(diff);
+    getChunkManager().render(diff);
+  }
+
+  void setShowLineNumbers(boolean b) {
+    if (b) {
+      getDiffTable().addStyleName(
+          Resources.I.diffTableStyle().showLineNumbers());
+    } else {
+      getDiffTable().removeStyleName(
+          Resources.I.diffTableStyle().showLineNumbers());
+    }
+  }
+
+  void setShowIntraline(boolean b) {
+    if (b && getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF) {
+      reloadDiffInfo();
+    } else if (b) {
+      getDiffTable().removeStyleName(Resources.I.diffTableStyle().noIntraline());
+    } else {
+      getDiffTable().addStyleName(Resources.I.diffTableStyle().noIntraline());
+    }
+  }
+
+  private void toggleShowIntraline() {
+    prefs.intralineDifference(!prefs.intralineDifference());
+    setShowIntraline(prefs.intralineDifference());
+    prefsAction.update();
+  }
+
+  abstract void setSyntaxHighlighting(boolean b);
+
+  void setContext(final int context) {
+    operation(new Runnable() {
+      @Override
+      public void run() {
+        skipManager.removeAll();
+        skipManager.render(context, diff);
+        updateRenderEntireFile();
+      }
+    });
+  }
+
+  private int adjustCommitMessageLine(int line) {
+    /* When commit messages are shown in the diff screen they include
+      a header block that looks like this:
+
+      1 Parent:     deadbeef (Parent commit title)
+      2 Author:     A. U. Thor <author@example.com>
+      3 AuthorDate: 2015-02-27 19:20:52 +0900
+      4 Commit:     A. U. Thor <author@example.com>
+      5 CommitDate: 2015-02-27 19:20:52 +0900
+      6 [blank line]
+      7 Commit message title
+      8
+      9 Commit message body
+     10 ...
+     11 ...
+
+    If the commit is a merge commit, both parent commits are listed in the
+    first two lines instead of a 'Parent' line:
+
+      1 Merge Of:   deadbeef (Parent 1 commit title)
+      2             beefdead (Parent 2 commit title)
+
+    */
+
+    // Offset to compensate for header lines until the blank line
+    // after 'CommitDate'
+    int offset = 6;
+
+    // Adjust for merge commits, which have two parent lines
+    if (diff.textB().startsWith("Merge")) {
+      offset += 1;
+    }
+
+    // If the cursor is inside the header line, reset to the first line of the
+    // commit message. Otherwise if the cursor is on an actual line of the commit
+    // message, adjust the line number to compensate for the header lines, so the
+    // focus is on the correct line.
+    if (line <= offset) {
+      return 1;
+    }
+    return line - offset;
+  }
+
+  private Runnable openEditScreen(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        LineHandle handle = cm.extras().activeLine();
+        int line = cm.getLineNumber(handle) + 1;
+        if (Patch.COMMIT_MSG.equals(path)) {
+          line = adjustCommitMessageLine(line);
+        }
+        String token = Dispatcher.toEditScreen(revision, path, line);
+        if (!Gerrit.isSignedIn()) {
+          Gerrit.doSignIn(token);
+        } else {
+          Gerrit.display(token);
+        }
+      }
+    };
+  }
+
+  void updateRenderEntireFile() {
+    boolean entireFile = renderEntireFile();
+    for (CodeMirror cm : getCms()) {
+      cm.removeKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
+      if (entireFile) {
+        cm.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
+      }
+      cm.setOption("viewportMargin", entireFile ? POSITIVE_INFINITY : 10);
+    }
+  }
+
+  void resizeCodeMirror() {
+    int height = header.getOffsetHeight() + getDiffTable().getHeaderHeight();
+    for (CodeMirror cm : getCms()) {
+      cm.adjustHeight(height);
+    }
+  }
+
+  abstract ChunkManager getChunkManager();
+
+  abstract CommentManager getCommentManager();
+
+  Change.Status getChangeStatus() {
+    return changeStatus;
+  }
+
+  int getStartLine() {
+    return startLine;
+  }
+
+  void setStartLine(int startLine) {
+    this.startLine = startLine;
+  }
+
+  DisplaySide getStartSide() {
+    return startSide;
+  }
+
+  void setStartSide(DisplaySide startSide) {
+    this.startSide = startSide;
+  }
+
+  DiffInfo getDiff() {
+    return diff;
+  }
+
+  FileSize getFileSize() {
+    return fileSize;
+  }
+
+  PreferencesAction getPrefsAction() {
+    return prefsAction;
+  }
+
+  void setPrefsAction(PreferencesAction prefsAction) {
+    this.prefsAction = prefsAction;
+  }
+
+  abstract void operation(final Runnable apply);
+
+  private Runnable upToChange(final boolean openReplyBox) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        CallbackGroup group = new CallbackGroup();
+        getCommentManager().saveAllDrafts(group);
+        group.done();
+        group.addListener(new GerritCallback<Void>() {
+          @Override
+          public void onSuccess(Void result) {
+            String b = base != null ? String.valueOf(base.get()) : null;
+            String rev = String.valueOf(revision.get());
+            Gerrit.display(
+              PageLinks.toChange(changeId, b, rev),
+              new ChangeScreen(changeId, b, rev, openReplyBox,
+                  FileTable.Mode.REVIEW));
+          }
+        });
+      }
+    };
+  }
+
+  private Runnable maybePrevVimSearch(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        if (cm.vim().hasSearchHighlight()) {
+          cm.vim().handleKey("N");
+        } else {
+          getCommentManager().commentNav(cm, Direction.NEXT).run();
+        }
+      }
+    };
+  }
+
+  private Runnable maybeNextVimSearch(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        if (cm.vim().hasSearchHighlight()) {
+          cm.vim().handleKey("n");
+        } else {
+          getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
+        }
+      }
+    };
+  }
+
+  Runnable maybeNextCmSearch(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        if (cm.hasSearchHighlight()) {
+          cm.execCommand("findNext");
+        } else {
+          cm.execCommand("clearSearch");
+          getCommentManager().toggleOpenBox(cm).run();
+        }
+      }
+    };
+  }
+
+  boolean renderEntireFile() {
+    return prefs.renderEntireFile() && canRenderEntireFile(prefs);
+  }
+
+  boolean canRenderEntireFile(DiffPreferences prefs) {
+    // CodeMirror is too slow to layout an entire huge file.
+    return fileSize.compareTo(FileSize.HUGE) < 0
+        || (prefs.context() != WHOLE_FILE_CONTEXT && prefs.context() < 100);
+  }
+
+  DiffInfo.IntraLineStatus getIntraLineStatus() {
+    return diff.intralineStatus();
+  }
+
+  void setThemeStyles(boolean d) {
+    if (d) {
+      getDiffTable().addStyleName(Resources.I.diffTableStyle().dark());
+    } else {
+      getDiffTable().removeStyleName(Resources.I.diffTableStyle().dark());
+    }
+  }
+
+  void setShowTabs(boolean show) {
+    for (CodeMirror cm : getCms()) {
+      cm.extras().showTabs(show);
+    }
+  }
+
+  void setLineLength(int columns) {
+    for (CodeMirror cm : getCms()) {
+      cm.extras().lineLength(columns);
+    }
+  }
+
+  String getContentType(DiffInfo.FileMeta meta) {
+    if (prefs.syntaxHighlighting() && meta != null
+        && meta.contentType() != null) {
+     ModeInfo m = ModeInfo.findMode(meta.contentType(), path);
+     return m != null ? m.mime() : null;
+   }
+   return null;
+  }
+
+  String getContentType() {
+    return getContentType(diff.metaB());
+  }
+
+  void injectMode(DiffInfo diffInfo, AsyncCallback<Void> cb) {
+    new ModeInjector()
+        .add(getContentType(diffInfo.metaA()))
+        .add(getContentType(diffInfo.metaB()))
+        .inject(cb);
+  }
+
+  abstract void setAutoHideDiffHeader(boolean hide);
+
+  void prefetchNextFile() {
+    String nextPath = header.getNextPath();
+    if (nextPath != null) {
+      DiffApi.diff(revision, nextPath)
+        .base(base)
+        .wholeFile()
+        .intraline(prefs.intralineDifference())
+        .ignoreWhitespace(prefs.ignoreWhitespace())
+        .get(new AsyncCallback<DiffInfo>() {
+          @Override
+          public void onSuccess(DiffInfo info) {
+            new ModeInjector()
+              .add(getContentType(info.metaA()))
+              .add(getContentType(info.metaB()))
+              .inject(CallbackGroup.<Void> emptyCallback());
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+          }
+        });
+    }
+  }
+
+  void reloadDiffInfo() {
+    final int id = ++reloadVersionId;
+    DiffApi.diff(revision, path)
+      .base(base)
+      .wholeFile()
+      .intraline(prefs.intralineDifference())
+      .ignoreWhitespace(prefs.ignoreWhitespace())
+      .get(new GerritCallback<DiffInfo>() {
+        @Override
+        public void onSuccess(DiffInfo diffInfo) {
+          if (id == reloadVersionId && isAttached()) {
+            diff = diffInfo;
+            operation(new Runnable() {
+              @Override
+              public void run() {
+                skipManager.removeAll();
+                getChunkManager().reset();
+                getDiffTable().scrollbar.removeDiffAnnotations();
+                setShowIntraline(prefs.intralineDifference());
+                render(diff);
+                skipManager.render(prefs.context(), diff);
+              }
+            });
+          }
+        }
+      });
+  }
+
+  private static FileSize bucketFileSize(DiffInfo diff) {
+    FileMeta a = diff.metaA();
+    FileMeta b = diff.metaB();
+    FileSize[] sizes = FileSize.values();
+    for (int i = sizes.length - 1; 0 <= i; i--) {
+      FileSize s = sizes[i];
+      if ((a != null && s.lines <= a.lines())
+          || (b != null && s.lines <= b.lines())) {
+        return s;
+      }
+    }
+    return FileSize.SMALL;
+  }
+
+  abstract Runnable updateActiveLine(CodeMirror cm);
+
+  private GutterClickHandler onGutterClick(final CodeMirror cm) {
+    return new GutterClickHandler() {
+      @Override
+      public void handle(CodeMirror instance, final int line,
+          final String gutterClass, NativeEvent clickEvent) {
+        if (Element.as(clickEvent.getEventTarget())
+                .hasClassName(getLineNumberClassName())
+            && clickEvent.getButton() == NativeEvent.BUTTON_LEFT
+            && !clickEvent.getMetaKey()
+            && !clickEvent.getAltKey()
+            && !clickEvent.getCtrlKey()
+            && !clickEvent.getShiftKey()) {
+          cm.setCursor(Pos.create(line));
+          Scheduler.get().scheduleDeferred(new ScheduledCommand() {
+            @Override
+            public void execute() {
+              getCommentManager().newDraftOnGutterClick(
+                  cm, gutterClass, line + 1);
+            }
+          });
+        }
+      }
+    };
+  }
+
+  abstract FocusHandler getFocusHandler();
+
+  abstract CodeMirror[] getCms();
+
+  abstract CodeMirror getCmFromSide(DisplaySide side);
+
+  abstract DiffTable getDiffTable();
+
+  abstract int getCmLine(int line, DisplaySide side);
+
+  abstract String getLineNumberClassName();
+
+  LineOnOtherInfo lineOnOther(DisplaySide side, int line) {
+    return getChunkManager().lineMapper.lineOnOther(side, line);
+  }
+
+  abstract ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
+      CommentsCollections comments);
+
+  abstract boolean isSideBySide();
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css
new file mode 100644
index 0000000..7569cf5
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.css
@@ -0,0 +1,41 @@
+/* Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+.range {
+  background-color: #ffd500 !important;
+}
+.rangeHighlight {
+  background-color: #ffff00 !important;
+}
+
+.fullscreen {
+  background-color: #f7f7f7;
+  border-bottom: 1px solid #ddd;
+}
+
+@external .diffHeader;
+.diffHeader {
+  font-size: 12px;
+  font-weight: bold;
+  color: #5252ad;
+}
+
+.diffHeader pre {
+  margin: 0 0 3px 0;
+}
+
+@external .dark, .noIntraline, .showLineNumbers;
+.dark {}
+.noIntraline {}
+.showLineNumbers {}
\ No newline at end of file
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
index 4b073f5..392ad2f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
@@ -18,54 +18,43 @@
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
 
 import net.codemirror.lib.CodeMirror;
 
 /**
- * A table with one row and two columns to hold the two CodeMirrors displaying
- * the files to be diffed.
+ * Base class for SideBySideTable2 and UnifiedTable2
  */
-class DiffTable extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, DiffTable> {}
-  private static final Binder uiBinder = GWT.create(Binder.class);
+abstract class DiffTable extends Composite {
+  static {
+    Resources.I.diffTableStyle().ensureInjected();
+  }
 
-  interface DiffTableStyle extends CssResource {
+  interface Style extends CssResource {
     String fullscreen();
-    String intralineBg();
     String dark();
-    String diff();
     String noIntraline();
     String range();
     String rangeHighlight();
+    String diffHeader();
     String showLineNumbers();
-    String hideA();
-    String hideB();
-    String padding();
   }
 
-  @UiField Element cmA;
-  @UiField Element cmB;
-  Scrollbar scrollbar;
   @UiField Element patchSetNavRow;
   @UiField Element patchSetNavCellA;
   @UiField Element patchSetNavCellB;
   @UiField Element diffHeaderRow;
   @UiField Element diffHeaderText;
   @UiField FlowPanel widgets;
-  @UiField static DiffTableStyle style;
 
   @UiField(provided = true)
   PatchSetSelectBox patchSetSelectBoxA;
@@ -73,88 +62,58 @@
   @UiField(provided = true)
   PatchSetSelectBox patchSetSelectBoxB;
 
-  private SideBySide parent;
   private boolean header;
-  private boolean visibleA;
   private ChangeType changeType;
+  Scrollbar scrollbar;
 
-  DiffTable(SideBySide parent, PatchSet.Id base, PatchSet.Id revision,
-      String path) {
+  DiffTable(DiffScreen parent, PatchSet.Id base, PatchSet.Id revision, String path) {
     patchSetSelectBoxA = new PatchSetSelectBox(
         parent, DisplaySide.A, revision.getParentKey(), base, path);
     patchSetSelectBoxB = new PatchSetSelectBox(
         parent, DisplaySide.B, revision.getParentKey(), revision, path);
     PatchSetSelectBox.link(patchSetSelectBoxA, patchSetSelectBoxB);
 
-    initWidget(uiBinder.createAndBindUi(this));
     this.scrollbar = new Scrollbar(this);
-    this.parent = parent;
-    this.visibleA = true;
   }
 
-  boolean isVisibleA() {
-    return visibleA;
-  }
-
-  void setVisibleA(boolean show) {
-    visibleA = show;
-    if (show) {
-      removeStyleName(style.hideA());
-      parent.syncScroll(DisplaySide.B); // match B's viewport
-    } else {
-      addStyleName(style.hideA());
-    }
-  }
-
-  Runnable toggleA() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        setVisibleA(!isVisibleA());
-      }
-    };
-  }
-
-  void setVisibleB(boolean show) {
-    if (show) {
-      removeStyleName(style.hideB());
-      parent.syncScroll(DisplaySide.A); // match A's viewport
-    } else {
-      addStyleName(style.hideB());
-    }
-  }
+  abstract boolean isVisibleA();
 
   void setHeaderVisible(boolean show) {
+    DiffScreen parent = getDiffScreen();
     if (show != UIObject.isVisible(patchSetNavRow)) {
       UIObject.setVisible(patchSetNavRow, show);
       UIObject.setVisible(diffHeaderRow, show && header);
       if (show) {
-        parent.header.removeStyleName(style.fullscreen());
+        parent.header.removeStyleName(Resources.I.diffTableStyle().fullscreen());
       } else {
-        parent.header.addStyleName(style.fullscreen());
+        parent.header.addStyleName(Resources.I.diffTableStyle().fullscreen());
       }
       parent.resizeCodeMirror();
     }
   }
 
-  int getHeaderHeight() {
-    int h = patchSetSelectBoxA.getOffsetHeight();
-    if (header) {
-      h += diffHeaderRow.getOffsetHeight();
-    }
-    return h;
-  }
+  abstract int getHeaderHeight();
 
   ChangeType getChangeType() {
     return changeType;
   }
 
-  void set(DiffPreferences prefs, JsArray<RevisionInfo> list, DiffInfo info,
+  void setUpBlameIconA(CodeMirror cm, boolean isBase, PatchSet.Id rev,
+      String path) {
+    patchSetSelectBoxA.setUpBlame(cm, isBase, rev, path);
+  }
+
+  void setUpBlameIconB(CodeMirror cm, PatchSet.Id rev,
+      String path) {
+    patchSetSelectBoxB.setUpBlame(cm, false, rev, path);
+  }
+
+  void set(DiffPreferences prefs, JsArray<RevisionInfo> list, int parents, DiffInfo info,
       boolean editExists, boolean current, boolean open, boolean binary) {
     this.changeType = info.changeType();
-    patchSetSelectBoxA.setUpPatchSetNav(list, info.metaA(), editExists,
+    patchSetSelectBoxA.setUpPatchSetNav(list, parents, info.metaA(), editExists,
         current, open, binary);
-    patchSetSelectBoxB.setUpPatchSetNav(list, info.metaB(), editExists,
+    patchSetSelectBoxB.setUpPatchSetNav(list, parents, info.metaB(), editExists,
         current, open, binary);
 
     JsArrayString hdr = info.diffHeader();
@@ -162,10 +121,11 @@
       StringBuilder b = new StringBuilder();
       for (int i = 1; i < hdr.length(); i++) {
         String s = hdr.get(i);
-        if (s.startsWith("diff --git ")
+        if (!info.binary()
+            && (s.startsWith("diff --git ")
             || s.startsWith("index ")
             || s.startsWith("+++ ")
-            || s.startsWith("--- ")) {
+            || s.startsWith("--- "))) {
           continue;
         }
         b.append(s).append('\n');
@@ -182,17 +142,11 @@
     setHideEmptyPane(prefs.hideEmptyPane());
   }
 
-  void setHideEmptyPane(boolean hide) {
-    if (changeType == ChangeType.ADDED) {
-      setVisibleA(!hide);
-    } else if (changeType == ChangeType.DELETED) {
-      setVisibleB(!hide);
-    }
-  }
+  abstract void setHideEmptyPane(boolean hide);
 
   void refresh() {
     if (header) {
-      CodeMirror cm = parent.getCmFromSide(DisplaySide.A);
+      CodeMirror cm = getDiffScreen().getCmFromSide(DisplaySide.A);
       diffHeaderText.getStyle().setMarginLeft(
           cm.getGutterElement().getOffsetWidth(),
           Unit.PX);
@@ -202,4 +156,10 @@
   void add(Widget widget) {
     widgets.add(widget);
   }
+
+  abstract DiffScreen getDiffScreen();
+
+  boolean hasHeader() {
+    return header;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml
deleted file mode 100644
index e72cf27..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.ui.xml
+++ /dev/null
@@ -1,167 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:d='urn:import:com.google.gerrit.client.diff'>
-  <ui:style gss='false' type='com.google.gerrit.client.diff.DiffTable.DiffTableStyle'>
-    @external .CodeMirror, .CodeMirror-selectedtext;
-    @external .CodeMirror-linenumber;
-    @external .CodeMirror-overlayscroll-vertical, .CodeMirror-scroll;
-    @external .CodeMirror-dialog-bottom;
-    @external .CodeMirror-cursor;
-
-    .fullscreen {
-      background-color: #f7f7f7;
-      border-bottom: 1px solid #ddd;
-    }
-
-    .difftable .patchSetNav,
-    .difftable .CodeMirror {
-      -webkit-touch-callout: none;
-      -webkit-user-select: none;
-      -khtml-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-    }
-
-    .difftable .CodeMirror pre {
-      overflow: hidden;
-      border-right: 0;
-      width: auto;
-    }
-
-    /* Preserve space for underscores. If this changes
-     * see ChunkManager.addPadding() and adjust there.
-     */
-    .difftable .CodeMirror pre,
-    .difftable .CodeMirror pre span {
-      padding-bottom: 1px;
-    }
-
-    .hideA .psNavA,
-    .hideA .a {
-      display: none;
-    }
-
-    .hideB .psNavB,
-    .hideB .b {
-      display: none;
-    }
-
-    .table {
-      width: 100%;
-      table-layout: fixed;
-      border-spacing: 0;
-    }
-    .table td { padding: 0 }
-    .a, .b { width: 50% }
-    .hideA .psNavB, .hideA .b { width: 100% }
-    .hideB .psNavA, .hideB .a { width: 100% }
-
-    /* Hide scrollbars on A, B controls both views. */
-    .a .CodeMirror-scroll { margin-right: -36px; }
-    .a .CodeMirror-overlayscroll-vertical { display: none !important; }
-
-    .showLineNumbers .b { border-left: none; }
-    .b { border-left: 1px solid #ddd; }
-
-    .a .diff { background-color: #faa; }
-    /* Set min-width for lineWrapping to make sure it gets enough width
-       before lineWrapping and to make sure it dosent do a ugly line wrap */
-    .b .diff { background-color: #9f9; min-width: 60em; }
-    .a .intralineBg { background-color: #fee; }
-    .b .intralineBg { background-color: #dfd; }
-    .noIntraline .a .intralineBg { background-color: #faa; }
-    .noIntraline .b .intralineBg { background-color: #9f9; }
-
-    .dark .a .diff { background-color: #400; }
-    .dark .b .diff { background-color: #444; }
-
-    .dark .a .intralineBg { background-color: #888; }
-    .dark .b .intralineBg { background-color: #bbb; }
-    .dark .noIntraline .a .intralineBg { background-color: #400; }
-    .dark .noIntraline .b .intralineBg { background-color: #444; }
-
-    .patchSetNav, .diff_header {
-      background-color: #f7f7f7;
-      line-height: 1;
-    }
-    .fileCommentCell {
-      overflow-x: auto;
-    }
-
-    .range {
-      background-color: #ffd500 !important;
-    }
-    .rangeHighlight {
-      background-color: #ffff00 !important;
-    }
-    .difftable .CodeMirror-selectedtext {
-      background-color: inherit !important;
-    }
-    .difftable .CodeMirror-linenumber {
-      height: 1.11em;
-      cursor: pointer;
-    }
-    .difftable .CodeMirror div.CodeMirror-cursor {
-      border-left: 2px solid black;
-    }
-    .difftable .CodeMirror-dialog-bottom {
-      border-top: 0;
-      border-left: 1px solid #000;
-      border-bottom: 1px solid #000;
-      background-color: #f7f7f7;
-      top: 0;
-      right: 0;
-      bottom: auto;
-      left: auto;
-    }
-    .showLineNumbers .padding {
-      margin-left: 21px;
-      border-left: 2px solid #d64040;
-    }
-
-    .diff_header {
-      font-size: 12px;
-      font-weight: bold;
-      color: #5252ad;
-    }
-    .diff_header pre {
-      margin: 0 0 3px 0;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.difftable}'>
-    <table class='{style.table}'>
-      <tr ui:field='patchSetNavRow' class='{style.patchSetNav}'>
-        <td ui:field='patchSetNavCellA' class='{style.psNavA}'>
-          <d:PatchSetSelectBox ui:field='patchSetSelectBoxA' />
-        </td>
-        <td ui:field='patchSetNavCellB' class='{style.psNavB}'>
-          <d:PatchSetSelectBox ui:field='patchSetSelectBoxB' />
-        </td>
-      </tr>
-      <tr ui:field='diffHeaderRow' class='{style.diff_header}'>
-        <td colspan='2'><pre ui:field='diffHeaderText' /></td>
-      </tr>
-      <tr>
-        <td ui:field='cmA' class='{style.a}' />
-        <td ui:field='cmB' class='{style.b}' />
-      </tr>
-    </table>
-    <g:FlowPanel ui:field='widgets' visible='false'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
index 4dfcd8c..c7ee678 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
@@ -16,5 +16,9 @@
 
 /** Enum representing the side on a side-by-side view */
 public enum DisplaySide {
-  A, B
+  A, B;
+
+  DisplaySide otherSide() {
+    return this == A ? B : A;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
index 21b2f50..d814a57 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.change.LocalComments;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
@@ -190,7 +192,7 @@
     setRangeHighlight(edit);
     if (edit) {
       String msg = comment.message() != null
-          ? comment.message().trim()
+          ? comment.message()
           : "";
       editArea.setValue(msg);
       cancel.setVisible(!isNew());
@@ -289,6 +291,7 @@
     enableEdit(false);
 
     pendingGroup = group;
+    final LocalComments lc = new LocalComments(psId);
     GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
       @Override
       public void onSuccess(CommentInfo result) {
@@ -306,6 +309,11 @@
       public void onFailure(Throwable e) {
         enableEdit(true);
         pendingGroup = null;
+        if (RestApi.isNotSignedIn(e)) {
+          CommentInfo saved = CommentInfo.copy(comment);
+          saved.message(editArea.getValue().trim());
+          lc.setInlineComment(saved);
+        }
         super.onFailure(e);
       }
     };
@@ -380,14 +388,13 @@
         removeUI();
         restoreSelection();
         return;
-      } else {
-        setEdit(false);
-        if (autoClosed) {
-          setOpen(false);
-        }
-        getCm().focus();
-        return;
       }
+      setEdit(false);
+      if (autoClosed) {
+        setOpen(false);
+      }
+      getCm().focus();
+      return;
     }
     expandTimer.schedule(250);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index eec3d0c..f377038 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.account.DiffPreferences;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.ReviewInfo;
 import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.diff.DiffInfo.Region;
 import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.FileInfo;
-import com.google.gerrit.client.info.GitwebInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.patches.PatchUtil;
 import com.google.gerrit.client.rpc.CallbackGroup;
@@ -33,7 +32,9 @@
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
@@ -67,13 +68,15 @@
     Resources.I.style().ensureInjected();
   }
 
-  private static enum ReviewedState {
+  private enum ReviewedState {
     AUTO_REVIEW, LOADED
   }
 
   @UiField CheckBox reviewed;
   @UiField Element project;
   @UiField Element filePath;
+  @UiField Element fileNumber;
+  @UiField Element fileCount;
 
   @UiField Element noDiff;
   @UiField FlowPanel linkPanel;
@@ -87,80 +90,69 @@
   private final PatchSet.Id base;
   private final PatchSet.Id patchSetId;
   private final String path;
+  private final DiffView diffScreenType;
+  private final DiffPreferences prefs;
   private boolean hasPrev;
   private boolean hasNext;
   private String nextPath;
+  private JsArray<FileInfo> files;
   private PreferencesAction prefsAction;
   private ReviewedState reviewedState;
 
   Header(KeyCommandSet keys, PatchSet.Id base, PatchSet.Id patchSetId,
-      String path) {
+      String path, DiffView diffSreenType, DiffPreferences prefs) {
     initWidget(uiBinder.createAndBindUi(this));
     this.keys = keys;
     this.base = base;
     this.patchSetId = patchSetId;
     this.path = path;
+    this.diffScreenType = diffSreenType;
+    this.prefs = prefs;
 
     if (!Gerrit.isSignedIn()) {
       reviewed.getElement().getStyle().setVisibility(Visibility.HIDDEN);
     }
-    SafeHtml.setInnerHTML(filePath, formatPath(path, null, null));
+    SafeHtml.setInnerHTML(filePath, formatPath(path));
     up.setTargetHistoryToken(PageLinks.toChange(
         patchSetId.getParentKey(),
         base != null ? base.getId() : null, patchSetId.getId()));
   }
 
-  public static SafeHtml formatPath(String path, String project, String commit) {
+  public static SafeHtml formatPath(String path) {
     SafeHtmlBuilder b = new SafeHtmlBuilder();
     if (Patch.COMMIT_MSG.equals(path)) {
       return b.append(Util.C.commitMessage());
     }
 
-    GitwebInfo gw = (project != null && commit != null)
-        ? Gerrit.info().gitweb() : null;
     int s = path.lastIndexOf('/') + 1;
-    if (gw != null && s > 0) {
-      String base = path.substring(0, s - 1);
-      b.openAnchor()
-          .setAttribute("href", gw.toFile(project, commit, base))
-          .setAttribute("title", gw.getLinkName())
-          .append(base)
-          .closeAnchor()
-          .append('/');
-    } else {
-      b.append(path.substring(0, s));
-    }
+    b.append(path.substring(0, s));
     b.openElement("b");
     b.append(path.substring(s));
     b.closeElement("b");
     return b;
   }
 
+  private int findCurrentFileIndex(JsArray<FileInfo> files) {
+    int currIndex = 0;
+    for (int i = 0; i < files.length(); i++) {
+      if (path.equals(files.get(i).path())) {
+        currIndex = i;
+        break;
+      }
+    }
+    return currIndex;
+  }
+
   @Override
   protected void onLoad() {
     DiffApi.list(patchSetId, base, new GerritCallback<NativeMap<FileInfo>>() {
       @Override
       public void onSuccess(NativeMap<FileInfo> result) {
-        JsArray<FileInfo> files = result.values();
+        files = result.values();
         FileInfo.sortFileInfoByPath(files);
-        int index = 0; // TODO: Maybe use patchIndex.
-        for (int i = 0; i < files.length(); i++) {
-          if (path.equals(files.get(i).path())) {
-            index = i;
-            break;
-          }
-        }
-        FileInfo nextInfo = index == files.length() - 1
-            ? null
-            : files.get(index + 1);
-        KeyCommand p = setupNav(prev, '[', PatchUtil.C.previousFileHelp(),
-            index == 0 ? null : files.get(index - 1));
-        KeyCommand n = setupNav(next, ']', PatchUtil.C.nextFileHelp(),
-            nextInfo);
-        if (p != null && n != null) {
-          keys.pair(p, n);
-        }
-        nextPath = nextInfo != null ? nextInfo.path() : null;
+        fileNumber.setInnerText(
+            Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
+        fileCount.setInnerText(Integer.toString(files.length()));
       }
     });
 
@@ -194,23 +186,6 @@
   }
 
   void setChangeInfo(ChangeInfo info) {
-    GitwebInfo gw = Gerrit.info().gitweb();
-    if (gw != null) {
-      for (RevisionInfo rev : Natives.asList(info.revisions().values())) {
-        if (patchSetId.getId().equals(rev.id())) {
-          String c = rev.name();
-          SafeHtml.setInnerHTML(filePath, formatPath(path, info.project(), c));
-          SafeHtml.setInnerHTML(project, new SafeHtmlBuilder()
-              .openAnchor()
-              .setAttribute("href", gw.toFile(info.project(), c, ""))
-              .setAttribute("title", gw.getLinkName())
-              .append(info.project())
-              .closeAnchor());
-          return;
-        }
-      }
-    }
-
     project.setInnerText(info.project());
   }
 
@@ -262,9 +237,9 @@
   }
 
   private String url(FileInfo info) {
-    return info.binary()
-      ? Dispatcher.toUnified(base, patchSetId, info.path())
-      : Dispatcher.toSideBySide(base, patchSetId, info.path());
+    return diffScreenType == DiffView.UNIFIED_DIFF
+        ? Dispatcher.toUnified(base, patchSetId, info.path())
+        : Dispatcher.toSideBySide(base, patchSetId, info.path());
   }
 
   private KeyCommand setupNav(InlineHyperlink link, char key, String help, FileInfo info) {
@@ -287,11 +262,46 @@
         hasNext = true;
       }
       return k;
-    } else {
-      link.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-      keys.add(new UpToChangeCommand(patchSetId, 0, key));
-      return null;
     }
+    link.getElement().getStyle().setVisibility(Visibility.HIDDEN);
+    keys.add(new UpToChangeCommand(patchSetId, 0, key));
+    return null;
+  }
+
+  private boolean shouldSkipFile(FileInfo curr, CommentsCollections comments) {
+    return prefs.skipDeleted() && ChangeType.DELETED.matches(curr.status())
+        || prefs.skipUnchanged() && ChangeType.RENAMED.matches(curr.status())
+        || prefs.skipUncommented() && !comments.hasCommentForPath(curr.path());
+  }
+
+  void setupPrevNextFiles(CommentsCollections comments) {
+    FileInfo prevInfo = null;
+    FileInfo nextInfo = null;
+    int currIndex = findCurrentFileIndex(files);
+    for (int i = currIndex - 1; i >= 0; i--) {
+      FileInfo curr = files.get(i);
+      if (shouldSkipFile(curr, comments)) {
+        continue;
+      }
+      prevInfo = curr;
+      break;
+    }
+    for (int i = currIndex + 1; i < files.length(); i++) {
+      FileInfo curr = files.get(i);
+      if (shouldSkipFile(curr, comments)) {
+        continue;
+      }
+      nextInfo = curr;
+      break;
+    }
+    KeyCommand p = setupNav(prev, '[', PatchUtil.C.previousFileHelp(),
+        prevInfo);
+    KeyCommand n = setupNav(next, ']', PatchUtil.C.nextFileHelp(),
+        nextInfo);
+    if (p != null && n != null) {
+      keys.pair(p, n);
+    }
+    nextPath = nextInfo != null ? nextInfo.path() : null;
   }
 
   Runnable toggleReviewed() {
@@ -345,9 +355,13 @@
   }
 
   void setNoDiff(DiffInfo diff) {
-    JsArray<Region> regions = diff.content();
-    boolean b = regions.length() == 0
-        || (regions.length() == 1 && regions.get(0).ab() != null);
-    UIObject.setVisible(noDiff, b);
+    if (diff.binary()) {
+      UIObject.setVisible(noDiff, false); // Don't bother showing "No Differences"
+    } else {
+      JsArray<Region> regions = diff.content();
+      boolean b = regions.length() == 0
+          || (regions.length() == 1 && regions.get(0).ab() != null);
+      UIObject.setVisible(noDiff, b);
+    }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
index f13c9a3..39eb6cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
@@ -34,6 +34,11 @@
   .path {
     white-space: nowrap;
   }
+  .fileCount {
+    white-space: nowrap;
+    position: relative;
+    bottom: 4px;
+  }
   .navigation {
     position: absolute;
     top: 0;
@@ -69,6 +74,9 @@
     <div class='{style.navigation}'>
       <span ui:field='noDiff' class='{style.nodiff}'><ui:msg>No Differences</ui:msg></span>
       <g:FlowPanel ui:field='linkPanel' styleName='{style.linkPanel}'/>
+      <span class='{style.fileCount}'>
+        <ui:msg>File <span ui:field='fileNumber'/> of <span ui:field='fileCount'/></ui:msg>
+      </span>
       <x:InlineHyperlink ui:field='prev' styleName='{res.style.goPrev}'/>
       <x:InlineHyperlink ui:field='up'
           styleName='{res.style.goUp}'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
index 7c8bc21..ac295e3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
@@ -43,7 +43,7 @@
       @Override
       public void onClick(ClickEvent event) {
         setVisible(false);
-        commentManager.insertNewDraft(cm).run();
+        commentManager.newDraftCallback(cm).run();
       }
     }, ClickEvent.getType());
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
index 9ec760c..96128f3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
@@ -118,17 +118,16 @@
     int ret = Collections.binarySearch(lineGaps, new LineGap(line));
     if (ret == -1) {
       return new LineOnOtherInfo(line, true);
-    } else {
-      LineGap lookup = lineGaps.get(0 <= ret ? ret : -ret - 2);
-      int start = lookup.start;
-      int end = lookup.end;
-      int delta = lookup.delta;
-      if (start <= line && line <= end && end != -1) { // Line falls within gap
-        return new LineOnOtherInfo(end + delta, false);
-      } else { // Line after gap
-        return new LineOnOtherInfo(line + delta, true);
-      }
     }
+    LineGap lookup = lineGaps.get(0 <= ret ? ret : -ret - 2);
+    int start = lookup.start;
+    int end = lookup.end;
+    int delta = lookup.delta;
+    if (start <= line && line <= end && end != -1) { // Line falls within gap
+      return new LineOnOtherInfo(end + delta, false);
+    }
+    // Line after gap
+    return new LineOnOtherInfo(line + delta, true);
   }
 
   AlignedPair align(DisplaySide mySide, int line) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
index c22769e..969b861 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
@@ -26,4 +26,4 @@
   @Override
   public void onKeyPress(KeyPressEvent event) {
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
index 186cd98..bc37abb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
@@ -16,9 +16,13 @@
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.blame.BlameInfo;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.reviewdb.client.Change;
@@ -27,6 +31,7 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.resources.client.CssResource;
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
@@ -39,6 +44,8 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtorm.client.KeyUtil;
 
+import net.codemirror.lib.CodeMirror;
+
 import java.util.List;
 
 /** HTMLPanel to select among patch sets */
@@ -54,7 +61,7 @@
   @UiField HTMLPanel linkPanel;
   @UiField BoxStyle style;
 
-  private SideBySide parent;
+  private DiffScreen parent;
   private DisplaySide side;
   private boolean sideA;
   private String path;
@@ -63,7 +70,7 @@
   private PatchSet.Id idActive;
   private PatchSetSelectBox other;
 
-  PatchSetSelectBox(SideBySide parent,
+  PatchSetSelectBox(DiffScreen parent,
       DisplaySide side,
       Change.Id changeId,
       PatchSet.Id revision,
@@ -81,13 +88,29 @@
     this.path = path;
   }
 
-  void setUpPatchSetNav(JsArray<RevisionInfo> list, DiffInfo.FileMeta meta,
+  void setUpPatchSetNav(JsArray<RevisionInfo> list, int parents, DiffInfo.FileMeta meta,
       boolean editExists, boolean current, boolean open, boolean binary) {
-    InlineHyperlink baseLink = null;
     InlineHyperlink selectedLink = null;
     if (sideA) {
-      baseLink = createLink(PatchUtil.C.patchBase(), null);
-      linkPanel.add(baseLink);
+      if (parents <= 1) {
+        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), null);
+        linkPanel.add(link);
+        selectedLink = link;
+      } else {
+        for (int i = parents; i > 0; i--) {
+          PatchSet.Id id = new PatchSet.Id(changeId, -i);
+          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), id);
+          linkPanel.add(link);
+          if (revision != null && id.equals(revision)) {
+            selectedLink = link;
+          }
+        }
+        InlineHyperlink link = createLink(Util.C.autoMerge(), null);
+        linkPanel.add(link);
+        if (selectedLink == null) {
+          selectedLink = link;
+        }
+      }
     }
     for (int i = 0; i < list.length(); i++) {
       RevisionInfo r = list.get(i);
@@ -100,8 +123,6 @@
     }
     if (selectedLink != null) {
       selectedLink.setStyleName(style.selected());
-    } else if (sideA) {
-      baseLink.setStyleName(style.selected());
     }
 
     if (meta == null) {
@@ -124,6 +145,32 @@
     }
   }
 
+  void setUpBlame(final CodeMirror cm, final boolean isBase,
+      final PatchSet.Id rev, final String path) {
+    if (!Patch.COMMIT_MSG.equals(path) && Gerrit.isSignedIn()
+        && Gerrit.info().change().allowBlame()) {
+      Anchor blameIcon = createBlameIcon();
+      blameIcon.addClickHandler(new ClickHandler() {
+        @Override
+        public void onClick(ClickEvent clickEvent) {
+          if (cm.extras().getBlameInfo() != null) {
+            cm.extras().toggleAnnotation();
+          } else {
+            ChangeApi.blame(rev, path, isBase)
+              .get(new GerritCallback<JsArray<BlameInfo>>() {
+
+                @Override
+                public void onSuccess(JsArray<BlameInfo> lines) {
+                  cm.extras().toggleAnnotation(lines);
+                }
+              });
+          }
+        }
+      });
+      linkPanel.add(blameIcon);
+    }
+  }
+
   private Widget createEditIcon() {
     PatchSet.Id id = (idActive == null) ? other.idActive : idActive;
     Anchor anchor = new Anchor(
@@ -133,6 +180,13 @@
     return anchor;
   }
 
+  private Anchor createBlameIcon() {
+    Anchor anchor = new Anchor(
+        new ImageResourceRenderer().render(Gerrit.RESOURCES.blame()));
+    anchor.setTitle(PatchUtil.C.blame());
+    return anchor;
+  }
+
   static void link(PatchSetSelectBox a, PatchSetSelectBox b) {
     a.other = b;
     b.other = a;
@@ -143,10 +197,13 @@
     if (sideA) {
       assert other.idActive != null;
     }
-    return new InlineHyperlink(label, Dispatcher.toSideBySide(
-        sideA ? id : other.idActive,
-        sideA ? other.idActive : id,
-        path));
+    PatchSet.Id diffBase = sideA ? id : other.idActive;
+    PatchSet.Id revision = sideA ? other.idActive : id;
+
+    return new InlineHyperlink(label,
+        parent.isSideBySide()
+            ? Dispatcher.toSideBySide(diffBase, revision, path)
+            : Dispatcher.toUnified(diffBase, revision, path));
   }
 
   private Anchor createDownloadLink() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
index 869b4a3..3f7a0ab 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
@@ -22,13 +22,13 @@
 import com.google.gwt.user.client.ui.Widget;
 
 class PreferencesAction {
-  private final SideBySide view;
+  private final DiffScreen view;
   private final DiffPreferences prefs;
   private PopupPanel popup;
   private PreferencesBox current;
   private Widget partner;
 
-  PreferencesAction(SideBySide view, DiffPreferences prefs) {
+  PreferencesAction(DiffScreen view, DiffPreferences prefs) {
     this.view = view;
     this.prefs = prefs;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index c5b1f96..78d01db 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -58,6 +58,7 @@
 import com.google.gwt.user.client.ui.ToggleButton;
 import com.google.gwt.user.client.ui.UIObject;
 
+import net.codemirror.lib.CodeMirror;
 import net.codemirror.mode.ModeInfo;
 import net.codemirror.mode.ModeInjector;
 import net.codemirror.theme.ThemeLoader;
@@ -73,7 +74,7 @@
     String dialog();
   }
 
-  private final SideBySide view;
+  private final DiffScreen view;
   private DiffPreferences prefs;
   private int contextLastValue;
   private Timer updateContextTimer;
@@ -102,13 +103,16 @@
   @UiField ToggleButton renderEntireFile;
   @UiField ToggleButton matchBrackets;
   @UiField ToggleButton lineWrapping;
+  @UiField ToggleButton skipDeleted;
+  @UiField ToggleButton skipUnchanged;
+  @UiField ToggleButton skipUncommented;
   @UiField ListBox theme;
   @UiField Element modeLabel;
   @UiField ListBox mode;
   @UiField Button apply;
   @UiField Button save;
 
-  public PreferencesBox(SideBySide view) {
+  public PreferencesBox(DiffScreen view) {
     this.view = view;
 
     initWidget(uiBinder.createAndBindUi(this));
@@ -168,7 +172,7 @@
 
     setIgnoreWhitespace(prefs.ignoreWhitespace());
     tabWidth.setIntValue(prefs.tabSize());
-    if (view != null && Patch.COMMIT_MSG.equals(view.getPath())) {
+    if (view != null && Patch.COMMIT_MSG.equals(view.path)) {
       lineLength.setEnabled(false);
       lineLength.setIntValue(72);
     } else {
@@ -182,9 +186,9 @@
     lineNumbers.setValue(prefs.showLineNumbers());
     emptyPane.setValue(!prefs.hideEmptyPane());
     if (view != null) {
-      leftSide.setValue(view.diffTable.isVisibleA());
+      leftSide.setValue(view.getDiffTable().isVisibleA());
       leftSide.setEnabled(!(prefs.hideEmptyPane()
-          && view.diffTable.getChangeType() == ChangeType.ADDED));
+          && view.getDiffTable().getChangeType() == ChangeType.ADDED));
     } else {
       UIObject.setVisible(leftSideLabel, false);
       leftSide.setVisible(false);
@@ -195,6 +199,9 @@
     expandAllComments.setValue(prefs.expandAllComments());
     matchBrackets.setValue(prefs.matchBrackets());
     lineWrapping.setValue(prefs.lineWrapping());
+    skipDeleted.setValue(!prefs.skipDeleted());
+    skipUnchanged.setValue(!prefs.skipUnchanged());
+    skipUncommented.setValue(!prefs.skipUncommented());
     setTheme(prefs.theme());
 
     if (view == null || view.canRenderEntireFile(prefs)) {
@@ -317,8 +324,9 @@
           @Override
           public void run() {
             int v = prefs.tabSize();
-            view.getCmFromSide(DisplaySide.A).setOption("tabSize", v);
-            view.getCmFromSide(DisplaySide.B).setOption("tabSize", v);
+            for (CodeMirror cm : view.getCms()) {
+              cm.setOption("tabSize", v);
+            }
           }
         });
       }
@@ -380,21 +388,23 @@
 
   @UiHandler("leftSide")
   void onLeftSide(ValueChangeEvent<Boolean> e) {
-    view.diffTable.setVisibleA(e.getValue());
+    if (view.getDiffTable() instanceof SideBySideTable) {
+      ((SideBySideTable) view.getDiffTable()).setVisibleA(e.getValue());
+    }
   }
 
   @UiHandler("emptyPane")
   void onHideEmptyPane(ValueChangeEvent<Boolean> e) {
     prefs.hideEmptyPane(!e.getValue());
     if (view != null) {
-      view.diffTable.setHideEmptyPane(prefs.hideEmptyPane());
+      view.getDiffTable().setHideEmptyPane(prefs.hideEmptyPane());
       if (prefs.hideEmptyPane()) {
-        if (view.diffTable.getChangeType() == ChangeType.ADDED) {
+        if (view.getDiffTable().getChangeType() == ChangeType.ADDED) {
           leftSide.setValue(false);
           leftSide.setEnabled(false);
         }
       } else {
-        leftSide.setValue(view.diffTable.isVisibleA());
+        leftSide.setValue(view.getDiffTable().isVisibleA());
         leftSide.setEnabled(true);
       }
     }
@@ -470,8 +480,9 @@
         @Override
         public void run() {
           boolean s = prefs.showWhitespaceErrors();
-          view.getCmFromSide(DisplaySide.A).setOption("showTrailingSpace", s);
-          view.getCmFromSide(DisplaySide.B).setOption("showTrailingSpace", s);
+          for (CodeMirror cm : view.getCms()) {
+            cm.setOption("showTrailingSpace", s);
+          }
         }
       });
     }
@@ -503,6 +514,24 @@
         prefs.lineWrapping());
   }
 
+  @UiHandler("skipDeleted")
+  void onSkipDeleted(ValueChangeEvent<Boolean> e) {
+    prefs.skipDeleted(!e.getValue());
+    // TODO: Update the navigation links on the current DiffScreen
+  }
+
+  @UiHandler("skipUnchanged")
+  void onSkipUnchanged(ValueChangeEvent<Boolean> e) {
+    prefs.skipUnchanged(!e.getValue());
+    // TODO: Update the navigation links on the current DiffScreen
+  }
+
+  @UiHandler("skipUncommented")
+  void onSkipUncommented(ValueChangeEvent<Boolean> e) {
+    prefs.skipUncommented(!e.getValue());
+    // TODO: Update the navigation links on the current DiffScreen
+  }
+
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
     final Theme newTheme = getSelectedTheme();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml
index 7dbbc21..4465d63 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.ui.xml
@@ -305,6 +305,27 @@
         </g:ToggleButton></td>
       </tr>
       <tr>
+        <th><ui:msg>Skip Deleted Files</ui:msg></th>
+        <td><g:ToggleButton ui:field='skipDeleted'>
+          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
+          <g:downFace><ui:msg>No</ui:msg></g:downFace>
+        </g:ToggleButton></td>
+      </tr>
+      <tr>
+        <th><ui:msg>Skip Unchanged Files</ui:msg></th>
+        <td><g:ToggleButton ui:field='skipUnchanged'>
+          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
+          <g:downFace><ui:msg>No</ui:msg></g:downFace>
+        </g:ToggleButton></td>
+      </tr>
+      <tr>
+        <th><ui:msg>Skip Uncommented Files</ui:msg></th>
+        <td><g:ToggleButton ui:field='skipUncommented'>
+          <g:upFace><ui:msg>Yes</ui:msg></g:upFace>
+          <g:downFace><ui:msg>No</ui:msg></g:downFace>
+        </g:ToggleButton></td>
+      </tr>
+      <tr>
         <td></td>
         <td>
           <g:Button ui:field='apply' styleName='{style.apply}'>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
index 48b4c3c..dc2e3a2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.api.ApiGlue;
+import com.google.gerrit.client.change.ReplyBox;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.changes.Util;
@@ -44,12 +46,13 @@
   interface Binder extends UiBinder<HTMLPanel, PublishedBox> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  static interface Style extends CssResource {
+  interface Style extends CssResource {
     String closed();
   }
 
   private final PatchSet.Id psId;
   private final CommentInfo comment;
+  private final DisplaySide displaySide;
   private DraftBox replyBox;
 
   @UiField Style style;
@@ -71,11 +74,13 @@
       CommentLinkProcessor clp,
       PatchSet.Id psId,
       CommentInfo info,
+      DisplaySide displaySide,
       boolean open) {
     super(group, info.range());
 
     this.psId = psId;
     this.comment = info;
+    this.displaySide = displaySide;
 
     if (info.author() != null) {
       avatar = new AvatarImage(info.author());
@@ -99,6 +104,7 @@
       summary.setInnerText(msg);
       message.setInnerSafeHtml(clp.apply(
           new SafeHtmlBuilder().append(msg).wikify()));
+      ApiGlue.fireEvent("comment", message);
     }
 
     fix.setVisible(open);
@@ -143,17 +149,21 @@
     replyBox.setEdit(true);
   }
 
-  void addReplyBox() {
+  void addReplyBox(boolean quote) {
+    CommentInfo commentReply = CommentInfo.createReply(comment);
+    if (quote) {
+      commentReply.message(ReplyBox.quote(comment.message()));
+    }
     getCommentManager().addDraftBox(
-      getCm().side(),
-      CommentInfo.createReply(comment)).setEdit(true);
+      displaySide,
+      commentReply).setEdit(true);
   }
 
   void doReply() {
     if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(getCommentManager().getSideBySide().getToken());
+      Gerrit.doSignIn(getCommentManager().host.getToken());
     } else if (replyBox == null) {
-      addReplyBox();
+      addReplyBox(false);
     } else {
       openReplyBox();
     }
@@ -165,11 +175,20 @@
     doReply();
   }
 
+  @UiHandler("quote")
+  void onQuote(ClickEvent e) {
+    e.stopPropagation();
+    if (!Gerrit.isSignedIn()) {
+      Gerrit.doSignIn(getCommentManager().host.getToken());
+    }
+    addReplyBox(true);
+  }
+
   @UiHandler("done")
   void onReplyDone(ClickEvent e) {
     e.stopPropagation();
     if (!Gerrit.isSignedIn()) {
-      Gerrit.doSignIn(getCommentManager().getSideBySide().getToken());
+      Gerrit.doSignIn(getCommentManager().host.getToken());
     } else if (replyBox == null) {
       done.setEnabled(false);
       CommentInfo input = CommentInfo.createReply(comment);
@@ -180,7 +199,7 @@
             public void onSuccess(CommentInfo result) {
               done.setEnabled(true);
               setOpen(false);
-              getCommentManager().addDraftBox(getCm().side(), result);
+              getCommentManager().addDraftBox(displaySide, result);
             }
           });
     } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
index 46b76ca..cbea847 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
@@ -61,6 +61,11 @@
           <ui:attribute name='title'/>
           <div><ui:msg>Reply</ui:msg></div>
         </g:Button>
+        <g:Button ui:field='quote' styleName=''
+            title='Reply to this comment with quoting it'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Quote</ui:msg></div>
+        </g:Button>
         <g:Button ui:field='done' styleName=''
             title='Reply "Done" to this comment'>
           <ui:attribute name='title'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
index e379d60..2723374 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
@@ -20,11 +20,16 @@
 
 /** Resources used by diff. */
 interface Resources extends ClientBundle {
-  static final Resources I = GWT.create(Resources.class);
+  Resources I = GWT.create(Resources.class);
 
   @Source("CommentBox.css") CommentBox.Style style();
   @Source("Scrollbar.css") Scrollbar.Style scrollbarStyle();
+  @Source("DiffTable.css") DiffTable.Style diffTableStyle();
 
+  /**
+   * tango icon library (public domain):
+   * http://tango.freedesktop.org/Tango_Icon_Library
+   */
   @Source("goPrev.png") ImageResource goPrev();
   @Source("goNext.png") ImageResource goNext();
   @Source("goUp.png") ImageResource goUp();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
index 4ee09d2..13f58db 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
@@ -20,7 +20,7 @@
 import net.codemirror.lib.ScrollInfo;
 
 class ScrollSynchronizer {
-  private DiffTable diffTable;
+  private SideBySideTable diffTable;
   private LineMapper mapper;
   private ScrollCallback active;
   private ScrollCallback callbackA;
@@ -28,7 +28,7 @@
   private CodeMirror cmB;
   private boolean autoHideDiffTableHeader;
 
-  ScrollSynchronizer(DiffTable diffTable,
+  ScrollSynchronizer(SideBySideTable diffTable,
       CodeMirror cmA, CodeMirror cmB,
       LineMapper mapper) {
     this.diffTable = diffTable;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
index 383f278..26f8ff5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
@@ -27,12 +27,14 @@
 
 .delete {
   background-color: #faa;
+  min-width: 12px;
 }
 .insert {
   background-color: #9f9;
+  min-width: 12px;
 }
 .edit {
-  border-left: 3px solid #faa;
-  width: 2px !important;
+  border-left: 6px solid #faa;
+  width: 6px !important;
   background-color: #9f9;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index 0fdc519..dbe7e5d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -14,50 +14,26 @@
 
 package com.google.gerrit.client.diff;
 
-import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
 import static java.lang.Double.POSITIVE_INFINITY;
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.JumpKeys;
-import com.google.gerrit.client.account.DiffPreferences;
-import com.google.gerrit.client.change.ChangeScreen;
-import com.google.gerrit.client.change.FileTable;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.changes.ChangeList;
-import com.google.gerrit.client.diff.DiffInfo.FileMeta;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-import com.google.gerrit.client.info.ChangeInfo;
-import com.google.gerrit.client.info.ChangeInfo.EditInfo;
-import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.info.FileInfo;
 import com.google.gerrit.client.patches.PatchUtil;
 import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
-import com.google.gwt.dom.client.NativeEvent;
 import com.google.gwt.event.dom.client.FocusEvent;
 import com.google.gwt.event.dom.client.FocusHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ResizeEvent;
-import com.google.gwt.event.logical.shared.ResizeHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.user.client.Window;
@@ -66,77 +42,31 @@
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.globalkey.client.ShowHelpCommand;
 
 import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.CodeMirror.BeforeSelectionChangeHandler;
-import net.codemirror.lib.CodeMirror.GutterClickHandler;
 import net.codemirror.lib.CodeMirror.LineHandle;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.KeyMap;
 import net.codemirror.lib.Pos;
-import net.codemirror.mode.ModeInfo;
-import net.codemirror.mode.ModeInjector;
-import net.codemirror.theme.ThemeLoader;
 
-import java.util.ArrayList;
 import java.util.Collections;
-import java.util.EnumSet;
 import java.util.List;
 
-public class SideBySide extends Screen {
-  private static final KeyMap RENDER_ENTIRE_FILE_KEYMAP = KeyMap.create()
-      .propagate("Ctrl-F");
-
+public class SideBySide extends DiffScreen {
   interface Binder extends UiBinder<FlowPanel, SideBySide> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
-
-  enum FileSize {
-    SMALL(0),
-    LARGE(500),
-    HUGE(4000);
-
-    final int lines;
-
-    FileSize(int n) {
-      this.lines = n;
-    }
-  }
+  private static final String LINE_NUMBER_CLASSNAME = "CodeMirror-linenumber";
 
   @UiField(provided = true)
-  Header header;
-
-  @UiField(provided = true)
-  DiffTable diffTable;
-
-  private final Change.Id changeId;
-  private final PatchSet.Id base;
-  private final PatchSet.Id revision;
-  private final String path;
-  private DisplaySide startSide;
-  private int startLine;
-  private DiffPreferences prefs;
-  private Change.Status changeStatus;
+  SideBySideTable diffTable;
 
   private CodeMirror cmA;
   private CodeMirror cmB;
 
-  private HandlerRegistration resizeHandler;
   private ScrollSynchronizer scrollSynchronizer;
-  private DiffInfo diff;
-  private FileSize fileSize;
-  private EditInfo edit;
-  private ChunkManager chunkManager;
-  private CommentManager commentManager;
-  private SkipManager skipManager;
 
-  private KeyCommandSet keysNavigation;
-  private KeyCommandSet keysAction;
-  private KeyCommandSet keysComment;
-  private List<HandlerRegistration> handlers;
-  private PreferencesAction prefsAction;
-  private int reloadVersionId;
+  private SideBySideChunkManager chunkManager;
+  private SideBySideCommentManager commentManager;
 
   public SideBySide(
       PatchSet.Id base,
@@ -144,149 +74,34 @@
       String path,
       DisplaySide startSide,
       int startLine) {
-    this.base = base;
-    this.revision = revision;
-    this.changeId = revision.getParentKey();
-    this.path = path;
-    this.startSide = startSide;
-    this.startLine = startLine;
+    super(base, revision, path, startSide, startLine, DiffView.SIDE_BY_SIDE);
 
-    prefs = DiffPreferences.create(Gerrit.getDiffPreferences());
-    handlers = new ArrayList<>(6);
-    keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    header = new Header(keysNavigation, base, revision, path);
-    diffTable = new DiffTable(this, base, revision, path);
+    diffTable = new SideBySideTable(this, base, revision, path);
     add(uiBinder.createAndBindUi(this));
     addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
   }
 
   @Override
-  protected void onInitUI() {
-    super.onInitUI();
-    setHeaderVisible(false);
-    setWindowTitle(FileInfo.getFileName(path));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    CallbackGroup group1 = new CallbackGroup();
-    final CallbackGroup group2 = new CallbackGroup();
-
-    CodeMirror.initLibrary(group1.add(new AsyncCallback<Void>() {
-      final AsyncCallback<Void> themeCallback = group2.addEmpty();
-
+  ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
+      final CommentsCollections comments) {
+    return new ScreenLoadCallback<ConfigInfoCache.Entry>(SideBySide.this) {
       @Override
-      public void onSuccess(Void result) {
-        // Load theme after CM library to ensure theme can override CSS.
-        ThemeLoader.loadTheme(prefs.theme(), themeCallback);
+      protected void preDisplay(ConfigInfoCache.Entry result) {
+        commentManager = new SideBySideCommentManager(
+            SideBySide.this,
+            base, revision, path,
+            result.getCommentLinkProcessor(),
+            getChangeStatus().isOpen());
+        setTheme(result.getTheme());
+        display(comments);
+        header.setupPrevNextFiles(comments);
       }
-
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
-
-    DiffApi.diff(revision, path)
-      .base(base)
-      .wholeFile()
-      .intraline(prefs.intralineDifference())
-      .ignoreWhitespace(prefs.ignoreWhitespace())
-      .get(group1.addFinal(new GerritCallback<DiffInfo>() {
-        final AsyncCallback<Void> modeInjectorCb = group2.addEmpty();
-
-        @Override
-        public void onSuccess(DiffInfo diffInfo) {
-          diff = diffInfo;
-          fileSize = bucketFileSize(diffInfo);
-
-          if (prefs.syntaxHighlighting()) {
-            if (fileSize.compareTo(FileSize.SMALL) > 0) {
-              modeInjectorCb.onSuccess(null);
-            } else {
-              injectMode(diffInfo, modeInjectorCb);
-            }
-          } else {
-            modeInjectorCb.onSuccess(null);
-          }
-        }
-      }));
-
-    if (Gerrit.isSignedIn()) {
-      ChangeApi.edit(changeId.get(), group2.add(
-          new AsyncCallback<EditInfo>() {
-            @Override
-            public void onSuccess(EditInfo result) {
-              edit = result;
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          }));
-    }
-
-    final CommentsCollections comments = new CommentsCollections();
-    comments.load(base, revision, path, group2);
-
-    RestApi call = ChangeApi.detail(changeId.get());
-    ChangeList.addOptions(call, EnumSet.of(
-        ListChangesOption.ALL_REVISIONS));
-    call.get(group2.add(new AsyncCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo info) {
-        changeStatus = info.status();
-        info.revisions().copyKeysIntoChildren("name");
-        if (edit != null) {
-          edit.setName(edit.commit().commit());
-          info.setEdit(edit);
-          info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-        }
-        String currentRevision = info.currentRevision();
-        boolean current = currentRevision != null &&
-            revision.get() == info.revision(currentRevision)._number();
-        JsArray<RevisionInfo> list = info.revisions().values();
-        RevisionInfo.sortRevisionInfoByNumber(list);
-        diffTable.set(prefs, list, diff, edit != null, current,
-            changeStatus.isOpen(), diff.binary());
-        header.setChangeInfo(info);
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
-
-    ConfigInfoCache.get(changeId, group2.addFinal(
-        new ScreenLoadCallback<ConfigInfoCache.Entry>(SideBySide.this) {
-          @Override
-          protected void preDisplay(ConfigInfoCache.Entry result) {
-            commentManager = new CommentManager(
-                SideBySide.this,
-                base, revision, path,
-                result.getCommentLinkProcessor(),
-                changeStatus.isOpen());
-            setTheme(result.getTheme());
-            display(comments);
-          }
-        }));
+    };
   }
 
   @Override
   public void onShowView() {
     super.onShowView();
-    Window.enableScrolling(false);
-    JumpKeys.enable(false);
-    if (prefs.hideTopMenu()) {
-      Gerrit.setHeaderVisible(false);
-    }
-    resizeHandler = Window.addResizeHandler(new ResizeHandler() {
-      @Override
-      public void onResize(ResizeEvent event) {
-        resizeCodeMirror();
-      }
-    });
 
     operation(new Runnable() {
       @Override
@@ -300,21 +115,21 @@
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
 
-    if (startLine == 0) {
+    if (getStartLine() == 0) {
       DiffChunkInfo d = chunkManager.getFirst();
       if (d != null) {
         if (d.isEdit() && d.getSide() == DisplaySide.A) {
-          startSide = DisplaySide.B;
-          startLine = lineOnOther(d.getSide(), d.getStart()).getLine() + 1;
+          setStartSide(DisplaySide.B);
+          setStartLine(lineOnOther(d.getSide(), d.getStart()).getLine() + 1);
         } else {
-          startSide = d.getSide();
-          startLine = d.getStart() + 1;
+          setStartSide(d.getSide());
+          setStartLine(d.getStart() + 1);
         }
       }
     }
-    if (startSide != null && startLine > 0) {
-      CodeMirror cm = getCmFromSide(startSide);
-      cm.scrollToLine(startLine - 1);
+    if (getStartSide() != null && getStartLine() > 0) {
+      CodeMirror cm = getCmFromSide(getStartSide());
+      cm.scrollToLine(getStartLine() - 1);
       cm.focus();
     } else {
       cmA.setCursor(Pos.create(0));
@@ -327,266 +142,71 @@
   }
 
   @Override
-  protected void onUnload() {
-    super.onUnload();
+  void registerCmEvents(final CodeMirror cm) {
+    super.registerCmEvents(cm);
 
-    removeKeyHandlerRegistrations();
-    if (commentManager != null) {
-      CallbackGroup group = new CallbackGroup();
-      commentManager.saveAllDrafts(group);
-      group.done();
-    }
-    if (resizeHandler != null) {
-      resizeHandler.removeHandler();
-      resizeHandler = null;
-    }
-    if (cmA != null) {
-      cmA.getWrapperElement().removeFromParent();
-    }
-    if (cmB != null) {
-      cmB.getWrapperElement().removeFromParent();
-    }
-    if (prefsAction != null) {
-      prefsAction.hide();
-    }
-
-    Window.enableScrolling(true);
-    Gerrit.setHeaderVisible(true);
-    JumpKeys.enable(true);
-  }
-
-  private void removeKeyHandlerRegistrations() {
-    for (HandlerRegistration h : handlers) {
-      h.removeHandler();
-    }
-    handlers.clear();
-  }
-
-  private void registerCmEvents(final CodeMirror cm) {
-    cm.on("cursorActivity", updateActiveLine(cm));
-    cm.on("focus", updateActiveLine(cm));
     KeyMap keyMap = KeyMap.create()
-        .on("A", upToChange(true))
-        .on("U", upToChange(false))
-        .on("'['", header.navigate(Direction.PREV))
-        .on("']'", header.navigate(Direction.NEXT))
-        .on("R", header.toggleReviewed())
-        .on("O", commentManager.toggleOpenBox(cm))
-        .on("Enter", commentManager.toggleOpenBox(cm))
-        .on("N", maybeNextVimSearch(cm))
-        .on("E", openEditScreen(cm))
-        .on("P", chunkManager.diffChunkNav(cm, Direction.PREV))
         .on("Shift-A", diffTable.toggleA())
-        .on("Shift-M", header.reviewedAndNext())
-        .on("Shift-N", maybePrevVimSearch(cm))
-        .on("Shift-P", commentManager.commentNav(cm, Direction.PREV))
-        .on("Shift-O", commentManager.openCloseAll(cm))
         .on("Shift-Left", moveCursorToSide(cm, DisplaySide.A))
-        .on("Shift-Right", moveCursorToSide(cm, DisplaySide.B))
-        .on("I", new Runnable() {
-          @Override
-          public void run() {
-            switch (getIntraLineStatus()) {
-              case OFF:
-              case OK:
-                toggleShowIntraline();
-                break;
-              default:
-                break;
-            }
-          }
-        })
-        .on("','", new Runnable() {
-          @Override
-          public void run() {
-            prefsAction.show();
-          }
-        })
-        .on("Shift-/", new Runnable() {
-          @Override
-          public void run() {
-            new ShowHelpCommand().onKeyPress(null);
-          }
-        })
-        .on("Space", new Runnable() {
-          @Override
-          public void run() {
-            cm.vim().handleKey("<C-d>");
-          }
-        })
-        .on("Shift-Space", new Runnable() {
-          @Override
-          public void run() {
-            cm.vim().handleKey("<C-u>");
-          }
-        })
-        .on("Ctrl-F", new Runnable() {
-          @Override
-          public void run() {
-            cm.vim().handleKey("/");
-          }
-        })
-        .on("Ctrl-A", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("selectAll");
-          }
-        });
-    if (revision.get() != 0) {
-      cm.on("beforeSelectionChange", onSelectionChange(cm));
-      cm.on("gutterClick", onGutterClick(cm));
-      keyMap.on("C", commentManager.insertNewDraft(cm));
-    }
+        .on("Shift-Right", moveCursorToSide(cm, DisplaySide.B));
     cm.addKeyMap(keyMap);
-    if (renderEntireFile()) {
-      cm.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-    }
-  }
-
-  private BeforeSelectionChangeHandler onSelectionChange(final CodeMirror cm) {
-    return new BeforeSelectionChangeHandler() {
-      private InsertCommentBubble bubble;
-
-      @Override
-      public void handle(CodeMirror cm, Pos anchor, Pos head) {
-        if (anchor.equals(head)) {
-          if (bubble != null) {
-            bubble.setVisible(false);
-          }
-          return;
-        } else if (bubble == null) {
-          init(anchor);
-        } else {
-          bubble.setVisible(true);
-        }
-        bubble.position(cm.charCoords(head, "local"));
-      }
-
-      private void init(Pos anchor) {
-        bubble = new InsertCommentBubble(commentManager, cm);
-        add(bubble);
-        cm.addWidget(anchor, bubble.getElement());
-      }
-    };
+    maybeRegisterRenderEntireFileKeyMap(cm);
   }
 
   @Override
   public void registerKeys() {
     super.registerKeys();
 
-    keysNavigation.add(new UpToChangeCommand(revision, 0, 'u'));
-    keysNavigation.add(
+    getKeysNavigation().add(
         new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_LEFT, PatchUtil.C.focusSideA()),
         new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_RIGHT, PatchUtil.C.focusSideB()));
-    keysNavigation.add(
-        new NoOpKeyCommand(0, 'j', PatchUtil.C.lineNext()),
-        new NoOpKeyCommand(0, 'k', PatchUtil.C.linePrev()));
-    keysNavigation.add(
-        new NoOpKeyCommand(0, 'n', PatchUtil.C.chunkNext2()),
-        new NoOpKeyCommand(0, 'p', PatchUtil.C.chunkPrev2()));
-    keysNavigation.add(
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'n', PatchUtil.C.commentNext()),
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'p', PatchUtil.C.commentPrev()));
-    keysNavigation.add(
-        new NoOpKeyCommand(KeyCommand.M_CTRL, 'f', Gerrit.C.keySearch()));
-
-    keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-    keysAction.add(new NoOpKeyCommand(0, 'e', PatchUtil.C.openEditScreen()));
-    keysAction.add(new NoOpKeyCommand(0, KeyCodes.KEY_ENTER,
-        PatchUtil.C.expandComment()));
-    keysAction.add(new NoOpKeyCommand(0, 'o', PatchUtil.C.expandComment()));
-    keysAction.add(new NoOpKeyCommand(
-        KeyCommand.M_SHIFT, 'o', PatchUtil.C.expandAllCommentsOnCurrentLine()));
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          header.toggleReviewed().run();
-        }
-      });
-    }
-    keysAction.add(new KeyCommand(
-        KeyCommand.M_SHIFT, 'm', PatchUtil.C.markAsReviewedAndGoToNext()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        header.reviewedAndNext().run();
-      }
-    });
-    keysAction.add(new KeyCommand(0, 'a', PatchUtil.C.openReply()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        upToChange(true).run();
-      }
-    });
-    keysAction.add(new KeyCommand(
+    getKeysAction().add(new KeyCommand(
         KeyCommand.M_SHIFT, 'a', PatchUtil.C.toggleSideA()) {
       @Override
       public void onKeyPress(KeyPressEvent event) {
         diffTable.toggleA().run();
       }
     });
-    keysAction.add(new KeyCommand(0, ',', PatchUtil.C.showPreferences()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        prefsAction.show();
-      }
-    });
-    if (getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF
-        || getIntraLineStatus() == DiffInfo.IntraLineStatus.OK) {
-      keysAction.add(new KeyCommand(0, 'i', PatchUtil.C.toggleIntraline()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          toggleShowIntraline();
-        }
-      });
-    }
 
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(new NoOpKeyCommand(0, 'c', PatchUtil.C.commentInsert()));
-      keysComment = new KeyCommandSet(PatchUtil.C.commentEditorSet());
-      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's',
-          PatchUtil.C.commentSaveDraft()));
-      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE,
-          PatchUtil.C.commentCancelEdit()));
-    } else {
-      keysComment = null;
-    }
+    registerHandlers();
+  }
 
-    removeKeyHandlerRegistrations();
-    handlers.add(GlobalKey.add(this, keysAction));
-    handlers.add(GlobalKey.add(this, keysNavigation));
-    if (keysComment != null) {
-      handlers.add(GlobalKey.add(this, keysComment));
-    }
-    handlers.add(ShowHelpCommand.addFocusHandler(new FocusHandler() {
+  @Override
+  FocusHandler getFocusHandler() {
+    return new FocusHandler() {
       @Override
       public void onFocus(FocusEvent event) {
         cmB.focus();
       }
-    }));
+    };
   }
 
   private void display(final CommentsCollections comments) {
+    final DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
-      diffTable.addStyleName(DiffTable.style.showLineNumbers());
+      diffTable.addStyleName(Resources.I.diffTableStyle().showLineNumbers());
     }
 
-    cmA = newCM(diff.metaA(), diff.textA(), diffTable.cmA);
-    cmB = newCM(diff.metaB(), diff.textB(), diffTable.cmB);
+    cmA = newCm(diff.metaA(), diff.textA(), diffTable.cmA);
+    cmB = newCm(diff.metaB(), diff.textB(), diffTable.cmB);
+
+    boolean reviewingBase = base == null;
+    getDiffTable().setUpBlameIconA(cmA, reviewingBase,
+        reviewingBase ? revision : base, path);
+    getDiffTable().setUpBlameIconB(cmB, revision, path);
 
     cmA.extras().side(DisplaySide.A);
     cmB.extras().side(DisplaySide.B);
     setShowTabs(prefs.showTabs());
 
-    chunkManager = new ChunkManager(this, cmA, cmB, diffTable.scrollbar);
-    skipManager = new SkipManager(this, commentManager);
+    chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
     operation(new Runnable() {
       @Override
       public void run() {
-        // Estimate initial CM3 height, fixed up in onShowView.
+        // Estimate initial CodeMirror height, fixed up in onShowView.
         int height = Window.getClientHeight()
             - (Gerrit.getHeaderFooterHeight() + 18);
         cmA.setHeight(height);
@@ -601,26 +221,16 @@
     registerCmEvents(cmA);
     registerCmEvents(cmB);
     scrollSynchronizer = new ScrollSynchronizer(diffTable, cmA, cmB,
-            chunkManager.getLineMapper());
+            chunkManager.lineMapper);
 
-    prefsAction = new PreferencesAction(this, prefs);
-    header.init(prefsAction, getLinks(), diff.sideBySideWebLinks());
+    setPrefsAction(new PreferencesAction(this, prefs));
+    header.init(getPrefsAction(), getUnifiedDiffLink(), diff.sideBySideWebLinks());
     scrollSynchronizer.setAutoHideDiffTableHeader(prefs.autoHideDiffTableHeader());
 
-    if (prefs.syntaxHighlighting() && fileSize.compareTo(FileSize.SMALL) > 0) {
-      Scheduler.get().scheduleFixedDelay(new RepeatingCommand() {
-        @Override
-        public boolean execute() {
-          if (prefs.syntaxHighlighting() && isAttached()) {
-            setSyntaxHighlighting(prefs.syntaxHighlighting());
-          }
-          return false;
-        }
-      }, 250);
-    }
+    setupSyntaxHighlighting();
   }
 
-  private List<InlineHyperlink> getLinks() {
+  private List<InlineHyperlink> getUnifiedDiffLink() {
     InlineHyperlink toUnifiedDiffLink = new InlineHyperlink();
     toUnifiedDiffLink.setHTML(
         new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
@@ -630,90 +240,41 @@
     return Collections.singletonList(toUnifiedDiffLink);
   }
 
-  private CodeMirror newCM(
+  @Override
+  CodeMirror newCm(
       DiffInfo.FileMeta meta,
       String contents,
       Element parent) {
     return CodeMirror.create(parent, Configuration.create()
-      .set("readOnly", true)
       .set("cursorBlinkRate", prefs.cursorBlinkRate())
       .set("cursorHeight", 0.85)
-      .set("lineNumbers", prefs.showLineNumbers())
-      .set("tabSize", prefs.tabSize())
-      .set("mode", fileSize == FileSize.SMALL ? getContentType(meta) : null)
-      .set("lineWrapping", prefs.lineWrapping())
-      .set("scrollbarStyle", "overlay")
-      .set("styleSelectedText", true)
-      .set("showTrailingSpace", prefs.showWhitespaceErrors())
+      .set("inputStyle", "textarea")
       .set("keyMap", "vim_ro")
+      .set("lineNumbers", prefs.showLineNumbers())
+      .set("matchBrackets", prefs.matchBrackets())
+      .set("lineWrapping", prefs.lineWrapping())
+      .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
+      .set("readOnly", true)
+      .set("scrollbarStyle", "overlay")
+      .set("showTrailingSpace", prefs.showWhitespaceErrors())
+      .set("styleSelectedText", true)
+      .set("tabSize", prefs.tabSize())
       .set("theme", prefs.theme().name().toLowerCase())
       .set("value", meta != null ? contents : "")
       .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
   }
 
-  DiffInfo.IntraLineStatus getIntraLineStatus() {
-    return diff.intralineStatus();
-  }
-
-  boolean renderEntireFile() {
-    return prefs.renderEntireFile() && canRenderEntireFile(prefs);
-  }
-
-  boolean canRenderEntireFile(DiffPreferences prefs) {
-    // CodeMirror is too slow to layout an entire huge file.
-    return fileSize.compareTo(FileSize.HUGE) < 0
-        || (prefs.context() != WHOLE_FILE_CONTEXT && prefs.context() < 100);
-  }
-
-  String getContentType() {
-    return getContentType(diff.metaB());
-  }
-
-  void setThemeStyles(boolean d) {
-    if (d) {
-      diffTable.addStyleName(DiffTable.style.dark());
-    } else {
-      diffTable.removeStyleName(DiffTable.style.dark());
-    }
-  }
-
-  void setShowTabs(boolean show) {
-    cmA.extras().showTabs(show);
-    cmB.extras().showTabs(show);
-  }
-
-  void setLineLength(int columns) {
-    cmA.extras().lineLength(columns);
-    cmB.extras().lineLength(columns);
-  }
-
+  @Override
   void setShowLineNumbers(boolean b) {
+    super.setShowLineNumbers(b);
+
     cmA.setOption("lineNumbers", b);
     cmB.setOption("lineNumbers", b);
-    if (b) {
-      diffTable.addStyleName(DiffTable.style.showLineNumbers());
-    } else {
-      diffTable.removeStyleName(DiffTable.style.showLineNumbers());
-    }
   }
 
-  void setShowIntraline(boolean b) {
-    if (b && getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF) {
-      reloadDiffInfo();
-    } else if (b) {
-      diffTable.removeStyleName(DiffTable.style.noIntraline());
-    } else {
-      diffTable.addStyleName(DiffTable.style.noIntraline());
-    }
-  }
-
-  private void toggleShowIntraline() {
-    prefs.intralineDifference(!prefs.intralineDifference());
-    setShowIntraline(prefs.intralineDifference());
-    prefsAction.update();
-  }
-
+  @Override
   void setSyntaxHighlighting(boolean b) {
+    final DiffInfo diff = getDiff();
     if (b) {
       injectMode(diff, new AsyncCallback<Void>() {
         @Override
@@ -735,39 +296,27 @@
     }
   }
 
-  void setContext(final int context) {
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        skipManager.removeAll();
-        skipManager.render(context, diff);
-        updateRenderEntireFile();
-      }
-    });
-  }
-
+  @Override
   void setAutoHideDiffHeader(boolean hide) {
     scrollSynchronizer.setAutoHideDiffTableHeader(hide);
   }
 
-  private void render(DiffInfo diff) {
-    header.setNoDiff(diff);
-    chunkManager.render(diff);
-  }
-
   CodeMirror otherCm(CodeMirror me) {
     return me == cmA ? cmB : cmA;
   }
 
+  @Override
   CodeMirror getCmFromSide(DisplaySide side) {
     return side == DisplaySide.A ? cmA : cmB;
   }
 
-  LineOnOtherInfo lineOnOther(DisplaySide side, int line) {
-    return chunkManager.getLineMapper().lineOnOther(side, line);
+  @Override
+  int getCmLine(int line, DisplaySide side) {
+    return line;
   }
 
-  private Runnable updateActiveLine(final CodeMirror cm) {
+  @Override
+  Runnable updateActiveLine(final CodeMirror cm) {
     final CodeMirror other = otherCm(cm);
     return new Runnable() {
       @Override
@@ -804,50 +353,6 @@
     };
   }
 
-  private GutterClickHandler onGutterClick(final CodeMirror cm) {
-    return new GutterClickHandler() {
-      @Override
-      public void handle(CodeMirror instance, final int line, String gutter,
-          NativeEvent clickEvent) {
-        if (clickEvent.getButton() == NativeEvent.BUTTON_LEFT
-            && !clickEvent.getMetaKey()
-            && !clickEvent.getAltKey()
-            && !clickEvent.getCtrlKey()
-            && !clickEvent.getShiftKey()) {
-          cm.setCursor(Pos.create(line));
-          Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-            @Override
-            public void execute() {
-              commentManager.newDraft(cm, line + 1);
-            }
-          });
-        }
-      }
-    };
-  }
-
-  private Runnable upToChange(final boolean openReplyBox) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CallbackGroup group = new CallbackGroup();
-        commentManager.saveAllDrafts(group);
-        group.done();
-        group.addListener(new GerritCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            String b = base != null ? base.getId() : null;
-            String rev = revision.getId();
-            Gerrit.display(
-              PageLinks.toChange(changeId, b, rev),
-              new ChangeScreen(changeId, b, rev, openReplyBox,
-                  FileTable.Mode.REVIEW));
-          }
-        });
-      }
-    };
-  }
-
   private Runnable moveCursorToSide(final CodeMirror cmSrc, DisplaySide sideDst) {
     final CodeMirror cmDst = getCmFromSide(sideDst);
     if (cmDst == cmSrc) {
@@ -872,156 +377,13 @@
     };
   }
 
-  private Runnable maybePrevVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("N");
-        } else {
-          commentManager.commentNav(cm, Direction.NEXT).run();
-        }
-      }
-    };
-  }
-
-  private Runnable maybeNextVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("n");
-        } else {
-          chunkManager.diffChunkNav(cm, Direction.NEXT).run();
-        }
-      }
-    };
-  }
-
-  private int adjustCommitMessageLine(int line) {
-    /* When commit messages are shown in the side-by-side screen they include
-      a header block that looks like this:
-
-      1 Parent:     deadbeef (Parent commit title)
-      2 Author:     A. U. Thor <author@example.com>
-      3 AuthorDate: 2015-02-27 19:20:52 +0900
-      4 Commit:     A. U. Thor <author@example.com>
-      5 CommitDate: 2015-02-27 19:20:52 +0900
-      6 [blank line]
-      7 Commit message title
-      8
-      9 Commit message body
-     10 ...
-     11 ...
-
-    If the commit is a merge commit, both parent commits are listed in the
-    first two lines instead of a 'Parent' line:
-
-      1 Merge Of:   deadbeef (Parent 1 commit title)
-      2             beefdead (Parent 2 commit title)
-
-    */
-
-    // Offset to compensate for header lines until the blank line
-    // after 'CommitDate'
-    int offset = 6;
-
-    // Adjust for merge commits, which have two parent lines
-    if (diff.textB().startsWith("Merge")) {
-      offset += 1;
-    }
-
-    // If the cursor is inside the header line, reset to the first line of the
-    // commit message. Otherwise if the cursor is on an actual line of the commit
-    // message, adjust the line number to compensate for the header lines, so the
-    // focus is on the correct line.
-    if (line <= offset) {
-      return 1;
-    } else {
-      return line - offset;
-    }
-  }
-
-  private Runnable openEditScreen(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        LineHandle handle = cm.extras().activeLine();
-        int line = cm.getLineNumber(handle) + 1;
-        if (Patch.COMMIT_MSG.equals(path)) {
-          line = adjustCommitMessageLine(line);
-        }
-        String token = Dispatcher.toEditScreen(revision, path, line);
-        if (!Gerrit.isSignedIn()) {
-          Gerrit.doSignIn(token);
-        } else {
-          Gerrit.display(token);
-        }
-      }
-    };
-  }
-
-  void updateRenderEntireFile() {
-    cmA.removeKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-    cmB.removeKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-
-    boolean entireFile = renderEntireFile();
-    if (entireFile) {
-      cmA.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-      cmB.addKeyMap(RENDER_ENTIRE_FILE_KEYMAP);
-    }
-    cmA.setOption("viewportMargin", entireFile ? POSITIVE_INFINITY : 10);
-    cmB.setOption("viewportMargin", entireFile ? POSITIVE_INFINITY : 10);
-  }
-
-  void resizeCodeMirror() {
-    int hdr = header.getOffsetHeight() + diffTable.getHeaderHeight();
-    cmA.adjustHeight(hdr);
-    cmB.adjustHeight(hdr);
-  }
-
   void syncScroll(DisplaySide masterSide) {
     if (scrollSynchronizer != null) {
       scrollSynchronizer.syncScroll(masterSide);
     }
   }
 
-  private String getContentType(DiffInfo.FileMeta meta) {
-    if (prefs.syntaxHighlighting() && meta != null
-        && meta.contentType() != null) {
-     ModeInfo m = ModeInfo.findMode(meta.contentType(), path);
-     return m != null ? m.mime() : null;
-   }
-   return null;
-  }
-
-  private void injectMode(DiffInfo diffInfo, AsyncCallback<Void> cb) {
-    new ModeInjector()
-      .add(getContentType(diffInfo.metaA()))
-      .add(getContentType(diffInfo.metaB()))
-      .inject(cb);
-  }
-
-  String getPath() {
-    return path;
-  }
-
-  DiffPreferences getPrefs() {
-    return prefs;
-  }
-
-  ChunkManager getChunkManager() {
-    return chunkManager;
-  }
-
-  CommentManager getCommentManager() {
-    return commentManager;
-  }
-
-  SkipManager getSkipManager() {
-    return skipManager;
-  }
-
+  @Override
   void operation(final Runnable apply) {
     cmA.operation(new Runnable() {
       @Override
@@ -1036,70 +398,33 @@
     });
   }
 
-  private void prefetchNextFile() {
-    String nextPath = header.getNextPath();
-    if (nextPath != null) {
-      DiffApi.diff(revision, nextPath)
-        .base(base)
-        .wholeFile()
-        .intraline(prefs.intralineDifference())
-        .ignoreWhitespace(prefs.ignoreWhitespace())
-        .get(new AsyncCallback<DiffInfo>() {
-          @Override
-          public void onSuccess(DiffInfo info) {
-            new ModeInjector()
-              .add(getContentType(info.metaA()))
-              .add(getContentType(info.metaB()))
-              .inject(CallbackGroup.<Void> emptyCallback());
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        });
-    }
+  @Override
+  CodeMirror[] getCms() {
+    return new CodeMirror[]{cmA, cmB};
   }
 
-  void reloadDiffInfo() {
-    final int id = ++reloadVersionId;
-    DiffApi.diff(revision, path)
-      .base(base)
-      .wholeFile()
-      .intraline(prefs.intralineDifference())
-      .ignoreWhitespace(prefs.ignoreWhitespace())
-      .get(new GerritCallback<DiffInfo>() {
-        @Override
-        public void onSuccess(DiffInfo diffInfo) {
-          if (id == reloadVersionId && isAttached()) {
-            diff = diffInfo;
-            operation(new Runnable() {
-              @Override
-              public void run() {
-                skipManager.removeAll();
-                chunkManager.reset();
-                diffTable.scrollbar.removeDiffAnnotations();
-                setShowIntraline(prefs.intralineDifference());
-                render(diff);
-                chunkManager.adjustPadding();
-                skipManager.render(prefs.context(), diff);
-              }
-            });
-          }
-        }
-      });
+  @Override
+  SideBySideTable getDiffTable() {
+    return diffTable;
   }
 
-  private static FileSize bucketFileSize(DiffInfo diff) {
-    FileMeta a = diff.metaA();
-    FileMeta b = diff.metaB();
-    FileSize[] sizes = FileSize.values();
-    for (int i = sizes.length - 1; 0 <= i; i--) {
-      FileSize s = sizes[i];
-      if ((a != null && s.lines <= a.lines())
-          || (b != null && s.lines <= b.lines())) {
-        return s;
-      }
-    }
-    return FileSize.SMALL;
+  @Override
+  SideBySideChunkManager getChunkManager() {
+    return chunkManager;
+  }
+
+  @Override
+  SideBySideCommentManager getCommentManager() {
+    return commentManager;
+  }
+
+  @Override
+  boolean isSideBySide() {
+    return true;
+  }
+
+  @Override
+  String getLineNumberClassName() {
+    return LINE_NUMBER_CLASSNAME;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml
index a4c2eb9..55c9de0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.ui.xml
@@ -25,6 +25,6 @@
   </ui:style>
   <g:FlowPanel styleName='{style.sbs}'>
     <d:Header ui:field='header'/>
-    <d:DiffTable ui:field='diffTable'/>
+    <d:SideBySideTable ui:field='diffTable'/>
   </g:FlowPanel>
 </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
new file mode 100644
index 0000000..11b7e7c
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
@@ -0,0 +1,275 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import static com.google.gerrit.client.diff.DisplaySide.A;
+import static com.google.gerrit.client.diff.DisplaySide.B;
+
+import com.google.gerrit.client.diff.DiffInfo.Region;
+import com.google.gerrit.client.diff.DiffInfo.Span;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.JsArrayString;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.NativeEvent;
+import com.google.gwt.dom.client.Style.Unit;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.EventListener;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.CodeMirror.LineClassWhere;
+import net.codemirror.lib.Configuration;
+import net.codemirror.lib.LineWidget;
+import net.codemirror.lib.Pos;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Colors modified regions for {@link SideBySide}. */
+class SideBySideChunkManager extends ChunkManager {
+  private static final String DATA_LINES = "_cs2h";
+  private static double guessedLineHeightPx = 15;
+  private static final JavaScriptObject focusA = initOnClick(A);
+  private static final JavaScriptObject focusB = initOnClick(B);
+  private static native JavaScriptObject initOnClick(DisplaySide s) /*-{
+    return $entry(function(e){
+      @com.google.gerrit.client.diff.SideBySideChunkManager::focus(
+        Lcom/google/gwt/dom/client/NativeEvent;
+        Lcom/google/gerrit/client/diff/DisplaySide;)(e,s)
+    });
+  }-*/;
+
+  private static void focus(NativeEvent event, DisplaySide side) {
+    Element e = Element.as(event.getEventTarget());
+    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+      EventListener l = DOM.getEventListener(e);
+      if (l instanceof SideBySide) {
+        ((SideBySide) l).getCmFromSide(side).focus();
+        event.stopPropagation();
+      }
+    }
+  }
+
+  static void focusOnClick(Element e, DisplaySide side) {
+    onClick(e, side == A ? focusA : focusB);
+  }
+
+  private final SideBySide host;
+  private final CodeMirror cmA;
+  private final CodeMirror cmB;
+
+  private List<DiffChunkInfo> chunks;
+  private List<LineWidget> padding;
+  private List<Element> paddingDivs;
+
+  SideBySideChunkManager(SideBySide host,
+      CodeMirror cmA,
+      CodeMirror cmB,
+      Scrollbar scrollbar) {
+    super(scrollbar);
+
+    this.host = host;
+    this.cmA = cmA;
+    this.cmB = cmB;
+  }
+
+  @Override
+  DiffChunkInfo getFirst() {
+    return !chunks.isEmpty() ? chunks.get(0) : null;
+  }
+
+  @Override
+  void reset() {
+    super.reset();
+
+    for (LineWidget w : padding) {
+      w.clear();
+    }
+  }
+
+  @Override
+  void render(DiffInfo diff) {
+    super.render();
+
+    chunks = new ArrayList<>();
+    padding = new ArrayList<>();
+    paddingDivs = new ArrayList<>();
+
+    String diffColor = diff.metaA() == null || diff.metaB() == null
+        ? SideBySideTable.style.intralineBg()
+        : SideBySideTable.style.diff();
+
+    for (Region current : Natives.asList(diff.content())) {
+      if (current.ab() != null) {
+        lineMapper.appendCommon(current.ab().length());
+      } else if (current.skip() > 0) {
+        lineMapper.appendCommon(current.skip());
+      } else if (current.common()) {
+        lineMapper.appendCommon(current.b().length());
+      } else {
+        render(current, diffColor);
+      }
+    }
+
+    if (paddingDivs.isEmpty()) {
+      paddingDivs = null;
+    }
+  }
+
+  void adjustPadding() {
+    if (paddingDivs != null) {
+      double h = cmB.extras().lineHeightPx();
+      for (Element div : paddingDivs) {
+        int lines = div.getPropertyInt(DATA_LINES);
+        div.getStyle().setHeight(lines * h, Unit.PX);
+      }
+      for (LineWidget w : padding) {
+        w.changed();
+      }
+      paddingDivs = null;
+      guessedLineHeightPx = h;
+    }
+  }
+
+  private void render(Region region, String diffColor) {
+    int startA = lineMapper.getLineA();
+    int startB = lineMapper.getLineB();
+
+    JsArrayString a = region.a();
+    JsArrayString b = region.b();
+    int aLen = a != null ? a.length() : 0;
+    int bLen = b != null ? b.length() : 0;
+
+    String color = a == null || b == null
+        ? diffColor
+        : SideBySideTable.style.intralineBg();
+
+    colorLines(cmA, color, startA, aLen);
+    colorLines(cmB, color, startB, bLen);
+    markEdit(cmA, startA, a, region.editA());
+    markEdit(cmB, startB, b, region.editB());
+    addPadding(cmA, startA + aLen - 1, bLen - aLen);
+    addPadding(cmB, startB + bLen - 1, aLen - bLen);
+    addGutterTag(region, startA, startB);
+    lineMapper.appendReplace(aLen, bLen);
+
+    int endA = lineMapper.getLineA() - 1;
+    int endB = lineMapper.getLineB() - 1;
+    if (aLen > 0) {
+      addDiffChunk(cmB, endA, aLen, bLen > 0);
+    }
+    if (bLen > 0) {
+      addDiffChunk(cmA, endB, bLen, aLen > 0);
+    }
+  }
+
+  private void addGutterTag(Region region, int startA, int startB) {
+    if (region.a() == null) {
+      scrollbar.insert(cmB, startB, region.b().length());
+    } else if (region.b() == null) {
+      scrollbar.delete(cmA, cmB, startA, region.a().length());
+    } else {
+      scrollbar.edit(cmB, startB, region.b().length());
+    }
+  }
+
+  private void markEdit(CodeMirror cm, int startLine,
+      JsArrayString lines, JsArray<Span> edits) {
+    if (lines == null || edits == null) {
+      return;
+    }
+
+    EditIterator iter = new EditIterator(lines, startLine);
+    Configuration bg = Configuration.create()
+        .set("className", SideBySideTable.style.intralineBg())
+        .set("readOnly", true);
+
+    Configuration diff = Configuration.create()
+        .set("className", SideBySideTable.style.diff())
+        .set("readOnly", true);
+
+    Pos last = Pos.create(0, 0);
+    for (Span span : Natives.asList(edits)) {
+      Pos from = iter.advance(span.skip());
+      Pos to = iter.advance(span.mark());
+      if (from.line() == last.line()) {
+        getMarkers().add(cm.markText(last, from, bg));
+      } else {
+        getMarkers().add(cm.markText(Pos.create(from.line(), 0), from, bg));
+      }
+      getMarkers().add(cm.markText(from, to, diff));
+      last = to;
+      colorLines(cm, LineClassWhere.BACKGROUND,
+          SideBySideTable.style.diff(),
+          from.line(), to.line());
+    }
+  }
+
+  /**
+   * Insert a new padding div below the given line.
+   *
+   * @param cm parent CodeMirror to add extra space into.
+   * @param line line to put the padding below.
+   * @param len number of lines to pad. Padding is inserted only if
+   *        {@code len >= 1}.
+   */
+  private void addPadding(CodeMirror cm, int line, final int len) {
+    if (0 < len) {
+      Element pad = DOM.createDiv();
+      pad.setClassName(SideBySideTable.style.padding());
+      pad.setPropertyInt(DATA_LINES, len);
+      pad.getStyle().setHeight(guessedLineHeightPx * len, Unit.PX);
+      focusOnClick(pad, cm.side());
+      paddingDivs.add(pad);
+      padding.add(cm.addLineWidget(
+        line == -1 ? 0 : line,
+        pad,
+        Configuration.create()
+          .set("coverGutter", true)
+          .set("noHScroll", true)
+          .set("above", line == -1)));
+    }
+  }
+
+  private void addDiffChunk(CodeMirror cmToPad, int lineOnOther,
+      int chunkSize, boolean edit) {
+    chunks.add(new DiffChunkInfo(host.otherCm(cmToPad).side(),
+        lineOnOther - chunkSize + 1, lineOnOther, edit));
+  }
+
+  @Override
+  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        int line = cm.extras().hasActiveLine()
+            ? cm.getLineNumber(cm.extras().activeLine())
+            : 0;
+        int res = Collections.binarySearch(
+                chunks,
+                new DiffChunkInfo(cm.side(), line, 0, false),
+                getDiffChunkComparator());
+        diffChunkNavHelper(chunks, host, res, dir);
+      }
+    };
+  }
+
+  @Override
+  int getCmLine(int line, DisplaySide side) {
+    return line;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
new file mode 100644
index 0000000..db72864
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.Style.Unit;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Timer;
+
+import net.codemirror.lib.CodeMirror;
+
+import java.util.PriorityQueue;
+
+/**
+ * LineWidget attached to a CodeMirror container.
+ *
+ * When a comment is placed on a line a CommentWidget is created on both sides.
+ * The group tracks all comment boxes on that same line, and also includes an
+ * empty padding element to keep subsequent lines vertically aligned.
+ */
+class SideBySideCommentGroup extends CommentGroup
+    implements Comparable<SideBySideCommentGroup> {
+  static void pair(SideBySideCommentGroup a, SideBySideCommentGroup b) {
+    a.peers.add(b);
+    b.peers.add(a);
+  }
+
+  private final Element padding;
+  private final PriorityQueue<SideBySideCommentGroup> peers;
+
+  SideBySideCommentGroup(SideBySideCommentManager manager, CodeMirror cm, DisplaySide side,
+      int line) {
+    super(manager, cm, side, line);
+
+    padding = DOM.createDiv();
+    padding.setClassName(SideBySideTable.style.padding());
+    SideBySideChunkManager.focusOnClick(padding, cm.side());
+    getElement().appendChild(padding);
+    peers = new PriorityQueue<>();
+  }
+
+  SideBySideCommentGroup getPeer() {
+    return peers.peek();
+  }
+
+  @Override
+  void remove(DraftBox box) {
+    super.remove(box);
+
+    if (getBoxCount() == 0 && peers.size() == 1
+        && peers.peek().peers.size() > 1) {
+      SideBySideCommentGroup peer = peers.peek();
+      peer.peers.remove(this);
+      detach();
+      if (peer.getBoxCount() == 0 && peer.peers.size() == 1
+          && peer.peers.peek().getBoxCount() == 0) {
+        peer.detach();
+      } else {
+        peer.resize();
+      }
+    } else {
+      resize();
+    }
+  }
+
+  @Override
+  void init(DiffTable parent) {
+    if (getLineWidget() == null) {
+      attach(parent);
+    }
+    for (CommentGroup peer : peers) {
+      if (peer.getLineWidget() == null) {
+        peer.attach(parent);
+      }
+    }
+  }
+
+  @Override
+  void handleRedraw() {
+    getLineWidget().onRedraw(new Runnable() {
+      @Override
+      public void run() {
+        if (canComputeHeight() && peers.peek().canComputeHeight()) {
+          if (getResizeTimer() != null) {
+            getResizeTimer().cancel();
+            setResizeTimer(null);
+          }
+          adjustPadding(SideBySideCommentGroup.this, peers.peek());
+        } else if (getResizeTimer() == null) {
+          setResizeTimer(new Timer() {
+            @Override
+            public void run() {
+              if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                cancel();
+                setResizeTimer(null);
+                adjustPadding(SideBySideCommentGroup.this, peers.peek());
+              }
+            }
+          });
+          getResizeTimer().scheduleRepeating(5);
+        }
+      }
+    });
+  }
+
+  @Override
+  void resize() {
+    if (getLineWidget() != null) {
+      adjustPadding(this, peers.peek());
+    }
+  }
+
+  private int computeHeight() {
+    if (getComments().isVisible()) {
+      // Include margin-bottom: 5px from CSS class.
+      return getComments().getOffsetHeight() + 5;
+    }
+    return 0;
+  }
+
+  private static void adjustPadding(SideBySideCommentGroup a, SideBySideCommentGroup b) {
+    int apx = a.computeHeight();
+    int bpx = b.computeHeight();
+    for (SideBySideCommentGroup otherPeer : a.peers) {
+      if (otherPeer != b) {
+        bpx += otherPeer.computeHeight();
+      }
+    }
+    for (SideBySideCommentGroup otherPeer : b.peers) {
+      if (otherPeer != a) {
+        apx += otherPeer.computeHeight();
+      }
+    }
+    int h = Math.max(apx, bpx);
+    a.padding.getStyle().setHeight(Math.max(0, h - apx), Unit.PX);
+    b.padding.getStyle().setHeight(Math.max(0, h - bpx), Unit.PX);
+    a.getLineWidget().changed();
+    b.getLineWidget().changed();
+    a.updateSelection();
+    b.updateSelection();
+  }
+
+  @Override
+  public int compareTo(SideBySideCommentGroup o) {
+    if (side == o.side) {
+      return line - o.line;
+    }
+    throw new IllegalStateException(
+        "Cannot compare SideBySideCommentGroup with different sides");
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
new file mode 100644
index 0000000..bcb7dac
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.TextMarker.FromTo;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.SortedMap;
+
+/** Tracks comment widgets for {@link SideBySide}. */
+class SideBySideCommentManager extends CommentManager {
+  SideBySideCommentManager(SideBySide host,
+      PatchSet.Id base, PatchSet.Id revision,
+      String path,
+      CommentLinkProcessor clp,
+      boolean open) {
+    super(host, base, revision, path, clp, open);
+  }
+
+  @Override
+  SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side) {
+    return map(side);
+  }
+
+  @Override
+  void clearLine(DisplaySide side, int line, CommentGroup group) {
+    super.clearLine(side, line, group);
+  }
+
+  @Override
+  void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int line) {
+    if (!Gerrit.isSignedIn()) {
+      signInCallback(cm).run();
+    } else {
+      insertNewDraft(cm.side(), line);
+    }
+  }
+
+  @Override
+  CommentGroup getCommentGroupOnActiveLine(CodeMirror cm) {
+    CommentGroup group = null;
+    if (cm.extras().hasActiveLine()) {
+      group = map(cm.side())
+          .get(cm.getLineNumber(cm.extras().activeLine()) + 1);
+    }
+    return group;
+  }
+
+  @Override
+  Collection<Integer> getLinesWithCommentGroups() {
+    return sideB.tailMap(1).keySet();
+  }
+
+  @Override
+  String getTokenSuffixForActiveLine(CodeMirror cm) {
+    return (cm.side() == DisplaySide.A ? "a" : "")
+        + (cm.getLineNumber(cm.extras().activeLine()) + 1);
+  }
+
+  @Override
+  void newDraft(CodeMirror cm) {
+    int line = cm.getLineNumber(cm.extras().activeLine()) + 1;
+    if (cm.somethingSelected()) {
+      FromTo fromTo = adjustSelection(cm);
+      addDraftBox(cm.side(), CommentInfo.create(
+              getPath(),
+              getStoredSideFromDisplaySide(cm.side()),
+              getParentNumFromDisplaySide(cm.side()),
+              line,
+              CommentRange.create(fromTo))).setEdit(true);
+      cm.setCursor(fromTo.to());
+      cm.setSelection(cm.getCursor());
+    } else {
+      insertNewDraft(cm.side(), line);
+    }
+  }
+
+  @Override
+  CommentGroup group(DisplaySide side, int line) {
+    CommentGroup existing = map(side).get(line);
+    if (existing != null) {
+      return existing;
+    }
+
+    SideBySideCommentGroup newGroup = newGroup(side, line);
+    Map<Integer, CommentGroup> map =
+        side == DisplaySide.A ? sideA : sideB;
+    Map<Integer, CommentGroup> otherMap =
+        side == DisplaySide.A ? sideB : sideA;
+    map.put(line, newGroup);
+    int otherLine = host.lineOnOther(side, line - 1).getLine() + 1;
+    existing = map(side.otherSide()).get(otherLine);
+    CommentGroup otherGroup;
+    if (existing != null) {
+      otherGroup = existing;
+    } else {
+      otherGroup = newGroup(side.otherSide(), otherLine);
+      otherMap.put(otherLine, otherGroup);
+    }
+    SideBySideCommentGroup.pair(newGroup, (SideBySideCommentGroup) otherGroup);
+
+    if (isAttached()) {
+      newGroup.init(host.getDiffTable());
+      otherGroup.handleRedraw();
+    }
+    return newGroup;
+  }
+
+  private SideBySideCommentGroup newGroup(DisplaySide side, int line) {
+    return new SideBySideCommentGroup(this, host.getCmFromSide(side), side, line);
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
new file mode 100644
index 0000000..2296796
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.reviewdb.client.Patch.ChangeType;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.resources.client.CssResource;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.ui.HTMLPanel;
+
+/**
+ * A table with one row and two columns to hold the two CodeMirrors displaying
+ * the files to be compared.
+ */
+class SideBySideTable extends DiffTable {
+  interface Binder extends UiBinder<HTMLPanel, SideBySideTable> {}
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  interface DiffTableStyle extends CssResource {
+    String intralineBg();
+    String diff();
+    String hideA();
+    String hideB();
+    String padding();
+  }
+
+  private SideBySide parent;
+  @UiField Element cmA;
+  @UiField Element cmB;
+  @UiField static DiffTableStyle style;
+
+  private boolean visibleA;
+
+  SideBySideTable(SideBySide parent, PatchSet.Id base, PatchSet.Id revision,
+      String path) {
+    super(parent, base, revision, path);
+
+    initWidget(uiBinder.createAndBindUi(this));
+    this.visibleA = true;
+    this.parent = parent;
+  }
+
+  @Override
+  boolean isVisibleA() {
+    return visibleA;
+  }
+
+  void setVisibleA(boolean show) {
+    visibleA = show;
+    if (show) {
+      removeStyleName(style.hideA());
+      parent.syncScroll(DisplaySide.B); // match B's viewport
+    } else {
+      addStyleName(style.hideA());
+    }
+  }
+
+  Runnable toggleA() {
+    return new Runnable() {
+      @Override
+      public void run() {
+        setVisibleA(!isVisibleA());
+      }
+    };
+  }
+
+  void setVisibleB(boolean show) {
+    if (show) {
+      removeStyleName(style.hideB());
+      parent.syncScroll(DisplaySide.A); // match A's viewport
+    } else {
+      addStyleName(style.hideB());
+    }
+  }
+
+  @Override
+  void setHideEmptyPane(boolean hide) {
+    if (getChangeType() == ChangeType.ADDED) {
+      setVisibleA(!hide);
+    } else if (getChangeType() == ChangeType.DELETED) {
+      setVisibleB(!hide);
+    }
+  }
+
+  @Override
+  SideBySide getDiffScreen() {
+    return parent;
+  }
+
+  @Override
+  int getHeaderHeight() {
+    int h = patchSetSelectBoxA.getOffsetHeight();
+    if (hasHeader()) {
+      h += diffHeaderRow.getOffsetHeight();
+    }
+    return h;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml
new file mode 100644
index 0000000..b2e3f43
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.ui.xml
@@ -0,0 +1,147 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:d='urn:import:com.google.gerrit.client.diff'>
+  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
+  <ui:style gss='false' type='com.google.gerrit.client.diff.SideBySideTable.DiffTableStyle'>
+    @external .CodeMirror, .CodeMirror-selectedtext;
+    @external .CodeMirror-linenumber;
+    @external .CodeMirror-overlayscroll-vertical, .CodeMirror-scroll;
+    @external .CodeMirror-dialog-bottom;
+    @external .CodeMirror-cursor;
+
+    @external .dark, .noIntraline, .showLineNumbers;
+
+    .difftable .patchSetNav,
+    .difftable .CodeMirror {
+      -webkit-touch-callout: none;
+      -webkit-user-select: none;
+      -khtml-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+    }
+
+    .difftable .CodeMirror pre {
+      overflow: visible;
+      border-right: 0;
+      width: auto;
+    }
+
+    /* Preserve space for underscores. If this changes
+     * see ChunkManager.addPadding() and adjust there.
+     */
+    .difftable .CodeMirror pre,
+    .difftable .CodeMirror pre span {
+      padding-bottom: 1px;
+    }
+
+    .hideA .psNavA,
+    .hideA .a {
+      display: none;
+    }
+
+    .hideB .psNavB,
+    .hideB .b {
+      display: none;
+    }
+
+    .table {
+      width: 100%;
+      table-layout: fixed;
+      border-spacing: 0;
+    }
+    .table td { padding: 0 }
+    .a, .b { width: 50% }
+    .hideA .psNavB, .hideA .b { width: 100% }
+    .hideB .psNavA, .hideB .a { width: 100% }
+
+    /* Hide scrollbars on A, B controls both views. */
+    .a .CodeMirror-scroll { margin-right: -36px; }
+    .a .CodeMirror-overlayscroll-vertical { display: none !important; }
+
+    .showLineNumbers .b { border-left: none; }
+    .b { border-left: 1px solid #ddd; }
+
+    .a .diff { background-color: #faa; }
+    /* Set min-width for lineWrapping to make sure it gets enough width
+       before lineWrapping and to make sure it dosent do a ugly line wrap */
+    .b .diff { background-color: #9f9; min-width: 60em; }
+    .a .intralineBg { background-color: #fee; }
+    .b .intralineBg { background-color: #dfd; }
+    .noIntraline .a .intralineBg { background-color: #faa; }
+    .noIntraline .b .intralineBg { background-color: #9f9; }
+
+    .dark .a .diff { background-color: #400; }
+    .dark .b .diff { background-color: #444; }
+
+    .dark .a .intralineBg { background-color: #888; }
+    .dark .b .intralineBg { background-color: #bbb; }
+    .dark .noIntraline .a .intralineBg { background-color: #400; }
+    .dark .noIntraline .b .intralineBg { background-color: #444; }
+
+    .patchSetNav, .diff_header {
+      background-color: #f7f7f7;
+      line-height: 1;
+    }
+
+    .difftable .CodeMirror-selectedtext {
+      background-color: inherit !important;
+    }
+    .difftable .CodeMirror-linenumber {
+      height: 1.11em;
+      cursor: pointer;
+    }
+    .difftable .CodeMirror div.CodeMirror-cursor {
+      border-left: 2px solid black;
+    }
+    .difftable .CodeMirror-dialog-bottom {
+      border-top: 0;
+      border-left: 1px solid #000;
+      border-bottom: 1px solid #000;
+      background-color: #f7f7f7;
+      top: 0;
+      right: 0;
+      bottom: auto;
+      left: auto;
+    }
+    .showLineNumbers .padding {
+      margin-left: 21px;
+      border-left: 2px solid #d64040;
+    }
+  </ui:style>
+  <g:HTMLPanel styleName='{style.difftable}'>
+    <table class='{style.table}'>
+      <tr ui:field='patchSetNavRow' class='{style.patchSetNav}'>
+        <td ui:field='patchSetNavCellA' class='{style.psNavA}'>
+          <d:PatchSetSelectBox ui:field='patchSetSelectBoxA' />
+        </td>
+        <td ui:field='patchSetNavCellB' class='{style.psNavB}'>
+          <d:PatchSetSelectBox ui:field='patchSetSelectBoxB' />
+        </td>
+      </tr>
+      <tr ui:field='diffHeaderRow' class='{res.diffTableStyle.diffHeader}'>
+        <td colspan='2'><pre ui:field='diffHeaderText' /></td>
+      </tr>
+      <tr>
+        <td ui:field='cmA' class='{style.a}' />
+        <td ui:field='cmB' class='{style.b}' />
+      </tr>
+    </table>
+    <g:FlowPanel ui:field='widgets' visible='false'/>
+  </g:HTMLPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
index 258eec6..5f86955 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
@@ -34,7 +34,6 @@
 import net.codemirror.lib.TextMarker;
 import net.codemirror.lib.TextMarker.FromTo;
 
-/** The Widget that handles expanding of skipped lines */
 class SkipBar extends Composite {
   interface Binder extends UiBinder<HTMLPanel, SkipBar> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
@@ -45,9 +44,9 @@
     String noExpand();
   }
 
-  @UiField(provided=true) Anchor skipNum;
-  @UiField(provided=true) Anchor upArrow;
-  @UiField(provided=true) Anchor downArrow;
+  @UiField(provided = true) Anchor skipNum;
+  @UiField(provided = true) Anchor upArrow;
+  @UiField(provided = true) Anchor downArrow;
   @UiField SkipBarStyle style;
 
   private final SkipManager manager;
@@ -133,7 +132,10 @@
 
   void expandBefore(int cnt) {
     expandSideBefore(cnt);
-    otherBar.expandSideBefore(cnt);
+
+    if (otherBar != null) {
+      otherBar.expandSideBefore(cnt);
+    }
   }
 
   private void expandSideBefore(int cnt) {
@@ -177,26 +179,40 @@
   void onExpandAll(@SuppressWarnings("unused") ClickEvent e) {
     expandAll();
     updateSelection();
-    otherBar.updateSelection();
+    if (otherBar != null) {
+      otherBar.expandAll();
+      otherBar.updateSelection();
+    }
+    cm.refresh();
     cm.focus();
   }
 
   private void expandAll() {
     expandSideAll();
-    otherBar.expandSideAll();
+    if (otherBar != null) {
+      otherBar.expandSideAll();
+    }
     manager.remove(this, otherBar);
   }
 
   @UiHandler("upArrow")
   void onExpandBefore(@SuppressWarnings("unused") ClickEvent e) {
     expandBefore(NUM_ROWS_TO_EXPAND);
+    if (otherBar != null) {
+      otherBar.expandBefore(NUM_ROWS_TO_EXPAND);
+    }
+    cm.refresh();
     cm.focus();
   }
 
   @UiHandler("downArrow")
   void onExpandAfter(@SuppressWarnings("unused") ClickEvent e) {
     expandAfter();
-    otherBar.expandAfter();
+
+    if (otherBar != null) {
+      otherBar.expandAfter();
+    }
+    cm.refresh();
     cm.focus();
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
index 5376588..cf23694 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
@@ -26,16 +26,16 @@
 import java.util.List;
 import java.util.Set;
 
-/** Collapses common regions with {@link SkipBar} for {@link SideBySide}. */
+/** Collapses common regions with {@link SkipBar} for {@link SideBySide}
+ *  and {@link Unified}. */
 class SkipManager {
-  private final SideBySide host;
-  private final CommentManager commentManager;
-  private Set<SkipBar> skipBars;
+  private final Set<SkipBar> skipBars;
+  private final DiffScreen host;
   private SkipBar line0;
 
-  SkipManager(SideBySide host, CommentManager commentManager) {
+  SkipManager(DiffScreen host) {
     this.host = host;
-    this.commentManager = commentManager;
+    this.skipBars = new HashSet<>();
   }
 
   void render(int context, DiffInfo diff) {
@@ -43,10 +43,10 @@
       return;
     }
 
-    JsArray<Region> regions = diff.content();
     List<SkippedLine> skips = new ArrayList<>();
     int lineA = 0;
     int lineB = 0;
+    JsArray<Region> regions = diff.content();
     for (int i = 0; i < regions.length(); i++) {
       Region current = regions.get(i);
       if (current.ab() != null || current.common() || current.skip() > 0) {
@@ -69,33 +69,58 @@
         lineB += current.b() != null ? current.b().length() : 0;
       }
     }
-    skips = commentManager.splitSkips(context, skips);
+    skips = host.getCommentManager().splitSkips(context, skips);
+    renderSkips(skips, lineA, lineB);
+  }
 
+  private void renderSkips(List<SkippedLine> skips, int lineA, int lineB) {
     if (!skips.isEmpty()) {
-      CodeMirror cmA = host.getCmFromSide(DisplaySide.A);
+      boolean isSideBySide = host.isSideBySide();
+      CodeMirror cmA = null;
+      if (isSideBySide) {
+        cmA = host.getCmFromSide(DisplaySide.A);
+      }
       CodeMirror cmB = host.getCmFromSide(DisplaySide.B);
 
-      skipBars = new HashSet<>();
       for (SkippedLine skip : skips) {
-        SkipBar barA = newSkipBar(cmA, DisplaySide.A, skip);
+        SkipBar barA = null;
         SkipBar barB = newSkipBar(cmB, DisplaySide.B, skip);
-        SkipBar.link(barA, barB);
-        skipBars.add(barA);
         skipBars.add(barB);
+        if (isSideBySide) {
+          barA = newSkipBar(cmA, DisplaySide.A, skip);
+          SkipBar.link(barA, barB);
+          skipBars.add(barA);
+        }
 
         if (skip.getStartA() == 0 || skip.getStartB() == 0) {
-          barA.upArrow.setVisible(false);
+          if (isSideBySide) {
+            barA.upArrow.setVisible(false);
+          }
           barB.upArrow.setVisible(false);
-          line0 = barB;
+          setLine0(barB);
         } else if (skip.getStartA() + skip.getSize() == lineA
             || skip.getStartB() + skip.getSize() == lineB) {
-          barA.downArrow.setVisible(false);
+          if (isSideBySide) {
+            barA.downArrow.setVisible(false);
+          }
           barB.downArrow.setVisible(false);
         }
       }
     }
   }
 
+  private SkipBar newSkipBar(CodeMirror cm, DisplaySide side,
+      SkippedLine skip) {
+    int start = host.getCmLine(
+        side == DisplaySide.A ? skip.getStartA() : skip.getStartB(), side);
+    int end = start + skip.getSize() - 1;
+
+    SkipBar bar = new SkipBar(this, cm);
+    host.getDiffTable().add(bar);
+    bar.collapse(start, end, true);
+    return bar;
+  }
+
   void ensureFirstLineIsVisible() {
     if (line0 != null) {
       line0.expandBefore(1);
@@ -104,11 +129,10 @@
   }
 
   void removeAll() {
-    if (skipBars != null) {
+    if (!skipBars.isEmpty()) {
       for (SkipBar bar : skipBars) {
         bar.expandSideAll();
       }
-      skipBars = null;
       line0 = null;
     }
   }
@@ -116,21 +140,16 @@
   void remove(SkipBar a, SkipBar b) {
     skipBars.remove(a);
     skipBars.remove(b);
-    if (line0 == a || line0 == b) {
-      line0 = null;
-    }
-    if (skipBars.isEmpty()) {
-      skipBars = null;
+    if (getLine0() == a || getLine0() == b) {
+      setLine0(null);
     }
   }
 
-  private SkipBar newSkipBar(CodeMirror cm, DisplaySide side, SkippedLine skip) {
-    int start = side == DisplaySide.A ? skip.getStartA() : skip.getStartB();
-    int end = start + skip.getSize() - 1;
+  SkipBar getLine0() {
+    return line0;
+  }
 
-    SkipBar bar = new SkipBar(this, cm);
-    host.diffTable.add(bar);
-    bar.collapse(start, end, true);
-    return bar;
+  void setLine0(SkipBar bar) {
+    line0 = bar;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
new file mode 100644
index 0000000..a231580
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -0,0 +1,396 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import static java.lang.Double.POSITIVE_INFINITY;
+
+import com.google.gerrit.client.Dispatcher;
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
+import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gerrit.client.projects.ConfigInfoCache;
+import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArrayString;
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.ScheduledCommand;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.event.dom.client.FocusEvent;
+import com.google.gwt.event.dom.client.FocusHandler;
+import com.google.gwt.event.dom.client.KeyPressEvent;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.ImageResourceRenderer;
+import com.google.gwt.user.client.ui.InlineHTML;
+import com.google.gwtexpui.globalkey.client.GlobalKey;
+import com.google.gwtexpui.safehtml.client.SafeHtml;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.CodeMirror.LineHandle;
+import net.codemirror.lib.Configuration;
+import net.codemirror.lib.Pos;
+import net.codemirror.lib.ScrollInfo;
+
+import java.util.Collections;
+import java.util.List;
+
+public class Unified extends DiffScreen {
+  interface Binder extends UiBinder<FlowPanel, Unified> {}
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  @UiField(provided = true)
+  UnifiedTable diffTable;
+
+  private CodeMirror cm;
+
+  private UnifiedChunkManager chunkManager;
+  private UnifiedCommentManager commentManager;
+
+  private boolean autoHideDiffTableHeader;
+
+  public Unified(
+      PatchSet.Id base,
+      PatchSet.Id revision,
+      String path,
+      DisplaySide startSide,
+      int startLine) {
+    super(base, revision, path, startSide, startLine, DiffView.UNIFIED_DIFF);
+
+    diffTable = new UnifiedTable(this, base, revision, path);
+    add(uiBinder.createAndBindUi(this));
+    addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
+  }
+
+  @Override
+  ScreenLoadCallback<ConfigInfoCache.Entry> getScreenLoadCallback(
+      final CommentsCollections comments) {
+    return new ScreenLoadCallback<ConfigInfoCache.Entry>(Unified.this) {
+      @Override
+      protected void preDisplay(ConfigInfoCache.Entry result) {
+        commentManager = new UnifiedCommentManager(
+            Unified.this,
+            base, revision, path,
+            result.getCommentLinkProcessor(),
+            getChangeStatus().isOpen());
+        setTheme(result.getTheme());
+        display(comments);
+        header.setupPrevNextFiles(comments);
+      }
+    };
+  }
+
+  @Override
+  public void onShowView() {
+    super.onShowView();
+
+    operation(new Runnable() {
+      @Override
+      public void run() {
+        resizeCodeMirror();
+        cm.refresh();
+      }
+    });
+    setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
+    diffTable.refresh();
+
+    if (getStartLine() == 0) {
+      DiffChunkInfo d = chunkManager.getFirst();
+      if (d != null) {
+        if (d.isEdit() && d.getSide() == DisplaySide.A) {
+          setStartSide(DisplaySide.B);
+        } else {
+          setStartSide(d.getSide());
+        }
+        setStartLine(chunkManager.getCmLine(d.getStart(), d.getSide()) + 1);
+      }
+    }
+    if (getStartSide() != null && getStartLine() > 0) {
+      cm.scrollToLine(
+          chunkManager.getCmLine(getStartLine() - 1, getStartSide()));
+      cm.focus();
+    } else {
+      cm.setCursor(Pos.create(0));
+      cm.focus();
+    }
+    if (Gerrit.isSignedIn() && prefs.autoReview()) {
+      header.autoReview();
+    }
+    prefetchNextFile();
+  }
+
+  @Override
+  void registerCmEvents(final CodeMirror cm) {
+    super.registerCmEvents(cm);
+
+    cm.on("scroll", new Runnable() {
+      @Override
+      public void run() {
+        ScrollInfo si = cm.getScrollInfo();
+        if (autoHideDiffTableHeader) {
+          updateDiffTableHeader(si);
+        }
+      }
+    });
+    maybeRegisterRenderEntireFileKeyMap(cm);
+  }
+
+  @Override
+  public void registerKeys() {
+    super.registerKeys();
+
+    registerHandlers();
+  }
+
+  @Override
+  FocusHandler getFocusHandler() {
+    return new FocusHandler() {
+      @Override
+      public void onFocus(FocusEvent event) {
+        cm.focus();
+      }
+    };
+  }
+
+  private void display(final CommentsCollections comments) {
+    final DiffInfo diff = getDiff();
+    setThemeStyles(prefs.theme().isDark());
+    setShowIntraline(prefs.intralineDifference());
+    if (prefs.showLineNumbers()) {
+      diffTable.addStyleName(Resources.I.diffTableStyle().showLineNumbers());
+    }
+
+    cm = newCm(
+        diff.metaA() == null ? diff.metaB() : diff.metaA(),
+        diff.textUnified(),
+        diffTable.cm);
+    setShowTabs(prefs.showTabs());
+
+    chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
+
+    operation(new Runnable() {
+      @Override
+      public void run() {
+        // Estimate initial CodeMirror height, fixed up in onShowView.
+        int height = Window.getClientHeight()
+            - (Gerrit.getHeaderFooterHeight() + 18);
+        cm.setHeight(height);
+
+        render(diff);
+        commentManager.render(comments, prefs.expandAllComments());
+        skipManager.render(prefs.context(), diff);
+      }
+    });
+
+    registerCmEvents(cm);
+
+    setPrefsAction(new PreferencesAction(this, prefs));
+    header.init(getPrefsAction(), getSideBySideDiffLink(), diff.unifiedWebLinks());
+    setAutoHideDiffHeader(prefs.autoHideDiffTableHeader());
+
+    setupSyntaxHighlighting();
+  }
+
+  private List<InlineHyperlink> getSideBySideDiffLink() {
+    InlineHyperlink toSideBySideDiffLink = new InlineHyperlink();
+    toSideBySideDiffLink.setHTML(
+        new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
+    toSideBySideDiffLink.setTargetHistoryToken(
+        Dispatcher.toSideBySide(base, revision, path));
+    toSideBySideDiffLink.setTitle(PatchUtil.C.sideBySideDiff());
+    return Collections.singletonList(toSideBySideDiffLink);
+  }
+
+  @Override
+  CodeMirror newCm(
+      DiffInfo.FileMeta meta,
+      String contents,
+      Element parent) {
+    JsArrayString gutters = JavaScriptObject.createArray().cast();
+    gutters.push(UnifiedTable.style.lineNumbersLeft());
+    gutters.push(UnifiedTable.style.lineNumbersRight());
+
+    return CodeMirror.create(parent, Configuration.create()
+        .set("cursorBlinkRate", prefs.cursorBlinkRate())
+        .set("cursorHeight", 0.85)
+        .set("gutters", gutters)
+        .set("inputStyle", "textarea")
+        .set("keyMap", "vim_ro")
+        .set("lineNumbers", false)
+        .set("lineWrapping", prefs.lineWrapping())
+        .set("matchBrackets", prefs.matchBrackets())
+        .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
+        .set("readOnly", true)
+        .set("scrollbarStyle", "overlay")
+        .set("styleSelectedText", true)
+        .set("showTrailingSpace", prefs.showWhitespaceErrors())
+        .set("tabSize", prefs.tabSize())
+        .set("theme", prefs.theme().name().toLowerCase())
+        .set("value", meta != null ? contents : "")
+        .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
+  }
+
+  @Override
+  void setShowLineNumbers(boolean b) {
+    super.setShowLineNumbers(b);
+
+    cm.refresh();
+  }
+
+  private void setLineNumber(DisplaySide side, int cmLine, Integer line,
+      String styleName) {
+    SafeHtml html = SafeHtml.asis(line != null ? line.toString() : "&nbsp;");
+    InlineHTML gutter = new InlineHTML(html);
+    diffTable.add(gutter);
+    gutter.setStyleName(styleName);
+    cm.setGutterMarker(cmLine, side == DisplaySide.A
+        ? UnifiedTable.style.lineNumbersLeft()
+        : UnifiedTable.style.lineNumbersRight(), gutter.getElement());
+  }
+
+  void setLineNumber(DisplaySide side, int cmLine, int line) {
+    setLineNumber(side, cmLine, line, UnifiedTable.style.unifiedLineNumber());
+  }
+
+  void setLineNumberEmpty(DisplaySide side, int cmLine) {
+    setLineNumber(side, cmLine, null,
+        UnifiedTable.style.unifiedLineNumberEmpty());
+  }
+
+  @Override
+  void setSyntaxHighlighting(boolean b) {
+    final DiffInfo diff = getDiff();
+    if (b) {
+      injectMode(diff, new AsyncCallback<Void>() {
+        @Override
+        public void onSuccess(Void result) {
+          if (prefs.syntaxHighlighting()) {
+            cm.setOption("mode", getContentType(diff.metaA() == null
+                ? diff.metaB()
+                : diff.metaA()));
+          }
+        }
+
+        @Override
+        public void onFailure(Throwable caught) {
+          prefs.syntaxHighlighting(false);
+        }
+      });
+    } else {
+      cm.setOption("mode", (String) null);
+    }
+  }
+
+  @Override
+  void setAutoHideDiffHeader(boolean autoHide) {
+    if (autoHide) {
+      updateDiffTableHeader(cm.getScrollInfo());
+    } else {
+      diffTable.setHeaderVisible(true);
+    }
+    autoHideDiffTableHeader = autoHide;
+  }
+
+  private void updateDiffTableHeader(ScrollInfo si) {
+    if (si.top() == 0) {
+      diffTable.setHeaderVisible(true);
+    } else if (si.top() > 0.5 * si.clientHeight()) {
+      diffTable.setHeaderVisible(false);
+    }
+  }
+
+  @Override
+  Runnable updateActiveLine(final CodeMirror cm) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        // The rendering of active lines has to be deferred. Reflow
+        // caused by adding and removing styles chokes Firefox when arrow
+        // key (or j/k) is held down. Performance on Chrome is fine
+        // without the deferral.
+        //
+        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
+          @Override
+          public void execute() {
+            LineHandle handle =
+                cm.getLineHandleVisualStart(cm.getCursor("end").line());
+            cm.extras().activeLine(handle);
+          }
+        });
+      }
+    };
+  }
+
+  @Override
+  CodeMirror getCmFromSide(DisplaySide side) {
+    return cm;
+  }
+
+  @Override
+  int getCmLine(int line, DisplaySide side) {
+    return chunkManager.getCmLine(line, side);
+  }
+
+  LineRegionInfo getLineRegionInfoFromCmLine(int cmLine) {
+    return chunkManager.getLineRegionInfoFromCmLine(cmLine);
+  }
+
+  @Override
+  void operation(final Runnable apply) {
+    cm.operation(new Runnable() {
+      @Override
+      public void run() {
+        apply.run();
+      }
+    });
+  }
+
+  @Override
+  CodeMirror[] getCms() {
+    return new CodeMirror[] {cm};
+  }
+
+  @Override
+  UnifiedTable getDiffTable() {
+    return diffTable;
+  }
+
+  @Override
+  UnifiedChunkManager getChunkManager() {
+    return chunkManager;
+  }
+
+  @Override
+  UnifiedCommentManager getCommentManager() {
+    return commentManager;
+  }
+
+  @Override
+  boolean isSideBySide() {
+    return false;
+  }
+
+  @Override
+  String getLineNumberClassName() {
+    return UnifiedTable.style.unifiedLineNumber();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml
new file mode 100644
index 0000000..85f46a6
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.ui.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:d='urn:import:com.google.gerrit.client.diff'>
+  <ui:style>
+    .unified {
+      margin-left: -5px;
+      margin-right: -5px;
+    }
+  </ui:style>
+  <g:FlowPanel styleName='{style.unified}'>
+    <d:Header ui:field='header'/>
+    <d:UnifiedTable ui:field='diffTable'/>
+  </g:FlowPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
new file mode 100644
index 0000000..74cf0df
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -0,0 +1,345 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.diff.DiffInfo.Region;
+import com.google.gerrit.client.diff.DiffInfo.Span;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.JsArrayString;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.NativeEvent;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.EventListener;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.CodeMirror.LineClassWhere;
+import net.codemirror.lib.Configuration;
+import net.codemirror.lib.Pos;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/** Colors modified regions for {@link Unified}. */
+class UnifiedChunkManager extends ChunkManager {
+  private static final JavaScriptObject focus = initOnClick();
+  private static native JavaScriptObject initOnClick() /*-{
+    return $entry(function(e){
+      @com.google.gerrit.client.diff.UnifiedChunkManager::focus(
+        Lcom/google/gwt/dom/client/NativeEvent;)(e)
+    });
+  }-*/;
+
+  private List<UnifiedDiffChunkInfo> chunks;
+
+  @Override
+  DiffChunkInfo getFirst() {
+    return !chunks.isEmpty() ? chunks.get(0) : null;
+  }
+
+  private static void focus(NativeEvent event) {
+    Element e = Element.as(event.getEventTarget());
+    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+      EventListener l = DOM.getEventListener(e);
+      if (l instanceof Unified) {
+        ((Unified) l).getCmFromSide(DisplaySide.A).focus();
+        event.stopPropagation();
+      }
+    }
+  }
+
+  static void focusOnClick(Element e) {
+    onClick(e, focus);
+  }
+
+  private final Unified host;
+  private final CodeMirror cm;
+
+  UnifiedChunkManager(Unified host,
+      CodeMirror cm,
+      Scrollbar scrollbar) {
+    super(scrollbar);
+
+    this.host = host;
+    this.cm = cm;
+  }
+
+  @Override
+  void render(DiffInfo diff) {
+    super.render();
+
+    chunks = new ArrayList<>();
+
+    int cmLine = 0;
+    boolean useIntralineBg = diff.metaA() == null || diff.metaB() == null;
+
+    for (Region current : Natives.asList(diff.content())) {
+      int origLineA = lineMapper.getLineA();
+      int origLineB = lineMapper.getLineB();
+      if (current.ab() != null) {
+        int length = current.ab().length();
+        lineMapper.appendCommon(length);
+        for (int i = 0; i < length; i++) {
+          host.setLineNumber(DisplaySide.A, cmLine + i, origLineA + i + 1);
+          host.setLineNumber(DisplaySide.B, cmLine + i, origLineB + i + 1);
+        }
+        cmLine += length;
+      } else if (current.skip() > 0) {
+        lineMapper.appendCommon(current.skip());
+        cmLine += current.skip(); // Maybe current.ab().length();
+      } else if (current.common()) {
+        lineMapper.appendCommon(current.b().length());
+        cmLine += current.b().length();
+      } else {
+        cmLine += render(current, cmLine, useIntralineBg);
+      }
+    }
+    host.setLineNumber(DisplaySide.A, cmLine, lineMapper.getLineA() + 1);
+    host.setLineNumber(DisplaySide.B, cmLine, lineMapper.getLineB() + 1);
+  }
+
+  private int render(Region region, int cmLine, boolean useIntralineBg) {
+    int startA = lineMapper.getLineA();
+    int startB = lineMapper.getLineB();
+
+    JsArrayString a = region.a();
+    JsArrayString b = region.b();
+    int aLen = a != null ? a.length() : 0;
+    int bLen = b != null ? b.length() : 0;
+    boolean insertOrDelete = a == null || b == null;
+
+    colorLines(cm,
+        insertOrDelete && !useIntralineBg
+            ? UnifiedTable.style.diffDelete()
+            : UnifiedTable.style.intralineDelete(), cmLine, aLen);
+    colorLines(cm,
+        insertOrDelete && !useIntralineBg
+            ? UnifiedTable.style.diffInsert()
+            : UnifiedTable.style.intralineInsert(), cmLine + aLen,
+        bLen);
+    markEdit(DisplaySide.A, cmLine, a, region.editA());
+    markEdit(DisplaySide.B, cmLine + aLen, b, region.editB());
+    addGutterTag(region, cmLine); // TODO: verify addGutterTag
+    lineMapper.appendReplace(aLen, bLen);
+
+    int endA = lineMapper.getLineA() - 1;
+    int endB = lineMapper.getLineB() - 1;
+    if (aLen > 0) {
+      addDiffChunk(DisplaySide.A, endA, aLen, cmLine, bLen > 0);
+      for (int j = 0; j < aLen; j++) {
+        host.setLineNumber(DisplaySide.A, cmLine + j, startA + j + 1);
+        host.setLineNumberEmpty(DisplaySide.B, cmLine + j);
+      }
+    }
+    if (bLen > 0) {
+      addDiffChunk(DisplaySide.B, endB, bLen, cmLine + aLen, aLen > 0);
+      for (int j = 0; j < bLen; j++) {
+        host.setLineNumberEmpty(DisplaySide.A, cmLine + aLen + j);
+        host.setLineNumber(DisplaySide.B, cmLine + aLen + j, startB + j + 1);
+      }
+    }
+    return aLen + bLen;
+  }
+
+  private void addGutterTag(Region region, int cmLine) {
+    if (region.a() == null) {
+      scrollbar.insert(cm, cmLine, region.b().length());
+    } else if (region.b() == null) {
+      scrollbar.delete(cm, cm, cmLine, region.a().length());
+    } else {
+      scrollbar.edit(cm, cmLine, region.b().length());
+    }
+  }
+
+  private void markEdit(DisplaySide side, int startLine,
+      JsArrayString lines, JsArray<Span> edits) {
+    if (lines == null || edits == null) {
+      return;
+    }
+
+    EditIterator iter = new EditIterator(lines, startLine);
+    Configuration bg = Configuration.create()
+        .set("className", getIntralineBgFromSide(side))
+        .set("readOnly", true);
+
+    Configuration diff = Configuration.create()
+        .set("className", getDiffColorFromSide(side))
+        .set("readOnly", true);
+
+    Pos last = Pos.create(0, 0);
+    for (Span span : Natives.asList(edits)) {
+      Pos from = iter.advance(span.skip());
+      Pos to = iter.advance(span.mark());
+      if (from.line() == last.line()) {
+        getMarkers().add(cm.markText(last, from, bg));
+      } else {
+        getMarkers().add(cm.markText(Pos.create(from.line(), 0), from, bg));
+      }
+      getMarkers().add(cm.markText(from, to, diff));
+      last = to;
+      colorLines(cm, LineClassWhere.BACKGROUND,
+          getDiffColorFromSide(side),
+          from.line(), to.line());
+    }
+  }
+
+  private String getIntralineBgFromSide(DisplaySide side) {
+    return side == DisplaySide.A ? UnifiedTable.style.intralineDelete()
+        : UnifiedTable.style.intralineInsert();
+  }
+
+  private String getDiffColorFromSide(DisplaySide side) {
+    return side == DisplaySide.A ? UnifiedTable.style.diffDelete()
+        : UnifiedTable.style.diffInsert();
+  }
+
+  private void addDiffChunk(DisplaySide side, int chunkEnd, int chunkSize,
+      int cmLine, boolean edit) {
+    chunks.add(new UnifiedDiffChunkInfo(side, chunkEnd - chunkSize + 1, chunkEnd,
+        cmLine, edit));
+  }
+
+  @Override
+  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        int line = cm.extras().hasActiveLine()
+            ? cm.getLineNumber(cm.extras().activeLine())
+            : 0;
+        int res = Collections.binarySearch(
+                chunks,
+                new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
+                getDiffChunkComparatorCmLine());
+        diffChunkNavHelper(chunks, host, res, dir);
+      }
+    };
+  }
+
+  /** Diff chunks are ordered by their starting lines in CodeMirror */
+  private Comparator<UnifiedDiffChunkInfo> getDiffChunkComparatorCmLine() {
+    return new Comparator<UnifiedDiffChunkInfo>() {
+      @Override
+      public int compare(UnifiedDiffChunkInfo o1, UnifiedDiffChunkInfo o2) {
+        return o1.getCmLine() - o2.getCmLine();
+      }
+    };
+  }
+
+  @Override
+  int getCmLine(int line, DisplaySide side) {
+    int res =
+        Collections.binarySearch(chunks,
+            new UnifiedDiffChunkInfo(
+                side, line, 0, 0, false), // Dummy DiffChunkInfo
+            getDiffChunkComparator());
+    if (res >= 0) {
+      return chunks.get(res).getCmLine();
+    }
+    // The line might be within a DiffChunk
+    res = -res - 1;
+    if (res > 0) {
+      UnifiedDiffChunkInfo info = chunks.get(res - 1);
+      if (side == DisplaySide.A && info.isEdit()
+          && info.getSide() == DisplaySide.B) {
+        // Need to use the start and cmLine of the deletion chunk
+        UnifiedDiffChunkInfo delete = chunks.get(res - 2);
+        if (line <= delete.getEnd()) {
+          return delete.getCmLine() + line - delete.getStart();
+        }
+        // Need to add the length of the insertion chunk
+        return delete.getCmLine() + line - delete.getStart()
+            + info.getEnd() - info.getStart() + 1;
+      } else if (side == info.getSide()) {
+        return info.getCmLine() + line - info.getStart();
+      } else {
+        return info.getCmLine()
+            + lineMapper.lineOnOther(side, line).getLine()
+            - info.getStart();
+      }
+    }
+    return line;
+  }
+
+  LineRegionInfo getLineRegionInfoFromCmLine(int cmLine) {
+    int res =
+        Collections.binarySearch(chunks,
+            new UnifiedDiffChunkInfo(
+                DisplaySide.A, 0, 0, cmLine, false), // Dummy DiffChunkInfo
+            getDiffChunkComparatorCmLine());
+    if (res >= 0) {  // The line is right at the start of a diff chunk.
+      UnifiedDiffChunkInfo info = chunks.get(res);
+      return new LineRegionInfo(
+          info.getStart(), displaySideToRegionType(info.getSide()));
+    }
+    // The line might be within or after a diff chunk.
+    res = -res - 1;
+    if (res > 0) {
+      UnifiedDiffChunkInfo info = chunks.get(res - 1);
+      int lineOnInfoSide = info.getStart() + cmLine - info.getCmLine();
+      if (lineOnInfoSide > info.getEnd()) { // After a diff chunk
+        if (info.getSide() == DisplaySide.A) {
+          // For the common region after a deletion chunk, associate the line
+          // on side B with a common region.
+          return new LineRegionInfo(
+              lineMapper.lineOnOther(DisplaySide.A, lineOnInfoSide)
+                  .getLine(), RegionType.COMMON);
+        }
+        return new LineRegionInfo(lineOnInfoSide, RegionType.COMMON);
+      }
+      // Within a diff chunk
+      return new LineRegionInfo(
+          lineOnInfoSide, displaySideToRegionType(info.getSide()));
+    }
+    // The line is before any diff chunk, so it always equals cmLine and
+    // belongs to a common region.
+    return new LineRegionInfo(cmLine, RegionType.COMMON);
+  }
+
+  enum RegionType {
+    INSERT, DELETE, COMMON,
+  }
+
+  private static RegionType displaySideToRegionType(DisplaySide side) {
+    return side == DisplaySide.A ? RegionType.DELETE : RegionType.INSERT;
+  }
+
+  /**
+   * Helper class to associate a line in the original file with the type of the
+   * region it belongs to.
+   *
+   * @field line The 0-based line number in the original file. Note that this
+   *     might be different from the line number shown in CodeMirror.
+   * @field type The type of the region the line belongs to. Can be INSERT,
+   *     DELETE or COMMON.
+   */
+  static class LineRegionInfo {
+    final int line;
+    final RegionType type;
+
+    LineRegionInfo(int line, RegionType type) {
+      this.line = line;
+      this.type = type;
+    }
+
+    DisplaySide getSide() {
+      // Always return DisplaySide.B for INSERT or COMMON
+      return type == RegionType.DELETE ? DisplaySide.A : DisplaySide.B;
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
new file mode 100644
index 0000000..4effb46
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gwt.user.client.Timer;
+
+import net.codemirror.lib.CodeMirror;
+
+/**
+ * LineWidget attached to a CodeMirror container.
+ *
+ * When a comment is placed on a line a CommentWidget is created.
+ * The group tracks all comment boxes on a line in unified diff view.
+ */
+class UnifiedCommentGroup extends CommentGroup {
+  UnifiedCommentGroup(UnifiedCommentManager manager, CodeMirror cm, DisplaySide side, int line) {
+    super(manager, cm, side, line);
+  }
+
+  @Override
+  void remove(DraftBox box) {
+    super.remove(box);
+
+    if (0 < getBoxCount()) {
+      resize();
+    } else {
+      detach();
+    }
+  }
+
+  @Override
+  void init(DiffTable parent) {
+    if (getLineWidget() == null) {
+      attach(parent);
+    }
+  }
+
+  @Override
+  void handleRedraw() {
+    getLineWidget().onRedraw(new Runnable() {
+      @Override
+      public void run() {
+        if (canComputeHeight()) {
+          if (getResizeTimer() != null) {
+            getResizeTimer().cancel();
+            setResizeTimer(null);
+          }
+          reportHeightChange();
+        } else if (getResizeTimer() == null) {
+          setResizeTimer(new Timer() {
+            @Override
+            public void run() {
+              if (canComputeHeight()) {
+                cancel();
+                setResizeTimer(null);
+                reportHeightChange();
+              }
+            }
+          });
+          getResizeTimer().scheduleRepeating(5);
+        }
+      }
+    });
+  }
+
+  @Override
+  void resize() {
+    if (getLineWidget() != null) {
+      reportHeightChange();
+    }
+  }
+
+  private void reportHeightChange() {
+    getLineWidget().changed();
+    updateSelection();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
new file mode 100644
index 0000000..8968bc7
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
+import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
+import com.google.gerrit.client.diff.UnifiedChunkManager.RegionType;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.Pos;
+import net.codemirror.lib.TextMarker.FromTo;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/** Tracks comment widgets for {@link Unified}. */
+class UnifiedCommentManager extends CommentManager {
+
+  private final SortedMap<Integer, CommentGroup> mergedMap;
+
+  // In Unified, a CodeMirror line can have up to two CommentGroups - one for
+  // the base side and one for the revision, so we need to keep track of the
+  // duplicates and replace the entries in mergedMap on draft removal.
+  private final Map<Integer, CommentGroup> duplicates;
+
+  UnifiedCommentManager(Unified host,
+      PatchSet.Id base, PatchSet.Id revision,
+      String path,
+      CommentLinkProcessor clp,
+      boolean open) {
+    super(host, base, revision, path, clp, open);
+    mergedMap = new TreeMap<>();
+    duplicates = new HashMap<>();
+  }
+
+  @Override
+  SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side) {
+    return mergedMap;
+  }
+
+  @Override
+  void clearLine(DisplaySide side, int line, CommentGroup group) {
+    super.clearLine(side, line, group);
+
+    if (mergedMap.get(line) == group) {
+      mergedMap.remove(line);
+      if (duplicates.containsKey(line)) {
+        mergedMap.put(line, duplicates.remove(line));
+      }
+    }
+  }
+
+  @Override
+  void newDraftOnGutterClick(CodeMirror cm, String gutterClass,
+      int cmLinePlusOne) {
+    if (!Gerrit.isSignedIn()) {
+      signInCallback(cm).run();
+    } else {
+      LineRegionInfo info =
+          ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+      DisplaySide side =
+          gutterClass.equals(UnifiedTable.style.lineNumbersLeft())
+              ? DisplaySide.A
+              : DisplaySide.B;
+      int line = info.line;
+      if (info.getSide() != side) {
+        line = host.lineOnOther(info.getSide(), line).getLine();
+      }
+      insertNewDraft(side, line + 1);
+    }
+  }
+
+  @Override
+  CommentGroup getCommentGroupOnActiveLine(CodeMirror cm) {
+    CommentGroup group = null;
+    if (cm.extras().hasActiveLine()) {
+      int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
+      LineRegionInfo info =
+          ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+      CommentGroup forSide = map(info.getSide()).get(cmLinePlusOne);
+      group = forSide == null
+          ? map(info.getSide().otherSide()).get(cmLinePlusOne)
+          : forSide;
+    }
+    return group;
+  }
+
+  @Override
+  Collection<Integer> getLinesWithCommentGroups() {
+    return mergedMap.tailMap(1).keySet();
+  }
+
+  @Override
+  String getTokenSuffixForActiveLine(CodeMirror cm) {
+    int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
+    LineRegionInfo info =
+        ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+    return (info.getSide() == DisplaySide.A ? "a" : "") + cmLinePlusOne;
+  }
+
+  @Override
+  void newDraft(CodeMirror cm) {
+    if (cm.somethingSelected()) {
+      FromTo fromTo = adjustSelection(cm);
+      Pos from = fromTo.from();
+      Pos to = fromTo.to();
+      Unified unified = (Unified) host;
+      UnifiedChunkManager manager = unified.getChunkManager();
+      LineRegionInfo fromInfo =
+          unified.getLineRegionInfoFromCmLine(from.line());
+      LineRegionInfo toInfo =
+          unified.getLineRegionInfoFromCmLine(to.line());
+      DisplaySide side = toInfo.getSide();
+
+      // Handle special cases in selections that span multiple regions. Force
+      // start line to be on the same side as the end line.
+      if ((fromInfo.type == RegionType.INSERT
+          || fromInfo.type == RegionType.COMMON)
+          && toInfo.type == RegionType.DELETE) {
+        LineOnOtherInfo infoOnSideA = manager.lineMapper
+            .lineOnOther(DisplaySide.B, fromInfo.line);
+        int startLineOnSideA = infoOnSideA.getLine();
+        if (infoOnSideA.isAligned()) {
+          from.line(startLineOnSideA);
+        } else {
+          from.line(startLineOnSideA + 1);
+        }
+        from.ch(0);
+        to.line(toInfo.line);
+      } else if (fromInfo.type == RegionType.DELETE
+          && toInfo.type == RegionType.INSERT) {
+        LineOnOtherInfo infoOnSideB = manager.lineMapper
+            .lineOnOther(DisplaySide.A, fromInfo.line);
+        int startLineOnSideB = infoOnSideB.getLine();
+        if (infoOnSideB.isAligned()) {
+          from.line(startLineOnSideB);
+        } else {
+          from.line(startLineOnSideB + 1);
+        }
+        from.ch(0);
+        to.line(toInfo.line);
+      } else if (fromInfo.type == RegionType.DELETE
+          && toInfo.type == RegionType.COMMON) {
+        int toLineOnSideA = manager.lineMapper
+            .lineOnOther(DisplaySide.B, toInfo.line).getLine();
+        from.line(fromInfo.line);
+        // Force the end line to be on the same side as the start line.
+        to.line(toLineOnSideA);
+        side = DisplaySide.A;
+      } else { // Common case
+        from.line(fromInfo.line);
+        to.line(toInfo.line);
+      }
+
+      addDraftBox(side, CommentInfo.create(
+              getPath(),
+              getStoredSideFromDisplaySide(side),
+              to.line() + 1,
+              CommentRange.create(fromTo))).setEdit(true);
+      cm.setCursor(Pos.create(host.getCmLine(to.line(), side), to.ch()));
+      cm.setSelection(cm.getCursor());
+    } else {
+      int cmLine = cm.getLineNumber(cm.extras().activeLine());
+      LineRegionInfo info =
+          ((Unified) host).getLineRegionInfoFromCmLine(cmLine);
+      insertNewDraft(info.getSide(), cmLine + 1);
+    }
+  }
+
+  @Override
+  CommentGroup group(DisplaySide side, int cmLinePlusOne) {
+    Map<Integer, CommentGroup> map = map(side);
+    CommentGroup existing = map.get(cmLinePlusOne);
+    if (existing != null) {
+      return existing;
+    }
+
+    UnifiedCommentGroup g = new UnifiedCommentGroup(
+        this, host.getCmFromSide(side), side, cmLinePlusOne);
+    map.put(cmLinePlusOne, g);
+    if (mergedMap.containsKey(cmLinePlusOne)) {
+      duplicates.put(cmLinePlusOne, mergedMap.remove(cmLinePlusOne));
+    }
+    mergedMap.put(cmLinePlusOne, g);
+
+    if (isAttached()) {
+      g.init(host.getDiffTable());
+      g.handleRedraw();
+    }
+
+    return g;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
new file mode 100644
index 0000000..844be78
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+public class UnifiedDiffChunkInfo extends DiffChunkInfo {
+
+  private int cmLine;
+
+  UnifiedDiffChunkInfo(DisplaySide side,
+      int start, int end, int cmLine, boolean edit) {
+    super(side, start, end, edit);
+    this.cmLine = cmLine;
+  }
+
+  int getCmLine() {
+    return cmLine;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
new file mode 100644
index 0000000..72b3e49
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.diff;
+
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.resources.client.CssResource;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.user.client.ui.HTMLPanel;
+
+/**
+ * A table with one row and one column to hold a unified CodeMirror displaying
+ * the files to be compared.
+ */
+class UnifiedTable extends DiffTable {
+  interface Binder extends UiBinder<HTMLPanel, UnifiedTable> {}
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  interface DiffTableStyle extends CssResource {
+    String intralineInsert();
+    String intralineDelete();
+    String diffInsert();
+    String diffDelete();
+    String unifiedLineNumber();
+    String unifiedLineNumberEmpty();
+    String lineNumbersLeft();
+    String lineNumbersRight();
+  }
+
+  private Unified parent;
+  @UiField Element cm;
+  @UiField static DiffTableStyle style;
+
+  UnifiedTable(Unified parent, PatchSet.Id base, PatchSet.Id revision,
+      String path) {
+    super(parent, base, revision, path);
+
+    initWidget(uiBinder.createAndBindUi(this));
+    this.parent = parent;
+  }
+
+  @Override
+  void setHideEmptyPane(boolean hide) {
+  }
+
+  @Override
+  boolean isVisibleA() {
+    return true;
+  }
+
+  @Override
+  Unified getDiffScreen() {
+    return parent;
+  }
+
+  @Override
+  int getHeaderHeight() {
+    int h = patchSetSelectBoxA.getOffsetHeight()
+        + patchSetSelectBoxB.getOffsetHeight();
+    if (hasHeader()) {
+      h += diffHeaderRow.getOffsetHeight();
+    }
+    return h;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml
new file mode 100644
index 0000000..c2cefe4
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.ui.xml
@@ -0,0 +1,152 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2013 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:d='urn:import:com.google.gerrit.client.diff'>
+  <ui:with field='res' type='com.google.gerrit.client.diff.Resources'/>
+  <ui:style gss='false' type='com.google.gerrit.client.diff.UnifiedTable.DiffTableStyle'>
+    @external .CodeMirror, .CodeMirror-selectedtext;
+    @external .CodeMirror-vscrollbar .CodeMirror-scroll;
+    @external .CodeMirror-dialog-bottom;
+    @external .CodeMirror-cursor;
+
+    @external .dark, .unifiedLineNumber, .noIntraline, .showLineNumbers;
+
+    .difftable .patchSetNav,
+    .difftable .CodeMirror {
+      -webkit-touch-callout: none;
+      -webkit-user-select: none;
+      -khtml-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+    }
+
+    .difftable .CodeMirror pre {
+      overflow: visible;
+      border-right: 0;
+      width: auto;
+    }
+
+    /* Preserve space for underscores. If this changes
+     * see ChunkManager.addPadding() and adjust there.
+     */
+    .difftable .CodeMirror pre,
+    .difftable .CodeMirror pre span {
+      padding-bottom: 1px;
+    }
+    .table {
+      width: 100%;
+      table-layout: fixed;
+      border-spacing: 0;
+    }
+    .table td { padding: 0 }
+
+    /* Hide scrollbars. */
+    .difftable .CodeMirror-scroll { padding-right: 0; }
+    .difftable .CodeMirror-vscrollbar { display: none !important; }
+
+    .diffDelete { background-color: #faa; }
+    .diffInsert { background-color: #9f9; }
+    .intralineDelete { background-color: #fee; }
+    .intralineInsert { background-color: #dfd; }
+    .noIntraline .intralineDelete { background-color: #faa; }
+    .noIntraline .intralineInsert { background-color: #9f9; }
+
+    .dark .diffDelete { background-color: #400; }
+    .dark .diffInsert { background-color: #444; }
+    .dark .intralineDelete { background-color: #888; }
+    .dark .intralineInsert { background-color: #bbb; }
+    .dark .noIntraline .intralineDelete { background-color: #400; }
+    .dark .noIntraline .intralineInsert { background-color: #444; }
+
+    .patchSetNav, .diff_header {
+      background-color: #f7f7f7;
+      line-height: 1;
+    }
+
+    .difftable .CodeMirror-selectedtext {
+      background-color: inherit !important;
+    }
+    .difftable .CodeMirror div.CodeMirror-cursor {
+      border-left: 2px solid black;
+    }
+    .difftable .CodeMirror-dialog-bottom {
+      border-top: 0;
+      border-left: 1px solid #000;
+      border-bottom: 1px solid #000;
+      background-color: #f7f7f7;
+      top: 0;
+      right: 0;
+      bottom: auto;
+      left: auto;
+    }
+    .showLineNumbers .lineNumbersLeft, .showLineNumbers .lineNumbersRight {
+      min-width: 20px;
+      width: 3em; /* TODO: This needs to be set based on number of lines */
+    }
+    .showLineNumbers .lineNumbersLeft {
+      border-right: 1px solid #ddd;
+    }
+    .unifiedLineNumber {
+      display: none;
+    }
+    .showLineNumbers .unifiedLineNumber {
+      display: block;
+      cursor: pointer;
+      padding: 0 3px 0 5px;
+      min-width: 20px;
+      text-align: right;
+      color: #999;
+    }
+    .unifiedLineNumberEmpty {
+      display: none;
+    }
+    .showLineNumbers .unifiedLineNumberEmpty {
+      display: block;
+      margin-left: 28px;
+      border-left: 2px solid #d64040;
+      padding-bottom: 1px;
+    }
+  </ui:style>
+  <g:HTMLPanel styleName='{style.difftable}'>
+    <table class='{style.table}'>
+      <tr ui:field='patchSetNavRow' class='{style.patchSetNav}'>
+        <td>
+          <table class='{style.table}'>
+            <tr>
+              <td ui:field='patchSetNavCellA'>
+                <d:PatchSetSelectBox ui:field='patchSetSelectBoxA' />
+              </td>
+            </tr>
+            <tr>
+              <td ui:field='patchSetNavCellB'>
+                <d:PatchSetSelectBox ui:field='patchSetSelectBoxB' />
+              </td>
+            </tr>
+          </table>
+        </td>
+      </tr>
+      <tr ui:field='diffHeaderRow' class='{res.diffTableStyle.diffHeader}'>
+        <td><pre ui:field='diffHeaderText' /></td>
+      </tr>
+      <tr>
+        <td ui:field='cm'/>
+      </tr>
+    </table>
+    <g:FlowPanel ui:field='widgets' visible='false'/>
+  </g:HTMLPanel>
+</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
index b57cdac..0c5be8e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
@@ -28,10 +28,10 @@
 class DocTable extends NavigationTable<DocInfo> {
   private static final int C_TITLE = 1;
 
-  private int rows = 0;
-  private int dataBeginRow = 0;
+  private int rows;
+  private int dataBeginRow;
 
-  public DocTable() {
+  DocTable() {
     super(Util.C.docItemHelp());
 
     table.setText(0, C_TITLE, Util.C.docTableColumnTitle());
@@ -117,7 +117,7 @@
   }
 
   public static class DocLink extends Anchor {
-    public DocLink(DocInfo d) {
+    DocLink(DocInfo d) {
       super(com.google.gerrit.client.changes.Util.cropSubject(d.title()));
       setHref(d.getFullUrl());
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
index 2a3ca5e..086376a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.AccountApi;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
 import com.google.gerrit.client.info.DownloadInfo.DownloadSchemeInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -70,10 +70,10 @@
 
     select();
 
-    AccountPreferencesInfo prefs = Gerrit.getUserPreferences();
+    GeneralPreferences prefs = Gerrit.getUserPreferences();
     if (Gerrit.isSignedIn() && !schemeName.equals(prefs.downloadScheme())) {
       prefs.downloadScheme(schemeName);
-      AccountPreferencesInfo in = AccountPreferencesInfo.create();
+      GeneralPreferences in = GeneralPreferences.create();
       in.downloadScheme(schemeName);
       AccountApi.self().view("preferences")
           .put(in, new AsyncCallback<JavaScriptObject>() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
index e95fdfc..8dc4137 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 interface EditConstants extends Constants {
-  static final EditConstants I = GWT.create(EditConstants.class);
+  EditConstants I = GWT.create(EditConstants.class);
 
   String closeUnsavedChanges();
   String cancelUnsavedChanges();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
index 7a439b5..6694e7c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.client.editor;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.account.AccountApi;
 import com.google.gerrit.client.account.EditPreferences;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.NpIntTextBox;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.KeyMapType;
 import com.google.gerrit.extensions.client.Theme;
 import com.google.gwt.core.client.GWT;
@@ -60,6 +60,7 @@
   @UiField Anchor close;
   @UiField NpIntTextBox tabWidth;
   @UiField NpIntTextBox lineLength;
+  @UiField NpIntTextBox indentUnit;
   @UiField NpIntTextBox cursorBlinkRate;
   @UiField ToggleButton topMenu;
   @UiField ToggleButton syntaxHighlighting;
@@ -69,6 +70,7 @@
   @UiField ToggleButton matchBrackets;
   @UiField ToggleButton lineWrapping;
   @UiField ToggleButton autoCloseBrackets;
+  @UiField ToggleButton showBase;
   @UiField ListBox theme;
   @UiField ListBox keyMap;
   @UiField Button apply;
@@ -95,6 +97,7 @@
 
     tabWidth.setIntValue(prefs.tabSize());
     lineLength.setIntValue(prefs.lineLength());
+    indentUnit.setIntValue(prefs.indentUnit());
     cursorBlinkRate.setIntValue(prefs.cursorBlinkRate());
     topMenu.setValue(!prefs.hideTopMenu());
     syntaxHighlighting.setValue(prefs.syntaxHighlighting());
@@ -104,6 +107,7 @@
     matchBrackets.setValue(prefs.matchBrackets());
     lineWrapping.setValue(prefs.lineWrapping());
     autoCloseBrackets.setValue(prefs.autoCloseBrackets());
+    showBase.setValue(prefs.showBase());
     setTheme(prefs.theme());
     setKeyMapType(prefs.keyMapType());
   }
@@ -114,7 +118,7 @@
     if (v != null && v.length() > 0) {
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.getEditor().setOption("tabSize", v);
+        view.setOption("tabSize", v);
       }
     }
   }
@@ -130,6 +134,17 @@
     }
   }
 
+  @UiHandler("indentUnit")
+  void onIndentUnit(ValueChangeEvent<String> e) {
+    String v = e.getValue();
+    if (v != null && v.length() > 0) {
+      prefs.indentUnit(Math.max(0, Integer.parseInt(v)));
+      if (view != null) {
+        view.setIndentUnit(prefs.indentUnit());
+      }
+    }
+  }
+
   @UiHandler("cursorBlinkRate")
   void onCursoBlinkRate(ValueChangeEvent<String> e) {
     String v = e.getValue();
@@ -138,7 +153,7 @@
       // don't let user shoot himself in the foot.
       prefs.cursorBlinkRate(Math.max(0, Integer.parseInt(v)));
       if (view != null) {
-        view.getEditor().setOption("cursorBlinkRate", prefs.cursorBlinkRate());
+        view.setOption("cursorBlinkRate", prefs.cursorBlinkRate());
       }
     }
   }
@@ -148,7 +163,7 @@
     prefs.hideTopMenu(!e.getValue());
     if (view != null) {
       Gerrit.setHeaderVisible(!prefs.hideTopMenu());
-      view.resizeCodeMirror();
+      view.adjustHeight();
     }
   }
 
@@ -188,7 +203,7 @@
   void onMatchBrackets(ValueChangeEvent<Boolean> e) {
     prefs.matchBrackets(e.getValue());
     if (view != null) {
-      view.getEditor().setOption("matchBrackets", prefs.matchBrackets());
+      view.setOption("matchBrackets", prefs.matchBrackets());
     }
   }
 
@@ -208,6 +223,15 @@
     }
   }
 
+  @UiHandler("showBase")
+  void onShowBase(ValueChangeEvent<Boolean> e) {
+    Boolean value = e.getValue();
+    prefs.showBase(value);
+    if (view != null) {
+      view.showBase.setValue(value, true);
+    }
+  }
+
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
     final Theme newTheme = Theme.valueOf(theme.getValue(theme.getSelectedIndex()));
@@ -216,13 +240,7 @@
       ThemeLoader.loadTheme(newTheme, new GerritCallback<Void>() {
         @Override
         public void onSuccess(Void result) {
-          view.getEditor().operation(new Runnable() {
-            @Override
-            public void run() {
-              String t = newTheme.name().toLowerCase();
-              view.getEditor().setOption("theme", t);
-            }
-          });
+          view.setTheme(newTheme);
         }
       });
     }
@@ -234,7 +252,7 @@
         keyMap.getValue(keyMap.getSelectedIndex()));
     prefs.keyMapType(keyMapType);
     if (view != null) {
-      view.getEditor().setOption("keyMap", keyMapType.name().toLowerCase());
+      view.setOption("keyMap", keyMapType.name().toLowerCase());
     }
   }
 
@@ -245,12 +263,13 @@
 
   @UiHandler("save")
   void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    AccountApi.putEditPreferences(prefs, new GerritCallback<VoidResult>() {
-      @Override
-      public void onSuccess(VoidResult n) {
-        prefs.copyTo(Gerrit.getEditPreferences());
-      }
-    });
+    AccountApi.putEditPreferences(prefs,
+        new GerritCallback<EditPreferences>() {
+          @Override
+          public void onSuccess(EditPreferences p) {
+            Gerrit.setEditPreferences(p.copyTo(new EditPreferencesInfo()));
+          }
+        });
     close();
   }
 
@@ -276,27 +295,9 @@
   }
 
   private void initTheme() {
-    theme.addItem(
-        Theme.DEFAULT.name().toLowerCase(),
-        Theme.DEFAULT.name());
-    theme.addItem(
-        Theme.ECLIPSE.name().toLowerCase(),
-        Theme.ECLIPSE.name());
-    theme.addItem(
-        Theme.ELEGANT.name().toLowerCase(),
-        Theme.ELEGANT.name());
-    theme.addItem(
-        Theme.NEAT.name().toLowerCase(),
-        Theme.NEAT.name());
-    theme.addItem(
-        Theme.MIDNIGHT.name().toLowerCase(),
-        Theme.MIDNIGHT.name());
-    theme.addItem(
-        Theme.NIGHT.name().toLowerCase(),
-        Theme.NIGHT.name());
-    theme.addItem(
-        Theme.TWILIGHT.name().toLowerCase(),
-        Theme.TWILIGHT.name());
+    for (Theme t : Theme.values()) {
+      theme.addItem(t.name().toLowerCase(), t.name());
+    }
   }
 
   private void setKeyMapType(KeyMapType v) {
@@ -311,14 +312,8 @@
   }
 
   private void initKeyMapType() {
-    keyMap.addItem(
-        KeyMapType.DEFAULT.name().toLowerCase(),
-        KeyMapType.DEFAULT.name());
-    keyMap.addItem(
-        KeyMapType.EMACS.name().toLowerCase(),
-        KeyMapType.EMACS.name());
-    keyMap.addItem(
-        KeyMapType.VIM.name().toLowerCase(),
-        KeyMapType.VIM.name());
+    for (KeyMapType t : KeyMapType.values()) {
+      keyMap.addItem(t.name().toLowerCase(), t.name());
+    }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
index 3d0692c..6379b67 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
@@ -185,6 +185,12 @@
             alignment='RIGHT'/></td>
       </tr>
       <tr>
+        <th><ui:msg>Indent Unit</ui:msg></th>
+        <td><x:NpIntTextBox ui:field='indentUnit'
+            visibleLength='4'
+            alignment='RIGHT'/></td>
+      </tr>
+      <tr>
         <th><ui:msg>Cursor Blink Rate</ui:msg></th>
         <td><x:NpIntTextBox ui:field='cursorBlinkRate'
             visibleLength='4'
@@ -247,6 +253,13 @@
         </g:ToggleButton></td>
       </tr>
       <tr>
+        <th><ui:msg>Show Base Version</ui:msg></th>
+        <td><g:ToggleButton ui:field='showBase'>
+          <g:upFace><ui:msg>Hide</ui:msg></g:upFace>
+          <g:downFace><ui:msg>Show</ui:msg></g:downFace>
+        </g:ToggleButton></td>
+      </tr>
+      <tr>
         <td></td>
         <td>
           <g:Button ui:field='apply' styleName='{style.apply}'>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index a546c62..8a141f1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.client.ui.Screen;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.client.KeyMapType;
+import com.google.gerrit.extensions.client.Theme;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
@@ -50,11 +51,14 @@
 import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.event.logical.shared.ResizeEvent;
 import com.google.gwt.event.logical.shared.ResizeHandler;
+import com.google.gwt.event.logical.shared.ValueChangeEvent;
 import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.resources.client.CssResource;
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.uibinder.client.UiHandler;
@@ -63,16 +67,21 @@
 import com.google.gwt.user.client.Window.ClosingHandler;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 
+import net.codemirror.addon.AddonInjector;
+import net.codemirror.addon.Addons;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.ChangesHandler;
+import net.codemirror.lib.CodeMirror.CommandRunner;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.KeyMap;
+import net.codemirror.lib.MergeView;
 import net.codemirror.lib.Pos;
 import net.codemirror.mode.ModeInfo;
 import net.codemirror.mode.ModeInjector;
@@ -84,14 +93,23 @@
   interface Binder extends UiBinder<HTMLPanel, EditScreen> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
+  interface Style extends CssResource {
+    String fullWidth();
+    String base();
+    String hideBase();
+  }
+
   private final PatchSet.Id base;
   private final PatchSet.Id revision;
   private final String path;
   private final int startLine;
   private EditPreferences prefs;
   private EditPreferencesAction editPrefsAction;
-  private CodeMirror cm;
+  private MergeView mv;
+  private CodeMirror cmBase;
+  private CodeMirror cmEdit;
   private HttpResponse<NativeString> content;
+  private HttpResponse<NativeString> baseContent;
   private EditFileInfo editFileInfo;
   private JsArray<DiffWebLinkInfo> diffLinks;
 
@@ -102,9 +120,11 @@
   @UiField Element cursLine;
   @UiField Element cursCol;
   @UiField Element dirty;
+  @UiField CheckBox showBase;
   @UiField Button close;
   @UiField Button save;
   @UiField Element editor;
+  @UiField Style style;
 
   private HandlerRegistration resizeHandler;
   private HandlerRegistration closeHandler;
@@ -144,9 +164,21 @@
       public void onSuccess(Void result) {
         // Load theme after CM library to ensure theme can override CSS.
         ThemeLoader.loadTheme(prefs.theme(), themeCallback);
-
         group2.done();
-        group3.done();
+
+        new AddonInjector().add(Addons.I.merge_bundled().getName()).inject(
+            new AsyncCallback<Void>() {
+          @Override
+          public void onFailure(Throwable caught) {
+          }
+
+          @Override
+          public void onSuccess(Void result) {
+            if (!prefs.showBase() || revision.get() > 0) {
+              group3.done();
+            }
+          }
+        });
       }
 
       @Override
@@ -159,7 +191,7 @@
           @Override
           public void onSuccess(ChangeInfo c) {
             project.setInnerText(c.project());
-            SafeHtml.setInnerHTML(filePath, Header.formatPath(path, null, null));
+            SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
           }
 
           @Override
@@ -179,13 +211,30 @@
             public void onFailure(Throwable e) {
             }
           }));
+
+      if (prefs.showBase()) {
+        ChangeEditApi.get(revision, path, true /* base */,
+            group1.addFinal(new HttpCallback<NativeString>() {
+              @Override
+              public void onSuccess(HttpResponse<NativeString> fc) {
+                baseContent = fc;
+                group3.done();
+              }
+
+              @Override
+              public void onFailure(Throwable e) {
+              }
+            }));
+      } else {
+        group1.done();
+      }
     } else {
       // TODO(davido): We probably want to create dedicated GET EditScreenMeta
       // REST endpoint. Abuse GET diff for now, as it retrieves links we need.
       DiffApi.diff(revision, path)
         .base(base)
         .webLinksOnly()
-        .get(group1.add(new AsyncCallback<DiffInfo>() {
+        .get(group1.addFinal(new AsyncCallback<DiffInfo>() {
           @Override
           public void onSuccess(DiffInfo diffInfo) {
             diffLinks = diffInfo.webLinks();
@@ -204,6 +253,10 @@
           @Override
           public void onSuccess(HttpResponse<NativeString> fc) {
             content = fc;
+            if (revision.get() > 0) {
+              baseContent = fc;
+            }
+
             if (prefs.syntaxHighlighting()) {
               injectMode(fc.getContentType(), modeCallback);
             } else {
@@ -226,14 +279,16 @@
     group3.addListener(new ScreenLoadCallback<Void>(this) {
       @Override
       protected void preDisplay(Void result) {
-        initEditor(content);
+        initEditor();
 
         renderLinks(editFileInfo, diffLinks);
         editFileInfo = null;
         diffLinks = null;
+
+        showBase.setValue(prefs.showBase(), true);
+        cmBase.refresh();
       }
     });
-    group1.done();
   }
 
   @Override
@@ -250,27 +305,15 @@
       localKeyMap.on("Ctrl-S", save());
     }
 
-    cm.addKeyMap(localKeyMap);
+    cmBase.addKeyMap(localKeyMap);
+    cmEdit.addKeyMap(localKeyMap);
   }
 
   private Runnable gotoLine() {
     return new Runnable() {
       @Override
       public void run() {
-        String n = Window.prompt(EditConstants.I.gotoLineNumber(), "");
-        if (n != null) {
-          try {
-            int line = Integer.parseInt(n);
-            line--;
-            if (line >= 0) {
-              cm.scrollToLine(line);
-            }
-          } catch (NumberFormatException e) {
-            // ignore non valid numbers
-            // We don't want to popup another ugly dialog just to say
-            // "The number you've provided is invalid, try again"
-          }
-        }
+        cmEdit.execCommand("jumpToLine");
       }
     };
   }
@@ -286,36 +329,36 @@
     resizeHandler = Window.addResizeHandler(new ResizeHandler() {
       @Override
       public void onResize(ResizeEvent event) {
-        cm.adjustHeight(header.getOffsetHeight());
+        adjustHeight();
       }
     });
     closeHandler = Window.addWindowClosingHandler(new ClosingHandler() {
       @Override
       public void onWindowClosing(ClosingEvent event) {
-        if (!cm.isClean(generation)) {
+        if (!cmEdit.isClean(generation)) {
           event.setMessage(EditConstants.I.closeUnsavedChanges());
         }
       }
     });
 
-    generation = cm.changeGeneration(true);
+    generation = cmEdit.changeGeneration(true);
     setClean(true);
-    cm.on(new ChangesHandler() {
+    cmEdit.on(new ChangesHandler() {
       @Override
       public void handle(CodeMirror cm) {
         setClean(cm.isClean(generation));
       }
     });
 
-    cm.adjustHeight(header.getOffsetHeight());
-    cm.on("cursorActivity", updateCursorPosition());
+    adjustHeight();
+    cmEdit.on("cursorActivity", updateCursorPosition());
     setShowTabs(prefs.showTabs());
     setLineLength(prefs.lineLength());
-    cm.refresh();
-    cm.focus();
+    cmEdit.refresh();
+    cmEdit.focus();
 
     if (startLine > 0) {
-      cm.scrollToLine(startLine);
+      cmEdit.scrollToLine(startLine);
     }
     updateActiveLine();
     editPrefsAction = new EditPreferencesAction(this, prefs);
@@ -324,8 +367,11 @@
   @Override
   protected void onUnload() {
     super.onUnload();
-    if (cm != null) {
-      cm.getWrapperElement().removeFromParent();
+    if (cmBase != null) {
+      cmBase.getWrapperElement().removeFromParent();
+    }
+    if (cmEdit != null) {
+      cmEdit.getWrapperElement().removeFromParent();
     }
     if (resizeHandler != null) {
       resizeHandler.removeHandler();
@@ -339,7 +385,7 @@
   }
 
   CodeMirror getEditor() {
-    return cm;
+    return cmEdit;
   }
 
   @UiHandler("editSettings")
@@ -354,46 +400,138 @@
 
   @UiHandler("close")
   void onClose(@SuppressWarnings("unused") ClickEvent e) {
-    if (cm.isClean(generation)
+    if (cmEdit.isClean(generation)
         || Window.confirm(EditConstants.I.cancelUnsavedChanges())) {
       upToChange();
     }
   }
 
+  private void displayBase() {
+    cmBase.getWrapperElement().getParentElement()
+        .removeClassName(style.hideBase());
+    cmEdit.getWrapperElement().getParentElement()
+        .removeClassName(style.fullWidth());
+    mv.getGapElement().removeClassName(style.hideBase());
+    setCmBaseValue();
+    setLineLength(prefs.lineLength());
+    cmBase.refresh();
+  }
+
+  @UiHandler("showBase")
+  void onShowBase(ValueChangeEvent<Boolean> e) {
+    boolean shouldShow = e.getValue();
+    if (shouldShow) {
+      if (baseContent == null) {
+        ChangeEditApi.get(revision, path, true /* base */,
+            new HttpCallback<NativeString>() {
+              @Override
+              public void onSuccess(HttpResponse<NativeString> fc) {
+                baseContent = fc;
+                displayBase();
+              }
+
+              @Override
+              public void onFailure(Throwable e) {
+              }
+            });
+      } else {
+        displayBase();
+      }
+    } else {
+      cmBase.getWrapperElement().getParentElement()
+          .addClassName(style.hideBase());
+      cmEdit.getWrapperElement().getParentElement()
+          .addClassName(style.fullWidth());
+      mv.getGapElement().addClassName(style.hideBase());
+    }
+    mv.setShowDifferences(shouldShow);
+  }
+
+  void setOption(String option, String value) {
+    cmBase.setOption(option, value);
+    cmEdit.setOption(option, value);
+  }
+
+  void setOption(String option, boolean value) {
+    cmBase.setOption(option, value);
+    cmEdit.setOption(option, value);
+  }
+
+  void setOption(String option, double value) {
+    cmBase.setOption(option, value);
+    cmEdit.setOption(option, value);
+  }
+
+  void setTheme(final Theme newTheme) {
+    cmBase.operation(new Runnable() {
+      @Override
+      public void run() {
+        cmBase.setOption("theme", newTheme.name().toLowerCase());
+      }
+    });
+    cmEdit.operation(new Runnable() {
+      @Override
+      public void run() {
+        cmEdit.setOption("theme", newTheme.name().toLowerCase());
+      }
+    });
+  }
+
   void setLineLength(int length) {
-    cm.extras().lineLength(
-        Patch.COMMIT_MSG.equals(path) ? 72 : length);
+    int adjustedLength = Patch.COMMIT_MSG.equals(path) ? 72 : length;
+    cmBase.extras().lineLength(adjustedLength);
+    cmEdit.extras().lineLength(adjustedLength);
+  }
+
+  void setIndentUnit(int indent) {
+    cmEdit.setOption("indentUnit", Patch.COMMIT_MSG.equals(path) ? 2 : indent);
   }
 
   void setShowLineNumbers(boolean show) {
-    cm.setOption("lineNumbers", show);
+    cmBase.setOption("lineNumbers", show);
+    cmEdit.setOption("lineNumbers", show);
   }
 
   void setShowWhitespaceErrors(final boolean show) {
-    cm.operation(new Runnable() {
+    cmBase.operation(new Runnable() {
       @Override
       public void run() {
-        cm.setOption("showTrailingSpace", show);
+        cmBase.setOption("showTrailingSpace", show);
+      }
+    });
+    cmEdit.operation(new Runnable() {
+      @Override
+      public void run() {
+        cmEdit.setOption("showTrailingSpace", show);
       }
     });
   }
 
   void setShowTabs(boolean show) {
-    cm.extras().showTabs(show);
+    cmBase.extras().showTabs(show);
+    cmEdit.extras().showTabs(show);
   }
 
-  void resizeCodeMirror() {
-    cm.adjustHeight(header.getOffsetHeight());
+  void adjustHeight() {
+    int height = header.getOffsetHeight();
+    int rest = Gerrit.getHeaderFooterHeight()
+        + height
+        + 5; // Estimate
+    mv.getGapElement().getStyle().setHeight(
+        Window.getClientHeight() - rest, Unit.PX);
+    cmBase.adjustHeight(height);
+    cmEdit.adjustHeight(height);
   }
 
   void setSyntaxHighlighting(boolean b) {
     ModeInfo modeInfo = ModeInfo.findMode(content.getContentType(), path);
-    final String mode = modeInfo != null ? modeInfo.mode() : null;
+    final String mode = modeInfo != null ? modeInfo.mime() : null;
     if (b && mode != null && !mode.isEmpty()) {
       injectMode(mode, new AsyncCallback<Void>() {
         @Override
         public void onSuccess(Void result) {
-          cm.setOption("mode", mode);
+          cmBase.setOption("mode", mode);
+          cmEdit.setOption("mode", mode);
         }
 
         @Override
@@ -402,7 +540,8 @@
         }
       });
     } else {
-      cm.setOption("mode", (String) null);
+      cmBase.setOption("mode", (String) null);
+      cmEdit.setOption("mode", (String) null);
     }
   }
 
@@ -410,31 +549,51 @@
     Gerrit.display(PageLinks.toChangeInEditMode(revision.getParentKey()));
   }
 
-  private void initEditor(HttpResponse<NativeString> file) {
+  private void initEditor() {
     ModeInfo mode = null;
-    String content = "";
-    if (file != null && file.getResult() != null) {
-      content = file.getResult().asString();
+    String editContent = "";
+    if (content != null && content.getResult() != null) {
+      editContent = content.getResult().asString();
       if (prefs.syntaxHighlighting()) {
-        mode = ModeInfo.findMode(file.getContentType(), path);
+        mode = ModeInfo.findMode(content.getContentType(), path);
       }
     }
-    cm = CodeMirror.create(editor, Configuration.create()
-        .set("value", content)
-        .set("readOnly", false)
+    Configuration cfg = Configuration.create()
+        .set("autoCloseBrackets", prefs.autoCloseBrackets())
         .set("cursorBlinkRate", prefs.cursorBlinkRate())
         .set("cursorHeight", 0.85)
-        .set("lineNumbers", prefs.hideLineNumbers())
-        .set("tabSize", prefs.tabSize())
-        .set("lineWrapping", false)
-        .set("matchBrackets", prefs.matchBrackets())
-        .set("autoCloseBrackets", prefs.autoCloseBrackets())
-        .set("scrollbarStyle", "overlay")
-        .set("styleSelectedText", true)
-        .set("showTrailingSpace", prefs.showWhitespaceErrors())
+        .set("indentUnit", prefs.indentUnit())
         .set("keyMap", prefs.keyMapType().name().toLowerCase())
+        .set("lineNumbers", prefs.hideLineNumbers())
+        .set("lineWrapping", prefs.lineWrapping())
+        .set("matchBrackets", prefs.matchBrackets())
+        .set("mode", mode != null ? mode.mime() : null)
+        .set("origLeft", editContent)
+        .set("scrollbarStyle", "overlay")
+        .set("showTrailingSpace", prefs.showWhitespaceErrors())
+        .set("styleSelectedText", true)
+        .set("tabSize", prefs.tabSize())
         .set("theme", prefs.theme().name().toLowerCase())
-        .set("mode", mode != null ? mode.mode() : null));
+        .set("value", "");
+
+    if (editContent.contains("\r\n")) {
+      cfg.set("lineSeparator", "\r\n");
+    }
+
+    mv = MergeView.create(editor, cfg);
+
+    cmBase = mv.leftOriginal();
+    cmBase.getWrapperElement().addClassName(style.base());
+    cmEdit = mv.editor();
+    setCmBaseValue();
+    cmEdit.setValue(editContent);
+
+    CodeMirror.addCommand("save", new CommandRunner() {
+      @Override
+      public void run(CodeMirror instance) {
+        save().run();
+      }
+    });
   }
 
   private void renderLinks(EditFileInfo editInfo,
@@ -486,7 +645,7 @@
         Scheduler.get().scheduleDeferred(new ScheduledCommand() {
           @Override
           public void execute() {
-            cm.operation(new Runnable() {
+            cmEdit.operation(new Runnable() {
               @Override
               public void run() {
                 updateActiveLine();
@@ -499,10 +658,10 @@
   }
 
   private void updateActiveLine() {
-    Pos p = cm.getCursor("end");
+    Pos p = cmEdit.getCursor("end");
     cursLine.setInnerText(Integer.toString(p.line() + 1));
     cursCol.setInnerText(Integer.toString(p.ch() + 1));
-    cm.extras().activeLine(cm.getLineHandleVisualStart(p.line()));
+    cmEdit.extras().activeLine(cmEdit.getLineHandleVisualStart(p.line()));
   }
 
   private void setClean(boolean clean) {
@@ -515,23 +674,23 @@
     return new Runnable() {
       @Override
       public void run() {
-        if (!cm.isClean(generation)) {
+        if (!cmEdit.isClean(generation)) {
           close.setEnabled(false);
-          String text = cm.getValue();
+          String text = cmEdit.getValue();
           if (Patch.COMMIT_MSG.equals(path)) {
             String trimmed = text.trim() + "\r";
             if (!trimmed.equals(text)) {
               text = trimmed;
-              cm.setValue(text);
+              cmEdit.setValue(text);
             }
           }
-          final int g = cm.changeGeneration(false);
+          final int g = cmEdit.changeGeneration(false);
           ChangeEditApi.put(revision.getParentKey().get(), path, text,
               new GerritCallback<VoidResult>() {
                 @Override
                 public void onSuccess(VoidResult result) {
                   generation = g;
-                  setClean(cm.isClean(g));
+                  setClean(cmEdit.isClean(g));
                 }
                 @Override
                 public void onFailure(final Throwable caught) {
@@ -546,4 +705,10 @@
   private void injectMode(String type, AsyncCallback<Void> cb) {
     new ModeInjector().add(type).inject(cb);
   }
+
+  private void setCmBaseValue() {
+    cmBase.setValue(baseContent != null && baseContent.getResult() != null
+        ? baseContent.getResult().asString()
+        : "");
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml
index 88af398..34282c8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.ui.xml
@@ -17,8 +17,11 @@
 <ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
     xmlns:g='urn:import:com.google.gwt.user.client.ui'>
   <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
-  <ui:style gss='false'>
+  <ui:style gss='false' type='com.google.gerrit.client.editor.EditScreen.Style'>
     @external .CodeMirror, .CodeMirror-cursor;
+    @external .CodeMirror-merge-2pane, .CodeMirror-merge-pane;
+    @external .CodeMirror-merge-gap;
+    @external .CodeMirror-scroll, .CodeMirror-overlayscroll-vertical;
 
     .header {
       position: relative;
@@ -123,12 +126,28 @@
       cursor: pointer;
       outline: none;
     }
+
+    .hideBase.CodeMirror-merge-pane {
+      display: none;
+    }
+
+    .hideBase.CodeMirror-merge-gap {
+      display: none;
+    }
+
+    .CodeMirror-merge-2pane .fullWidth.CodeMirror-merge-pane {
+      width: 100%;
+    }
+
+    /* Hide the vertical scrollbar on the base side. The edit side controls
+       both views */
+    .base .CodeMirror-scroll { margin-right: -42px; }
+    .base .CodeMirror-overlayscroll-vertical { display: none !important; }
   </ui:style>
   <g:HTMLPanel styleName='{style.header}'>
     <div class='{style.headerLine}' ui:field='header'>
        <div class='{style.headerButtons}'>
          <g:Button ui:field='close'
-             styleName=''
              title='Close file and return to change'>
            <ui:attribute name='title'/>
            <div><ui:msg>Close</ui:msg></div>
@@ -142,6 +161,11 @@
        </div>
        <span class='{style.path}'><span ui:field='project'/> / <span ui:field='filePath'/></span>
        <div class='{style.navigation}'>
+         <g:Label text='Show Base' styleName='{style.linkPanel}'></g:Label>
+         <g:CheckBox ui:field='showBase' checked='true' styleName='{style.linkPanel}'
+             title='Show Base Version'>
+           <ui:attribute name='title'/>
+         </g:CheckBox>
          <g:FlowPanel ui:field='linkPanel' styleName='{style.linkPanel}'/>
          <g:Image
              ui:field='editSettings'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index a511550..4190672 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -148,57 +148,6 @@
   background: selectionColor;
 }
 
-/** CommentPanel **/
-.commentPanelHeader {
-  cursor: pointer;
-  width: 100%;
-}
-.commentPanelSummary {
-  color: #777777;
-  white-space: nowrap;
-  overflow: hidden;
-}
-.commentPanelAuthorCell {
-  font-weight: bold;
-  white-space: nowrap;
-}
-.commentPanelSummaryCell {
-  width: 100%;
-}
-.commentPanelDateCell {
-  white-space: nowrap;
-}
-.commentPanelContent {
-  padding-bottom: 1px;
-}
-.commentPanelMessage {
-  font-size: small;
-  padding-left: 0.5em;
-  padding-right: 0.5em;
-}
-.commentPanelMessage p {
-  margin-top: 0px;
-  margin-bottom: 0px;
-  padding-top: 0.5em;
-  padding-bottom: 0.5em;
-  max-height: 100000px;
-}
-.commentPanelButtons {
-  margin-left: 0.5em;
-}
-.commentPanelButtons .gwt-Button {
-  margin-right: 2em;
-  font-size: 7pt;
-  padding: 1px;
-}
-
-.commentEditorPanel textarea {
-  margin-left: 0.5em;
-  font-size: small;
-  font-family: norm-font;
-}
-
-
 /** Menu **/
 .linkMenuBar {
   font-size: 9pt;
@@ -307,7 +256,15 @@
 }
 .searchPanel .searchTextBox {
   font-size: 9pt;
-  margin: 5.286px 3px 0 0;
+  margin: 8.286px 3px 0 0;
+}
+.searchPanel .searchDropdown {
+  font-size: 8pt;
+  border: 2px solid;
+  border-color: rgba(0, 0, 0, 0.15);
+  height: 16px;
+  border-radius: 2px;
+  box-sizing: content-box;
 }
 .searchPanel .searchButton {
   text-align: center;
@@ -434,57 +391,10 @@
   border-bottom: 1px solid trimColor;
 }
 
-.changeTable .changeTypeCell {
-  width: 1px;
-  padding-left: 5px;
-  padding-right: 5px;
-  border-right: 1px solid trimColor;
-  border-bottom: 1px solid trimColor;
-  vertical-align: top;
-}
-
-.changeTable .commentCell {
-  text-align: right;
-  font-weight: bold;
-  white-space: nowrap;
-}
-.changeTable .commentCell span.drafts {
-  color: #ff5555;
-}
-
-.changeTable .patchCellReverseDiff {
-  color: red;
-}
-
-.changeTable .patchSizeCell {
-  text-align: right;
-  white-space: nowrap;
-}
-.changeTable td.noborder {
-  border: none;
-}
-
-.changeTable .filePathCell {
-  white-space: nowrap;
-}
-
-.changeTable .sourceFilePath {
-  font-style: italic;
-  font-size: 9pt;
-}
-
-.changeTable .diffLinkCell {
-  white-space: nowrap;
-}
-
 .changeTable .leftMostCell {
   border-left: 1px solid trimColor;
 }
 
-.changeTable .topMostCell {
-  border-top: 1px solid trimColor;
-}
-
 .changeTable .dataCell {
   padding-left: 5px;
   padding-right: 5px;
@@ -619,205 +529,7 @@
   color: #2a5db0;
 }
 
-
-/** PatchScreen **/
-.reviewedPanelBottom {
-  float: right;
-  font-size: small;
-}
-
-.linkPanel img {
-  padding-right: 3px;
-}
-
-.nowrap {
-  white-space: nowrap;
-}
-
-
-/** PatchContentTable **/
-.patchContentTable {
-  width: 100%;
-  border-collapse: separate;
-  border-spacing: 0;
-  background: white;
-  color: black;
-}
-.patchContentTable td {
-  padding-top: 0;
-  padding-bottom: 0;
-  font-size: 9pt;
-  font-family: mono-font;
-}
-
-.patchContentTable .iconCell {
-  width: 1px;
-  padding: 0px;
-  vertical-align: middle;
-}
-
-.patchContentTable .diffText {
-  white-space: pre;
-  padding-left: 0.2em;
-  border-left: thin solid #b0bdcc;
-}
-
-.patchContentTable .diffTextFileHeader {
-  color: grey;
-  font-weight: bold;
-}
-.patchContentTable .diffTextNoLF {
-  color: grey;
-  font-weight: bold;
-}
-.patchContentTable .diffTextHunkHeader {
-  color: blue;
-}
-.patchContentTable .diffTextDELETE {
-  color: #a00000;
-}
-.patchContentTable .diffTextCONTEXT {
-  color: grey;
-}
-.patchContentTable .diffTextINSERT {
-  color: #006000;
-}
-
-.patchContentTable tr.commentHolder {
-  background: #E5ECF9;
-}
-.patchContentTable tr.commentHolder .iconCell {
-  background: white;
-}
-.patchContentTable tr.commentHolder .iconCellOfFileCommentRow {
-  background: trimColor;
-}
-.patchContentTable td.commentHolder {
-  padding-left: 0;
-  padding-right: 0;
-  border-top: 1px solid black;
-  border-right: 1px solid black;
-}
-.patchContentTable td.commentHolderLeftmost {
-  border-left: 1px solid black;
-}
-.patchContentTable td.commentHolder.commentPanelLast {
-  border-bottom: 1px solid black;
-}
-.patchContentTable .commentPanel {
-  font-family: norm-font;
-}
-.patchContentTable .commentPanel td {
-  font-family: norm-font;
-}
-.patchContentTable .commentPanelMessage {
-  padding-left: 1px;
-  padding-right: 1px;
-  white-space: normal;
-}
-.patchContentTable .commentPanelButtons,
-.patchContentTable .commentPanel textarea {
-  margin-left: 1px;
-}
-
-.lineNumber {
-  padding-left: 0.2em;
-  white-space: pre;
-  width: 1.5em;
-  text-align: center;
-  padding-right: 0.2em;
-  background: white;
-  border-bottom: 1px solid white;
-}
-.lineNumber.rightBorder {
-  border-right: thin solid #b0bdcc;
-}
-.lineNumber a {
-  color: #888;
-  text-decoration: none;
-}
-.patchContentTable td.fileColumnHeader {
-  background: trimColor;
-  font-family: norm-font;
-  font-weight: bold;
-  text-align: center;
-}
-.patchContentTable td.fileColumnHeader.unifiedTableHeader {
-  text-align: left;
-}
-.lineNumber.fileColumnHeader {
-  border-bottom: 1px solid trimColor;
-}
-
-.fileLine {
-  padding-left: 0;
-  padding-right: 0;
-  white-space: pre;
-  border-left: thin solid #b0bdcc;
-}
-.fileLineDELETE,
-.fileLineDELETE .wdc {
-  background: #ffeeee;
-  border-bottom: 1px solid #ffeeee;
-}
-.fileLineINSERT,
-.fileLineINSERT .wdc {
-  background: #ddffdd;
-  border-bottom: 1px solid #ddffdd;
-}
-.patchContentTable .wdd {
-  border-bottom: 1px solid #FAA;
-}
-.patchContentTable .wdi {
-  border-bottom: 1px solid #9F9;
-}
-
-.patchContentTable td.cellsNextToFileComment {
-  background: trimColor;
-  border-top: trimColor;
-  border-bottom: trimColor;
-}
-.patchContentTable .activeRow .iconCell,
-.patchContentTable .activeRow .lineNumber {
-  background: selectionColor;
-}
-.patchContentTable .activeRow .iconCell,
-.patchContentTable .activeRow .lineNumber,
-.patchContentTable .activeRow .fileLine,
-.patchContentTable .activeRow .diffText,
-.patchContentTable .activeRow td.commentHolder,
-.patchContentTable .activeRow .wdc,
-.patchContentTable .activeRow .wdd,
-.patchContentTable .activeRow .wdi,
-.patchContentTable .activeRow .iconCellOfFileCommentRow,
-.patchContentTable .activeRow td.commentHolder.commentPanelLast  {
-  border-bottom: 1px solid blue;
-}
-
-.patchContentTable .fileCommentBorder .iconCellOfFileCommentRow,
-.patchContentTable .fileCommentBorder .lineNumber,
-.patchContentTable .fileCommentBorder .diffText {
-  height: 20px;
-  background: trimColor;
-  border-bottom: 1px solid trimColor;
-}
-
 /** Change **/
-.changeScreenStarIcon {
-  margin-right: 5px;
-}
-
-.changeScreenDescription,
-.changeScreenDescription textarea {
-  white-space: pre;
-  font-family: mono-font;
-  font-size: 9pt;
-}
-.changeScreenDescription p {
-  margin-top: 0px;
-  padding-top: 0.5em;
-}
-
 .avatarInfoPanel {
   margin-right: 10px;
 }
@@ -853,10 +565,6 @@
   text-align: right;
 }
 
-.infoBlock td.noborder {
-  border-right: none;
-}
-
 .infoBlock td.bottomheader {
   border-bottom: 1px solid trimColor;
 }
@@ -960,45 +668,6 @@
   text-overflow: ellipsis;
 }
 
-/** UnifiedScreen **/
-.unifiedTable {
-  width: 100%;
-  border: 1px solid #B0BDCC;
-  display: table;
-}
-
-.sideBySideScreenLinkTable {
-  width: 100%;
-}
-.sideBySideScreenLinkTable td {
-  width: 33%;
-}
-
-.patchNoDifference {
-  margin-top: 1em;
-  margin-bottom: 2em;
-  margin-left: 1em;
-  margin-right: 5em;
-  font-weight: bold;
-  font-size: medium;
-  font-family: norm-font;
-}
-
-/** Patch History Table **/
-.patchHistoryTable {
-  width: auto;
-  margin-bottom: 10px;
-}
-
-.patchHistoryTable .dataCell {
-  white-space: nowrap;
-}
-
-.patchHistoryTablePatchSetHeader {
-  text-align: right;
-}
-
-
 /** AccountSettings  **/
 .usernameField {
   white-space: nowrap;
@@ -1159,6 +828,70 @@
   margin-bottom: 10px;
 }
 
+.oauthInfoBlock {
+  margin-bottom: 10px;
+}
+.oauthToken {
+  font-family: monospace;
+  font-size: small;
+  width: 40em;
+}
+.oauthToken span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 38em;
+}
+.oauthExpires {
+  font-family: monospace;
+  font-size: small;
+  width: 40em;
+}
+.oauthPanel {
+  margin-top: 10px;
+  border: 1px solid trimColor;
+  padding: 5px 5px 5px 5px;
+}
+.oauthPanelNetRCHeading {
+  margin-top: 5px;
+  margin-left: 1em;
+  white-space: nowrap;
+}
+.oauthPanelNetRCEntry {
+  margin-top: 5px;
+  margin-left: 2em;
+  font-family: monospace;
+  font-size: small;
+  width: 80em;
+}
+.oauthPanelNetRCEntry span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 78em;
+}
+.oauthPanelCookieHeading {
+  margin-top: 15px;
+  margin-left: 1em;
+  white-space: nowrap;
+}
+.oauthPanelCookieEntry {
+  margin-top: 5px;
+  margin-left: 2em;
+  font-family: monospace;
+  font-size: small;
+  width: 80em;
+}
+.oauthPanelCookieEntry span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 78em;
+}
+
 
 /** CommentedActionDialog **/
 .commentedActionDialog .gwt-DisclosurePanel .header td {
@@ -1193,17 +926,6 @@
   width: 100%;
 }
 
-/** PatchBrowserPopup **/
-.patchBrowserPopup {
-  opacity: 0.90;
-}
-.patchBrowserPopupBody {
-  background: backgroundColor;
-  margin: 4px;
-  opacity: 0.90;
-}
-
-
 /** AccountGroupInfoScreen **/
 .groupUUIDPanel {
   margin-bottom: 10px;
@@ -1298,6 +1020,12 @@
   min-width: 300px;
 }
 
+.queryIcon {
+  position: relative;
+  top: 2px;
+  margin-right: 3px;
+}
+
 /** ProjectSettings */
 .maxObjectSizeLimitEffectiveLabel {
   padding-top: 5px;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
index f62ae22..ed41b65 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
@@ -37,8 +37,8 @@
   public final native AccountInfo memberAsUser() /*-{ return this.member; }-*/;
   public final native GroupInfo memberAsGroup() /*-{ return this.member; }-*/;
 
-  private final native String dateRaw() /*-{ return this.date; }-*/;
-  private final native String typeRaw() /*-{ return this.type; }-*/;
+  private native String dateRaw() /*-{ return this.date; }-*/;
+  private native String typeRaw() /*-{ return this.type; }-*/;
 
   protected GroupAuditEventInfo() {
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
index 6142e5b..c3fd4ed 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
@@ -33,9 +33,9 @@
   public final native JsArray<AccountInfo> members() /*-{ return this.members; }-*/;
   public final native JsArray<GroupInfo> includes() /*-{ return this.includes; }-*/;
 
-  private final native int group_id() /*-{ return this.group_id; }-*/;
-  private final native String owner_id() /*-{ return this.owner_id; }-*/;
-  private final native void owner_id(String o) /*-{ if(o)this.owner_id=o; }-*/;
+  private native int group_id() /*-{ return this.group_id; }-*/;
+  private native String owner_id() /*-{ return this.owner_id; }-*/;
+  private native void owner_id(String o) /*-{ if(o)this.owner_id=o; }-*/;
 
   public final AccountGroup.UUID getOwnerUUID() {
     String owner = owner_id();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
index f28fb86..5532285 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
@@ -38,6 +38,21 @@
     call.get(NativeMap.copyKeysIntoChildren(cb));
   }
 
+  public static void suggestAccountGroupForProject(String project, String query,
+      int limit, AsyncCallback<GroupMap> cb) {
+    RestApi call = groups();
+    if (project != null) {
+      call.addParameter("p", project);
+    }
+    if (query != null) {
+      call.addParameter("s", query);
+    }
+    if (limit > 0) {
+      call.addParameter("n", limit);
+    }
+    call.get(NativeMap.copyKeysIntoChildren(cb));
+  }
+
   public static void myOwned(AsyncCallback<GroupMap> cb) {
     myOwnedGroups().get(NativeMap.copyKeysIntoChildren(cb));
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/MemberList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/MemberList.java
deleted file mode 100644
index d63c212..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/MemberList.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.groups;
-
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class MemberList extends JsArray<AccountInfo> {
-  public static void all(AccountGroup.UUID group,
-      AsyncCallback<MemberList> callback) {
-    new RestApi("/groups/").id(group.get()).view("members").get(callback);
-  }
-
-  protected MemberList() {
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java
deleted file mode 100644
index 5b9203a..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java
+++ /dev/null
@@ -1,970 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.CommentPanel;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.NeedsSignInKeyCommand;
-import com.google.gerrit.common.data.AccountInfoCache;
-import com.google.gerrit.common.data.CommentDetail;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.prettify.client.ClientSideFormatter;
-import com.google.gerrit.prettify.client.PrettyFormatter;
-import com.google.gerrit.prettify.client.SparseHtmlFile;
-import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.event.dom.client.BlurEvent;
-import com.google.gwt.event.dom.client.BlurHandler;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.History;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Focusable;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-import com.google.gwtorm.client.KeyUtil;
-
-import org.eclipse.jgit.diff.Edit;
-
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-
-abstract class AbstractPatchContentTable extends NavigationTable<Object>
-    implements CommentEditorContainer, FocusHandler, BlurHandler {
-  public static final int R_HEAD = 0;
-  static final short FILE_SIDE_A = (short) 0;
-  static final short FILE_SIDE_B = (short) 1;
-  protected PatchTable fileList;
-  protected AccountInfoCache accountCache = AccountInfoCache.empty();
-  protected Patch.Key patchKey;
-  protected PatchSet.Id idSideA;
-  protected PatchSet.Id idSideB;
-  protected boolean onlyOneHunk;
-  protected PatchSetSelectBox headerSideA;
-  protected PatchSetSelectBox headerSideB;
-  protected Image iconA;
-  protected Image iconB;
-
-  private final KeyCommandSet keysComment;
-  private HandlerRegistration regComment;
-  private final KeyCommandSet keysOpenByEnter;
-  private HandlerRegistration regOpenByEnter;
-  private CommentLinkProcessor commentLinkProcessor;
-  boolean isDisplayBinary;
-
-  protected AbstractPatchContentTable() {
-    keysNavigation.add(new PrevKeyCommand(0, 'k', PatchUtil.C.linePrev()));
-    keysNavigation.add(new NextKeyCommand(0, 'j', PatchUtil.C.lineNext()));
-    keysNavigation.add(new PrevChunkKeyCmd(0, 'p', PatchUtil.C.chunkPrev()));
-    keysNavigation.add(new NextChunkKeyCmd(0, 'n', PatchUtil.C.chunkNext()));
-    keysNavigation.add(new PrevCommentCmd(0, 'P', PatchUtil.C.commentPrev()));
-    keysNavigation.add(new NextCommentCmd(0, 'N', PatchUtil.C.commentNext()));
-
-    keysAction.add(new OpenKeyCommand(0, 'o', PatchUtil.C.expandComment()));
-    keysOpenByEnter = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    keysOpenByEnter.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER, PatchUtil.C.expandComment()));
-
-    if (Gerrit.isSignedIn()) {
-      keysAction.add(new InsertCommentCommand(0, 'c', PatchUtil.C
-          .commentInsert()));
-
-      // See CommentEditorPanel
-      //
-      keysComment = new KeyCommandSet(PatchUtil.C.commentEditorSet());
-      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's', PatchUtil.C
-          .commentSaveDraft()));
-      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE, PatchUtil.C
-          .commentCancelEdit()));
-    } else {
-      keysComment = null;
-    }
-
-    table.setStyleName(Gerrit.RESOURCES.css().patchContentTable());
-  }
-
-  abstract void createFileCommentEditorOnSideA();
-
-  abstract void createFileCommentEditorOnSideB();
-
-  protected void initHeaders(PatchScript script, PatchSetDetail detail) {
-    headerSideA = new PatchSetSelectBox(PatchSetSelectBox.Side.A);
-    headerSideA.display(detail, script, patchKey, idSideA, idSideB);
-    headerSideA.addDoubleClickHandler(new DoubleClickHandler() {
-      @Override
-      public void onDoubleClick(DoubleClickEvent event) {
-        if (headerSideA.isFileOrCommitMessage()) {
-          createFileCommentEditorOnSideA();
-        }
-      }
-    });
-    headerSideB = new PatchSetSelectBox(PatchSetSelectBox.Side.B);
-    headerSideB.display(detail, script, patchKey, idSideA, idSideB);
-    headerSideB.addDoubleClickHandler(new DoubleClickHandler() {
-      @Override
-      public void onDoubleClick(DoubleClickEvent event) {
-        if (headerSideB.isFileOrCommitMessage()) {
-          createFileCommentEditorOnSideB();
-        }
-      }
-    });
-
-    // Prepare icons.
-    iconA = new Image(Gerrit.RESOURCES.addFileComment());
-    iconA.setTitle(PatchUtil.C.addFileCommentToolTip());
-    iconA.addStyleName(Gerrit.RESOURCES.css().link());
-    iconA.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        createFileCommentEditorOnSideA();
-      }
-    });
-    iconB = new Image(Gerrit.RESOURCES.addFileComment());
-    iconB.setTitle(PatchUtil.C.addFileCommentToolTip());
-    iconB.addStyleName(Gerrit.RESOURCES.css().link());
-    iconB.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        createFileCommentEditorOnSideB();
-      }
-    });
-  }
-
-  @Override
-  public void notifyDraftDelta(final int delta) {
-    if (fileList != null) {
-      fileList.notifyDraftDelta(patchKey, delta);
-    }
-
-    Widget p = getParent();
-    while (p != null) {
-      if (p instanceof CommentEditorContainer) {
-        ((CommentEditorContainer) p).notifyDraftDelta(delta);
-        break;
-      }
-      p = p.getParent();
-    }
-  }
-
-  @Override
-  public void remove(CommentEditorPanel panel) {
-    final int nRows = table.getRowCount();
-    for (int row = 0; row < nRows; row++) {
-      final int nCells = table.getCellCount(row);
-      for (int cell = 0; cell < nCells; cell++) {
-        if (table.getWidget(row, cell) == panel) {
-          destroyEditor(row, cell);
-          Widget p = table;
-          while (p != null) {
-            if (p instanceof Focusable) {
-              ((Focusable) p).setFocus(true);
-              break;
-            }
-            p = p.getParent();
-          }
-
-          if (table.getCellFormatter().getStyleName(row - 1, cell)
-              .contains(Gerrit.RESOURCES.css().commentHolder())) {
-            table.getCellFormatter().addStyleName(row - 1, cell,
-                Gerrit.RESOURCES.css().commentPanelLast());
-          }
-          return;
-        }
-      }
-    }
-  }
-
-  @Override
-  public void setRegisterKeys(final boolean on) {
-    super.setRegisterKeys(on);
-    if (on && keysComment != null && regComment == null) {
-      regComment = GlobalKey.add(this, keysComment);
-    } else if (!on && regComment != null) {
-      regComment.removeHandler();
-      regComment = null;
-    }
-
-    if (on && keysOpenByEnter != null && regOpenByEnter == null) {
-      regOpenByEnter = GlobalKey.add(this, keysOpenByEnter);
-    } else if (!on && regOpenByEnter != null) {
-      regOpenByEnter.removeHandler();
-      regOpenByEnter = null;
-    }
-  }
-
-  public void display(final Patch.Key k, final PatchSet.Id a,
-      final PatchSet.Id b, final PatchScript s, final PatchSetDetail d) {
-    patchKey = k;
-    idSideA = a;
-    idSideB = b;
-
-    render(s, d);
-  }
-
-  void setCommentLinkProcessor(CommentLinkProcessor commentLinkProcessor) {
-    this.commentLinkProcessor = commentLinkProcessor;
-  }
-
-  protected boolean hasDifferences(PatchScript script) {
-    return hasEdits(script) || hasMeta(script) || hasComments(script);
-  }
-
-  public boolean isPureMetaChange(PatchScript script) {
-    return !hasEdits(script) && hasMeta(script);
-  }
-
-  // True if there are differences between the two patch sets
-  private boolean hasEdits(PatchScript script) {
-    for (Edit e : script.getEdits()) {
-      if (e.getType() != Edit.Type.EMPTY) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  // True if one of the two patch sets has comments
-  private boolean hasComments(PatchScript script) {
-    return !script.getCommentDetail().getCommentsA().isEmpty()
-        || !script.getCommentDetail().getCommentsB().isEmpty();
-  }
-
-  // True if this change is a mode change or a pure rename/copy
-  private boolean hasMeta(PatchScript script) {
-    return !script.getPatchHeader().isEmpty();
-  }
-
-  protected void appendNoDifferences(SafeHtmlBuilder m) {
-    m.openTr();
-    m.openTd();
-    m.setAttribute("colspan", 5);
-    m.openDiv();
-    m.addStyleName(Gerrit.RESOURCES.css().patchNoDifference());
-    m.append(PatchUtil.C.noDifference());
-    m.closeDiv();
-    m.closeTd();
-    m.closeTr();
-  }
-
-  protected SparseHtmlFile getSparseHtmlFileA(PatchScript s) {
-    DiffPreferencesInfo dp = s.getDiffPrefs();
-    dp.showWhitespaceErrors = false;
-
-    PrettyFormatter f = ClientSideFormatter.FACTORY.get();
-    f.setDiffPrefs(dp);
-    f.setFileName(s.getA().getPath());
-    f.setEditFilter(PrettyFormatter.A);
-    f.setEditList(s.getEdits());
-    f.format(s.getA());
-    return f;
-  }
-
-  protected SparseHtmlFile getSparseHtmlFileB(PatchScript s) {
-    DiffPreferencesInfo dp = s.getDiffPrefs();
-
-    SparseFileContent b = s.getB();
-    PrettyFormatter f = ClientSideFormatter.FACTORY.get();
-    f.setDiffPrefs(dp);
-    f.setFileName(b.getPath());
-    f.setEditFilter(PrettyFormatter.B);
-    f.setEditList(s.getEdits());
-
-    if (s.getA().isWholeFile() && !b.isWholeFile()) {
-      b = b.apply(s.getA(), s.getEdits());
-    }
-    f.format(b);
-    return f;
-  }
-
-  protected String getUrlA() {
-    final String rawBase = GWT.getHostPageBaseURL() + "cat/";
-    final String url;
-    if (idSideA == null) {
-      url = rawBase + KeyUtil.encode(patchKey.toString()) + "^1";
-    } else {
-      Patch.Key k = new Patch.Key(idSideA, patchKey.get());
-      url = rawBase + KeyUtil.encode(k.toString()) + "^0";
-    }
-    return url;
-  }
-
-  protected String getUrlB() {
-    final String rawBase = GWT.getHostPageBaseURL() + "cat/";
-    return rawBase + KeyUtil.encode(patchKey.toString()) + "^0";
-  }
-
-  protected abstract void render(PatchScript script, final PatchSetDetail detail);
-
-  protected abstract void onInsertComment(PatchLine pl);
-
-  public abstract void display(CommentDetail comments, boolean expandComments);
-
-  @Override
-  protected Object getRowItemKey(final Object item) {
-    return null;
-  }
-
-  protected void initScript(final PatchScript script) {
-    if (script.getEdits().size() == 1) {
-      final SparseFileContent a = script.getA();
-      final SparseFileContent b = script.getB();
-      onlyOneHunk = a.size() == 0 || b.size() == 0;
-    } else {
-      onlyOneHunk = false;
-    }
-  }
-
-  private boolean isChunk(final int row) {
-    final Object o = getRowItem(row);
-    if (!onlyOneHunk && o instanceof PatchLine) {
-      final PatchLine pl = (PatchLine) o;
-      switch (pl.getType()) {
-        case DELETE:
-        case INSERT:
-        case REPLACE:
-          return true;
-        case CONTEXT:
-          break;
-      }
-    } else if (o instanceof CommentList) {
-      return true;
-    }
-    return false;
-  }
-
-  private int findChunkStart(int row) {
-    while (0 <= row && isChunk(row)) {
-      row--;
-    }
-    return row + 1;
-  }
-
-  private int findChunkEnd(int row) {
-    final int max = table.getRowCount();
-    while (row < max && isChunk(row)) {
-      row++;
-    }
-    return row - 1;
-  }
-
-  private static int oneBefore(final int begin) {
-    return 1 <= begin ? begin - 1 : begin;
-  }
-
-  private int oneAfter(final int end) {
-    return end + 1 < table.getRowCount() ? end + 1 : end;
-  }
-
-  private void moveToPrevChunk(int row) {
-    while (0 <= row && isChunk(row)) {
-      row--;
-    }
-    for (; 0 <= row; row--) {
-      if (isChunk(row)) {
-        final int start = findChunkStart(row);
-        movePointerTo(start, false);
-        scrollIntoView(oneBefore(start), oneAfter(row));
-        return;
-      }
-    }
-
-    // No prior hunk found? Try to hit the first line in the file.
-    //
-    for (row = 0; row < table.getRowCount(); row++) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private void moveToNextChunk(int row) {
-    final int max = table.getRowCount();
-    while (row < max && isChunk(row)) {
-      row++;
-    }
-    for (; row < max; row++) {
-      if (isChunk(row)) {
-        movePointerTo(row, false);
-        scrollIntoView(oneBefore(row), oneAfter(findChunkEnd(row)));
-        return;
-      }
-    }
-
-    // No next hunk found? Try to hit the last line in the file.
-    //
-    for (row = max - 1; row >= 0; row--) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private void moveToPrevComment(int row) {
-    while (0 <= row && isComment(row)) {
-      row--;
-    }
-    for (; 0 <= row; row--) {
-      if (isComment(row)) {
-        movePointerTo(row, false);
-        scrollIntoView(oneBefore(row), oneAfter(row));
-        return;
-      }
-    }
-
-    // No prior comment found? Try to hit the first line in the file.
-    //
-    for (row = 0; row < table.getRowCount(); row++) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private void moveToNextComment(int row) {
-    final int max = table.getRowCount();
-    while (row < max && isComment(row)) {
-      row++;
-    }
-    for (; row < max; row++) {
-      if (isComment(row)) {
-        movePointerTo(row, false);
-        scrollIntoView(oneBefore(row), oneAfter(row));
-        return;
-      }
-    }
-
-    // No next comment found? Try to hit the last line in the file.
-    //
-    for (row = max - 1; row >= 0; row--) {
-      if (getRowItem(row) != null) {
-        movePointerTo(row);
-        break;
-      }
-    }
-  }
-
-  private boolean isComment(int row) {
-    return getRowItem(row) instanceof CommentList;
-  }
-
-  /**
-   * Invokes createCommentEditor() with an empty string as value for the comment
-   * parent UUID. This method is invoked by callers that want to create an
-   * editor for a comment that is not a reply.
-   */
-  protected void createCommentEditor(final int suggestRow, final int column,
-      final int line, final short file) {
-    if (Gerrit.isSignedIn()) {
-      if (R_HEAD <= line) {
-        final Patch.Key parentKey;
-        final short side;
-        switch (file) {
-          case 0:
-            if (idSideA == null) {
-              parentKey = new Patch.Key(idSideB, patchKey.get());
-              side = (short) 0;
-            } else {
-              parentKey = new Patch.Key(idSideA, patchKey.get());
-              side = (short) 1;
-            }
-            break;
-          case 1:
-            parentKey = new Patch.Key(idSideB, patchKey.get());
-            side = (short) 1;
-            break;
-          default:
-            throw new RuntimeException("unexpected file id " + file);
-        }
-
-        final PatchLineComment newComment = new PatchLineComment(
-            new PatchLineComment.Key(parentKey, null), line,
-            Gerrit.getUserAccount().getId(), null,
-            new Timestamp(System.currentTimeMillis()));
-        newComment.setSide(side);
-        newComment.setMessage("");
-
-        findOrCreateCommentEditor(suggestRow, column, newComment, true)
-            .setFocus(true);
-      }
-    } else {
-      Gerrit.doSignIn(History.getToken());
-    }
-  }
-
-  /**
-   * Update cursor after selecting a comment.
-   *
-   * @param newComment comment that was selected.
-   */
-  protected void updateCursor(final PatchLineComment newComment) {
-  }
-
-  abstract void insertFileCommentRow(final int row);
-
-  private CommentEditorPanel findOrCreateCommentEditor(final int suggestRow,
-      final int column, final PatchLineComment newComment, final boolean create) {
-    int row = suggestRow;
-    int[] spans = new int[column + 1];
-    FIND_ROW: while (row < table.getRowCount()) {
-      int col = 0;
-      for (int cell = 0; row < table.getRowCount()
-          && cell < table.getCellCount(row); cell++) {
-        while (col < column && 0 < spans[col]) {
-          spans[col++]--;
-        }
-        spans[col] = table.getFlexCellFormatter().getRowSpan(row, cell);
-        if (col == column) {
-          final Widget w = table.getWidget(row, cell);
-          if (w instanceof CommentEditorPanel
-              && ((CommentEditorPanel) w).getComment().getKey().getParentKey()
-                  .equals(newComment.getKey().getParentKey())) {
-            // Don't insert two editors on the same position, it doesn't make
-            // any sense to the user.
-            //
-            return ((CommentEditorPanel) w);
-
-          } else if (w instanceof CommentPanel) {
-            if (newComment != null && newComment.getParentUuid() != null) {
-              // If we are a reply, we were given the exact row to insert
-              // ourselves at. We should be before this panel so break.
-              //
-              break FIND_ROW;
-            }
-            row++;
-            cell--;
-          } else {
-            break FIND_ROW;
-          }
-        }
-      }
-    }
-
-    if (newComment == null || !create) {
-      return null;
-    }
-
-    final CommentEditorPanel ed =
-        new CommentEditorPanel(newComment, commentLinkProcessor);
-    ed.addFocusHandler(this);
-    ed.addBlurHandler(this);
-    boolean isCommentRow = false;
-    boolean needInsert = false;
-    if (row < table.getRowCount()) {
-      for (int cell = 0; cell < table.getCellCount(row); cell++) {
-        final Widget w = table.getWidget(row, cell);
-        if (w instanceof CommentEditorPanel || w instanceof CommentPanel) {
-          if (column == cell) {
-            needInsert = true;
-          }
-          isCommentRow = true;
-        }
-      }
-    }
-    if (needInsert || !isCommentRow) {
-      if (newComment.getLine() == R_HEAD) {
-        insertFileCommentRow(row);
-      } else {
-        insertRow(row);
-      }
-      styleCommentRow(row);
-    }
-    table.setWidget(row, column, ed);
-    styleLastCommentCell(row, column);
-
-    int span = 1;
-    for (int r = row + 1; r < table.getRowCount(); r++) {
-      boolean hasComment = false;
-      for (int c = 0; c < table.getCellCount(r); c++) {
-        final Widget w = table.getWidget(r, c);
-        if (w instanceof CommentPanel || w instanceof CommentEditorPanel) {
-          if (c != column) {
-            hasComment = true;
-            break;
-          }
-        }
-      }
-      if (hasComment) {
-        table.removeCell(r, column);
-        span++;
-      } else {
-        break;
-      }
-    }
-    if (span > 1) {
-      table.getFlexCellFormatter().setRowSpan(row, column, span);
-    }
-
-    for (int r = row - 1; r > 0; r--) {
-      if (getRowItem(r) instanceof CommentList) {
-        continue;
-      } else if (getRowItem(r) != null) {
-        movePointerTo(r);
-        break;
-      }
-    }
-
-    updateCursor(newComment);
-    return ed;
-  }
-
-  protected void insertRow(final int row) {
-    table.insertRow(row);
-    table.getCellFormatter().setStyleName(row, 0,
-        Gerrit.RESOURCES.css().iconCell());
-  }
-
-  @Override
-  protected void onOpenRow(final int row) {
-    final Object item = getRowItem(row);
-    if (item instanceof CommentList) {
-      for (final CommentPanel p : ((CommentList) item).panels) {
-        p.setOpen(!p.isOpen());
-      }
-    }
-  }
-
-  public void setAccountInfoCache(final AccountInfoCache aic) {
-    assert aic != null;
-    accountCache = aic;
-  }
-
-  private void destroyEditor(final int row, final int col) {
-    table.clearCell(row, col);
-    final int span = table.getFlexCellFormatter().getRowSpan(row, col);
-    boolean removeRow = true;
-    final int nCells = table.getCellCount(row);
-    for (int cell = 0; cell < nCells; cell++) {
-      if (table.getWidget(row, cell) != null) {
-        removeRow = false;
-        break;
-      }
-    }
-    if (removeRow) {
-      destroyCommentRow(row);
-    } else {
-      destroyComment(row, col, span);
-    }
-  }
-
-  protected void destroyCommentRow(int row) {
-    for (int r = row - 1; 0 <= r; r--) {
-      boolean data = false;
-      for (int c = 0; c < table.getCellCount(r); c++) {
-        data |= table.getWidget(r, c) != null;
-        final int s = table.getFlexCellFormatter().getRowSpan(r, c) - 1;
-        if (r + s == row) {
-          table.getFlexCellFormatter().setRowSpan(r, c, s);
-        }
-      }
-      if (!data) {
-        break;
-      }
-    }
-    table.removeRow(row);
-  }
-
-  private void destroyComment(int row, int col, int span) {
-    table.getFlexCellFormatter().setStyleName(//
-        row, col, Gerrit.RESOURCES.css().diffText());
-
-    if (span != 1) {
-      table.getFlexCellFormatter().setRowSpan(row, col, 1);
-      for (int r = row + 1; r < row + span; r++) {
-        table.insertCell(r, col);
-
-        table.getFlexCellFormatter().setStyleName(//
-            r, col, Gerrit.RESOURCES.css().diffText());
-      }
-    }
-  }
-
-  protected void bindComment(final int row, final int col,
-      final PatchLineComment line, boolean expandComment) {
-    if (line.getStatus() == PatchLineComment.Status.DRAFT) {
-      final CommentEditorPanel plc =
-          new CommentEditorPanel(line, commentLinkProcessor);
-      plc.addFocusHandler(this);
-      plc.addBlurHandler(this);
-      table.setWidget(row, col, plc);
-      styleLastCommentCell(row, col);
-
-    } else {
-      final AccountInfo author = FormatUtil.asInfo(accountCache.get(line.getAuthor()));
-      final PublishedCommentPanel panel =
-          new PublishedCommentPanel(author, line);
-      panel.setOpen(expandComment);
-      panel.addFocusHandler(this);
-      panel.addBlurHandler(this);
-      table.setWidget(row, col, panel);
-      styleLastCommentCell(row, col);
-
-      CommentList l = (CommentList) getRowItem(row);
-      if (l == null) {
-        l = new CommentList();
-        setRowItem(row, l);
-      }
-      l.comments.add(line);
-      l.panels.add(panel);
-    }
-
-    styleCommentRow(row);
-  }
-
-  @Override
-  public void onFocus(FocusEvent event) {
-    // when the comment panel gets focused (actually when a button inside the
-    // comment panel gets focused) we have to unregister the key binding for
-    // ENTER that expands/collapses the comment panel, if we don't do this the
-    // focused button in the comment panel cannot be triggered by pressing ENTER
-    // since ENTER would then be already consumed by this key binding
-    if (regOpenByEnter != null) {
-      regOpenByEnter.removeHandler();
-      regOpenByEnter = null;
-    }
-  }
-
-  @Override
-  public void onBlur(BlurEvent event) {
-    // when the comment panel gets blurred (actually when a button inside the
-    // comment panel gets blurred) we have to re-register the key binding for
-    // ENTER that expands/collapses the comment panel
-    if (keysOpenByEnter != null && regOpenByEnter == null) {
-      regOpenByEnter = GlobalKey.add(this, keysOpenByEnter);
-    }
-  }
-
-  private void styleCommentRow(final int row) {
-    final CellFormatter fmt = table.getCellFormatter();
-    final Element iconCell = fmt.getElement(row, 0);
-    UIObject.setStyleName(DOM.getParent(iconCell), Gerrit.RESOURCES.css()
-        .commentHolder(), true);
-  }
-
-  private void styleLastCommentCell(final int row, final int col) {
-    final CellFormatter fmt = table.getCellFormatter();
-    fmt.removeStyleName(row - 1, col, //
-        Gerrit.RESOURCES.css().commentPanelLast());
-    fmt.setStyleName(row, col, Gerrit.RESOURCES.css().commentHolder());
-    fmt.addStyleName(row, col, Gerrit.RESOURCES.css().commentPanelLast());
-    if (!fmt.getStyleName(row, col - 1).contains(Gerrit.RESOURCES.css().commentHolder())) {
-      fmt.addStyleName(row, col, Gerrit.RESOURCES.css().commentHolderLeftmost());
-    }
-  }
-
-  protected static class CommentList {
-    final List<PatchLineComment> comments = new ArrayList<>();
-    final List<PublishedCommentPanel> panels = new ArrayList<>();
-  }
-
-  public static class NoOpKeyCommand extends NeedsSignInKeyCommand {
-    public NoOpKeyCommand(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-    }
-  }
-
-  public class InsertCommentCommand extends NeedsSignInKeyCommand {
-    public InsertCommentCommand(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      ensurePointerVisible();
-      for (int row = getCurrentRow(); 0 <= row; row--) {
-        final Object item = getRowItem(row);
-        if (item instanceof PatchLine) {
-          onInsertComment((PatchLine) item);
-          return;
-        } else if (item instanceof CommentList) {
-          continue;
-        } else {
-          return;
-        }
-      }
-    }
-  }
-
-  public class PrevChunkKeyCmd extends KeyCommand {
-    public PrevChunkKeyCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      ensurePointerVisible();
-      moveToPrevChunk(getCurrentRow());
-    }
-  }
-
-  public class NextChunkKeyCmd extends KeyCommand {
-    public NextChunkKeyCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      ensurePointerVisible();
-      moveToNextChunk(getCurrentRow());
-    }
-  }
-
-  public class PrevCommentCmd extends KeyCommand {
-    public PrevCommentCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      ensurePointerVisible();
-      moveToPrevComment(getCurrentRow());
-    }
-  }
-
-  public class NextCommentCmd extends KeyCommand {
-    public NextCommentCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      ensurePointerVisible();
-      moveToNextComment(getCurrentRow());
-    }
-  }
-
-  private class PublishedCommentPanel extends CommentPanel implements
-      ClickHandler {
-    final PatchLineComment comment;
-    final Button reply;
-    final Button replyDone;
-
-    PublishedCommentPanel(final AccountInfo author, final PatchLineComment c) {
-      super(author, c.getWrittenOn(), c.getMessage(), commentLinkProcessor);
-      this.comment = c;
-
-      reply = new Button(PatchUtil.C.buttonReply());
-      reply.addClickHandler(this);
-      addButton(reply);
-
-      replyDone = new Button(PatchUtil.C.buttonReplyDone());
-      replyDone.addClickHandler(this);
-      addButton(replyDone);
-    }
-
-    @Override
-    public void onClick(final ClickEvent event) {
-      if (Gerrit.isSignedIn()) {
-        if (reply == event.getSource()) {
-          createReplyEditor();
-        } else if (replyDone == event.getSource()) {
-          cannedReply(PatchUtil.C.cannedReplyDone());
-        }
-
-      } else {
-        Gerrit.doSignIn(History.getToken());
-      }
-    }
-
-    private void createReplyEditor() {
-      final PatchLineComment newComment = newComment();
-      newComment.setMessage("");
-      findOrCreateEditor(newComment, true).setFocus(true);
-    }
-
-    private void cannedReply(String message) {
-      final PatchLineComment newComment = newComment();
-      newComment.setMessage(message);
-      CommentEditorPanel p = findOrCreateEditor(newComment, false);
-      if (p == null) {
-        enableButtons(false);
-        final PatchSet.Id psId = newComment.getKey().getParentKey().getParentKey();
-        CommentInfo in = CommentEditorPanel.toInput(newComment);
-        CommentApi.createDraft(psId, in,
-            new GerritCallback<CommentInfo>() {
-              @Override
-              public void onSuccess(CommentInfo result) {
-                enableButtons(true);
-                notifyDraftDelta(1);
-                findOrCreateEditor(CommentEditorPanel.toComment(
-                    psId, newComment.getKey().getParentKey().get(), result),
-                  true).setOpen(false);
-              }
-
-              @Override
-              public void onFailure(Throwable caught) {
-                enableButtons(true);
-                super.onFailure(caught);
-              }
-            });
-      } else {
-        if (!p.isOpen()) {
-          p.setOpen(true);
-        }
-        p.setFocus(true);
-      }
-    }
-
-    private CommentEditorPanel findOrCreateEditor(
-        PatchLineComment newComment, boolean create) {
-      int row = rowOf(getElement());
-      int column = columnOf(getElement());
-      return findOrCreateCommentEditor(row + 1, column, newComment, create);
-    }
-
-    private PatchLineComment newComment() {
-      PatchLineComment newComment =
-          new PatchLineComment(new PatchLineComment.Key(comment.getKey()
-              .getParentKey(), null), comment.getLine(), Gerrit
-              .getUserAccount().getId(), comment.getKey().get(),
-              new Timestamp(System.currentTimeMillis()));
-      newComment.setSide(comment.getSide());
-      return newComment;
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorContainer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorContainer.java
deleted file mode 100644
index b1381ca..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorContainer.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-public interface CommentEditorContainer {
-  void notifyDraftDelta(int delta);
-
-  void remove(CommentEditorPanel panel);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
deleted file mode 100644
index 81494c0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
+++ /dev/null
@@ -1,368 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.CommentApi;
-import com.google.gerrit.client.changes.CommentInfo;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.CommentPanel;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.user.client.Timer;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.NpTextArea;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-
-import java.sql.Timestamp;
-
-public class CommentEditorPanel extends CommentPanel implements ClickHandler,
-    DoubleClickHandler {
-  private static final int INITIAL_COLS = 60;
-  private static final int INITIAL_LINES = 5;
-  private static final int MAX_LINES = 30;
-  private static final AsyncCallback<VoidResult> NULL_CALLBACK =
-      new AsyncCallback<VoidResult>() {
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-
-        @Override
-        public void onSuccess(VoidResult result) {
-        }
-      };
-
-  private PatchLineComment comment;
-
-  private final NpTextArea text;
-  private final Button edit;
-  private final Button save;
-  private final Button cancel;
-  private final Button discard;
-  private final Timer expandTimer;
-
-  public CommentEditorPanel(final PatchLineComment plc,
-      final CommentLinkProcessor commentLinkProcessor) {
-    super(commentLinkProcessor);
-    comment = plc;
-
-    addStyleName(Gerrit.RESOURCES.css().commentEditorPanel());
-    setAuthorNameText(Gerrit.getUserAccount(), PatchUtil.C.draft());
-    setMessageText(plc.getMessage());
-    addDoubleClickHandler(this);
-
-    expandTimer = new Timer() {
-      @Override
-      public void run() {
-        expandText();
-      }
-    };
-    text = new NpTextArea();
-    text.setText(comment.getMessage());
-    text.setCharacterWidth(INITIAL_COLS);
-    text.setVisibleLines(INITIAL_LINES);
-    text.setSpellCheck(true);
-    text.addKeyDownHandler(new KeyDownHandler() {
-      @Override
-      public void onKeyDown(final KeyDownEvent event) {
-        if ((event.isControlKeyDown() || event.isMetaKeyDown())
-            && !event.isAltKeyDown() && !event.isShiftKeyDown()) {
-          switch (event.getNativeKeyCode()) {
-            case 's':
-            case 'S':
-              event.preventDefault();
-              onSave(NULL_CALLBACK);
-              return;
-          }
-        }
-
-        expandTimer.schedule(250);
-      }
-    });
-    addContent(text);
-
-    edit = new Button();
-    edit.setText(PatchUtil.C.buttonEdit());
-    edit.addClickHandler(this);
-    addButton(edit);
-
-    save = new Button();
-    save.setText(PatchUtil.C.buttonSave());
-    save.addClickHandler(this);
-    addButton(save);
-
-    cancel = new Button();
-    cancel.setText(PatchUtil.C.buttonCancel());
-    cancel.addClickHandler(this);
-    addButton(cancel);
-
-    discard = new Button();
-    discard.setText(PatchUtil.C.buttonDiscard());
-    discard.addClickHandler(this);
-    addButton(discard);
-
-    setOpen(true);
-    if (isNew()) {
-      edit();
-    } else {
-      render();
-    }
-  }
-
-  private void expandText() {
-    final double cols = text.getCharacterWidth();
-    int rows = 2;
-    for (final String line : text.getText().split("\n")) {
-      rows += Math.ceil((1.0 + line.length()) / cols);
-    }
-    rows = Math.max(INITIAL_LINES, Math.min(rows, MAX_LINES));
-    if (text.getVisibleLines() != rows) {
-      text.setVisibleLines(rows);
-    }
-  }
-
-  private void edit() {
-    if (!isOpen()) {
-      setOpen(true);
-    }
-    text.setText(comment.getMessage());
-    expandText();
-    stateEdit(true);
-    text.setFocus(true);
-  }
-
-  private void render() {
-    final Timestamp on = comment.getWrittenOn();
-    setDateText(PatchUtil.M.draftSaved(new java.util.Date(on.getTime())));
-    setMessageText(comment.getMessage());
-    stateEdit(false);
-  }
-
-  private void stateEdit(final boolean inEdit) {
-    expandTimer.cancel();
-    setMessageTextVisible(!inEdit);
-    edit.setVisible(!inEdit);
-
-    if (inEdit) {
-      text.setVisible(true);
-    } else {
-      text.setFocus(false);
-      text.setVisible(false);
-    }
-
-    save.setVisible(inEdit);
-    cancel.setVisible(inEdit && !isNew());
-    discard.setVisible(inEdit);
-  }
-
-  void setFocus(final boolean take) {
-    if (take && !isOpen()) {
-      setOpen(true);
-    }
-    if (text.isVisible()) {
-      text.setFocus(take);
-    } else if (take) {
-      edit();
-    }
-  }
-
-  boolean isNew() {
-    return comment.getKey().get() == null;
-  }
-
-  public PatchLineComment getComment() {
-    return comment;
-  }
-
-  @Override
-  public void onDoubleClick(final DoubleClickEvent event) {
-    edit();
-  }
-
-  @Override
-  public void onClick(final ClickEvent event) {
-    final Widget sender = (Widget) event.getSource();
-    if (sender == edit) {
-      edit();
-
-    } else if (sender == save) {
-      onSave(NULL_CALLBACK);
-
-    } else if (sender == cancel) {
-      render();
-
-    } else if (sender == discard) {
-      onDiscard();
-    }
-  }
-
-  public void saveDraft(AsyncCallback<VoidResult> onSave) {
-    if (isOpen() && text.isVisible()) {
-      onSave(onSave);
-    } else {
-      onSave.onSuccess(VoidResult.INSTANCE);
-    }
-  }
-
-  private void onSave(final AsyncCallback<VoidResult> onSave) {
-    expandTimer.cancel();
-    final String txt = text.getText().trim();
-    if ("".equals(txt)) {
-      return;
-    }
-
-    comment.setMessage(txt);
-    text.setFocus(false);
-    text.setReadOnly(true);
-    save.setEnabled(false);
-    cancel.setEnabled(false);
-    discard.setEnabled(false);
-
-    final PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
-    final boolean wasNew = isNew();
-    GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
-      @Override
-      public void onSuccess(CommentInfo result) {
-        notifyDraftDelta(wasNew ? 1 : 0);
-        comment = toComment(psId, comment.getKey().getParentKey().get(), result);
-        text.setReadOnly(false);
-        save.setEnabled(true);
-        cancel.setEnabled(true);
-        discard.setEnabled(true);
-        render();
-        onSave.onSuccess(VoidResult.INSTANCE);
-      }
-
-      @Override
-      public void onFailure(final Throwable caught) {
-        text.setReadOnly(false);
-        text.setFocus(true);
-        save.setEnabled(true);
-        cancel.setEnabled(true);
-        discard.setEnabled(true);
-        super.onFailure(caught);
-        onSave.onFailure(caught);
-      }
-    };
-    CommentInfo input = toInput(comment);
-    if (wasNew) {
-      CommentApi.createDraft(psId, input, cb);
-    } else {
-      CommentApi.updateDraft(psId, input.id(), input, cb);
-    }
-  }
-
-  private void notifyDraftDelta(final int delta) {
-    CommentEditorContainer c = getContainer();
-    if (c != null) {
-      c.notifyDraftDelta(delta);
-    }
-  }
-
-  private void onDiscard() {
-    expandTimer.cancel();
-    if (isNew()) {
-      text.setFocus(false);
-      removeUI();
-      return;
-    }
-
-    text.setFocus(false);
-    text.setReadOnly(true);
-    save.setEnabled(false);
-    cancel.setEnabled(false);
-    discard.setEnabled(false);
-
-    CommentApi.deleteDraft(
-        comment.getKey().getParentKey().getParentKey(),
-        comment.getKey().get(),
-        new GerritCallback<JavaScriptObject>() {
-          @Override
-          public void onSuccess(JavaScriptObject result) {
-            notifyDraftDelta(-1);
-            removeUI();
-          }
-
-          @Override
-          public void onFailure(final Throwable caught) {
-            text.setReadOnly(false);
-            text.setFocus(true);
-            save.setEnabled(true);
-            cancel.setEnabled(true);
-            discard.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
-  }
-
-  private void removeUI() {
-    CommentEditorContainer c = getContainer();
-    if (c != null) {
-      c.remove(this);
-    }
-  }
-
-  private CommentEditorContainer getContainer() {
-    Widget p = getParent();
-    while (p != null) {
-      if (p instanceof CommentEditorContainer) {
-        return (CommentEditorContainer) p;
-      }
-      p = p.getParent();
-    }
-    return null;
-  }
-
-  public static CommentInfo toInput(PatchLineComment c) {
-    CommentInfo i = CommentInfo.createObject().cast();
-    i.id(c.getKey().get());
-    i.path(c.getKey().getParentKey().get());
-    i.side(c.getSide() == 0 ? Side.PARENT : Side.REVISION);
-    if (c.getLine() > 0) {
-      i.line(c.getLine());
-    }
-    i.inReplyTo(c.getParentUuid());
-    i.message(c.getMessage());
-    return i;
-  }
-
-  public static PatchLineComment toComment(PatchSet.Id ps,
-      String path,
-      CommentInfo i) {
-    PatchLineComment p = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(ps, path),
-            i.id()),
-        i.line(),
-        Gerrit.getUserAccount().getId(),
-        i.inReplyTo(),
-        i.updated());
-    p.setMessage(i.message());
-    p.setSide((short) (i.side() == Side.PARENT ? 0 : 1));
-    return p;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.java
deleted file mode 100644
index f336382..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.java
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.StarredChanges;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.ui.ChangeLink;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.PreElement;
-import com.google.gwt.dom.client.Style.Display;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-import com.google.gwtexpui.clippy.client.CopyableLabel;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class CommitMessageBlock extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, CommitMessageBlock> {}
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  private KeyCommandSet keysAction;
-
-  @UiField
-  SimplePanel starPanel;
-  @UiField
-  FlowPanel permalinkPanel;
-  @UiField
-  PreElement commitSummaryPre;
-  @UiField
-  PreElement commitBodyPre;
-
-  CommitMessageBlock() {
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  CommitMessageBlock(KeyCommandSet keysAction) {
-    this.keysAction = keysAction;
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  void display(String commitMessage,
-      CommentLinkProcessor commentLinkProcessor) {
-    display(null, null, null, commitMessage, commentLinkProcessor);
-  }
-
-  void display(final PatchSet.Id patchSetId, final String revision,
-      Boolean starred, final String commitMessage,
-      CommentLinkProcessor commentLinkProcessor) {
-    starPanel.clear();
-    if (patchSetId != null && starred != null && Gerrit.isSignedIn()) {
-      Change.Id changeId = patchSetId.getParentKey();
-      StarredChanges.Icon star = StarredChanges.createIcon(changeId, starred);
-      star.setStyleName(Gerrit.RESOURCES.css().changeScreenStarIcon());
-      starPanel.add(star);
-
-      if (keysAction != null) {
-        keysAction.add(StarredChanges.newKeyCommand(star));
-      }
-    }
-
-    permalinkPanel.clear();
-    if (patchSetId != null && revision != null) {
-      final Change.Id changeId = patchSetId.getParentKey();
-      permalinkPanel.add(new ChangeLink(Util.C.changePermalink(), changeId));
-      permalinkPanel.add(new CopyableLabel(ChangeLink.permalink(changeId),
-          false));
-    }
-
-    String[] splitCommitMessage = commitMessage.split("\n", 2);
-
-    String commitSummary = splitCommitMessage[0];
-    String commitBody = "";
-    if (splitCommitMessage.length > 1) {
-      commitBody = splitCommitMessage[1];
-    }
-
-    // Linkify commit summary
-    SafeHtml commitSummaryLinkified = new SafeHtmlBuilder().append(commitSummary);
-    commitSummaryLinkified = commitSummaryLinkified.linkify();
-    commitSummaryLinkified = commentLinkProcessor.apply(commitSummaryLinkified);
-    commitSummaryPre.setInnerHTML(commitSummaryLinkified.asString());
-
-    // Hide commit body if there is no body
-    if (commitBody.trim().isEmpty()) {
-      commitBodyPre.getStyle().setDisplay(Display.NONE);
-    } else {
-      // Linkify commit body
-      SafeHtml commitBodyLinkified = new SafeHtmlBuilder().append(commitBody);
-      commitBodyLinkified = commitBodyLinkified.linkify();
-      commitBodyLinkified = commentLinkProcessor.apply(commitBodyLinkified);
-      commitBodyLinkified = commitBodyLinkified.replaceAll("\n\n", "<p></p>");
-      commitBodyLinkified = commitBodyLinkified.replaceAll("\n", "<br />");
-      commitBodyPre.setInnerHTML(commitBodyLinkified.asString());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.ui.xml
deleted file mode 100644
index f1bf3de..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommitMessageBlock.ui.xml
+++ /dev/null
@@ -1,102 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-
-
-  <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
-  <ui:style gss='false'>
-    @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-    @eval trimColor com.google.gerrit.client.Gerrit.getTheme().trimColor;
-    @eval backgroundColor com.google.gerrit.client.Gerrit.getTheme().backgroundColor;
-
-    .commitMessageTable {
-      border-collapse: separate;
-      border-spacing: 0;
-      margin-bottom: 10px;
-    }
-
-    .header {
-      background-color: trimColor;
-      white-space: nowrap;
-      color: textColor;
-      font-size: 10pt;
-      font-style: italic;
-      padding: 2px 6px 1px;
-    }
-
-    .contents {
-      border-bottom: 1px solid trimColor;
-      border-left: 1px solid trimColor;
-      border-right: 1px solid trimColor;
-      padding: 5px;
-    }
-
-    .contents span {
-      font-weight: bold;
-    }
-
-    .contents pre {
-      margin: 0;
-    }
-
-    .commitSummary {
-      font-weight: bold;
-    }
-
-    .commitBody p {
-      padding-top: 0px;
-    }
-
-    .starPanel {
-      float: left;
-    }
-
-    .boxTitle {
-      float: left;
-      margin-right: 10px;
-    }
-
-    .permalinkPanel {
-      float: right;
-    }
-
-    .permalinkPanel a {
-      float: left;
-    }
-
-    .permalinkPanel div {
-      display: inline;
-    }
-  </ui:style>
-
-  <g:HTMLPanel>
-    <table class='{style.commitMessageTable}'>
-      <tr><td class='{style.header}'>
-        <g:SimplePanel styleName='{style.starPanel}' ui:field='starPanel'></g:SimplePanel>
-        <div class='{style.boxTitle}'>Commit Message</div>
-        <g:FlowPanel styleName='{style.permalinkPanel}' ui:field='permalinkPanel'></g:FlowPanel>
-      </td></tr>
-      <tr><td class='{style.contents}'>
-        <pre class='{style.commitSummary} {res.css.changeScreenDescription}' ui:field='commitSummaryPre'/>
-        <pre class='{style.commitBody} {res.css.changeScreenDescription}' ui:field='commitBodyPre'/>
-      </td></tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
-
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/HistoryTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/HistoryTable.java
deleted file mode 100644
index c4fc1b0..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/HistoryTable.java
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-import com.google.gwt.user.client.ui.RadioButton;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A table used to specify which two patch sets should be diff'ed.
- */
-class HistoryTable extends FancyFlexTable<Patch> {
-  private final UnifiedPatchScreen screen;
-  final List<HistoryRadio> all = new ArrayList<>();
-
-  HistoryTable(final UnifiedPatchScreen parent) {
-    setStyleName(Gerrit.RESOURCES.css().patchHistoryTable());
-    screen = parent;
-    table.setWidth("auto");
-    table.addStyleName(Gerrit.RESOURCES.css().changeTable());
-  }
-
-  void onClick(final HistoryRadio b) {
-    PatchSet.Id sideA = screen.idSideA;
-    PatchSet.Id sideB = screen.idSideB;
-    switch (b.file) {
-      case 0:
-        sideA = b.patchSetId;
-        break;
-      case 1:
-        sideB = b.patchSetId;
-        break;
-      default:
-        return;
-    }
-    enableAll(false);
-    Patch.Key k = new Patch.Key(sideB, screen.getPatchKey().get());
-    Gerrit.display(Dispatcher.toUnified(sideA, k));
-  }
-
-  void enableAll(final boolean on) {
-    for (final HistoryRadio a : all) {
-      a.setEnabled(on);
-    }
-  }
-
-  void display(final List<Patch> result) {
-    all.clear();
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    table.setText(0, 0, PatchUtil.C.patchHeaderPatchSet());
-    fmt.setStyleName(0, 0, Gerrit.RESOURCES.css().dataHeader());
-    table.setText(1, 0, PatchUtil.C.patchHeaderOld());
-    fmt.setStyleName(1, 0, Gerrit.RESOURCES.css().dataHeader());
-    table.setText(2, 0, PatchUtil.C.patchHeaderNew());
-    fmt.setStyleName(2, 0, Gerrit.RESOURCES.css().dataHeader());
-    table.setText(3, 0, Util.C.patchTableColumnComments());
-    fmt.setStyleName(3, 0, Gerrit.RESOURCES.css().dataHeader());
-
-    if (screen.getPatchSetDetail().getInfo().getParents().size() > 1) {
-      table.setText(0, 1, PatchUtil.C.patchBaseAutoMerge());
-    } else {
-      table.setText(0, 1, PatchUtil.C.patchBase());
-    }
-    fmt.setStyleName(0, 1, Gerrit.RESOURCES.css().dataCell());
-    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topMostCell());
-    fmt.setStyleName(1, 1, Gerrit.RESOURCES.css().dataCell());
-    fmt.setStyleName(2, 1, Gerrit.RESOURCES.css().dataCell());
-    fmt.setStyleName(3, 1, Gerrit.RESOURCES.css().dataCell());
-
-    installRadio(1, 1, null, screen.idSideA, 0);
-
-    int col=2;
-    for (final Patch k : result) {
-      final PatchSet.Id psId = k.getKey().getParentKey();
-      table.setText(0, col, String.valueOf(psId.get()));
-      fmt.setStyleName(0, col, Gerrit.RESOURCES.css().patchHistoryTablePatchSetHeader());
-      fmt.addStyleName(0, col, Gerrit.RESOURCES.css().dataCell());
-      fmt.addStyleName(0, col, Gerrit.RESOURCES.css().topMostCell());
-
-      installRadio(1, col, psId, screen.idSideA, 0);
-      installRadio(2, col, psId, screen.idSideB, 1);
-
-      fmt.setStyleName(3, col, Gerrit.RESOURCES.css().dataCell());
-      if (k.getCommentCount() > 0) {
-        table.setText(3, col, Integer.toString(k.getCommentCount()));
-      }
-      col++;
-    }
-  }
-
-  private void installRadio(final int row, final int col, final PatchSet.Id psId,
-      final PatchSet.Id cur, final int file) {
-    final HistoryRadio b = new HistoryRadio(psId, file);
-    b.setValue(eq(cur, psId));
-
-    table.setWidget(row, col, b);
-    final FlexCellFormatter fmt = table.getFlexCellFormatter();
-    fmt.setHorizontalAlignment(row, col, HasHorizontalAlignment.ALIGN_CENTER);
-    fmt.setStyleName(row, col, Gerrit.RESOURCES.css().dataCell());
-    all.add(b);
-  }
-
-  private boolean eq(final PatchSet.Id cur, final PatchSet.Id psid) {
-    if (cur == null && psid == null) {
-      return true;
-    }
-    return psid != null && psid.equals(cur);
-  }
-
-  private class HistoryRadio extends RadioButton {
-    final PatchSet.Id patchSetId;
-    final int file;
-
-    HistoryRadio(final PatchSet.Id ps, final int f) {
-      super(String.valueOf(f));
-      sinkEvents(Event.ONCLICK);
-      patchSetId = ps;
-      file = f;
-    }
-
-    @Override
-    public void onBrowserEvent(final Event event) {
-      switch (DOM.eventGetType(event)) {
-        case Event.ONCLICK:
-          onClick(this);
-          break;
-        default:
-          super.onBrowserEvent(event);
-      }
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java
deleted file mode 100644
index c7f9859..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.ui.ChangeLink;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.Grid;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-
-import java.util.List;
-
-class NavLinks extends Composite {
-  public enum Nav {
-    PREV (0, '[', PatchUtil.C.previousFileHelp(), 0),
-    NEXT (3, ']', PatchUtil.C.nextFileHelp(), 1);
-
-    public int col;      // Table Cell column to display link in
-    public int key;      // key code shortcut to activate link
-    public String help;  // help string for '?' popup
-    public int cmd;      // index into cmds array
-
-    Nav(int c, int k, String h, int i) {
-      this.col = c;
-      this.key = k;
-      this.help = h;
-      this.cmd = i;
-    }
-  }
-
-  private final PatchSet.Id patchSetId;
-  private final KeyCommandSet keys;
-  private final Grid table;
-
-  private KeyCommand[] cmds = new KeyCommand[2];
-
-  NavLinks(KeyCommandSet kcs, PatchSet.Id forPatch) {
-    patchSetId = forPatch;
-    keys = kcs;
-    table = new Grid(1, 4);
-    initWidget(table);
-
-    final CellFormatter fmt = table.getCellFormatter();
-    table.setStyleName(Gerrit.RESOURCES.css().sideBySideScreenLinkTable());
-    fmt.setHorizontalAlignment(0, 0, HasHorizontalAlignment.ALIGN_LEFT);
-    fmt.setHorizontalAlignment(0, 1, HasHorizontalAlignment.ALIGN_CENTER);
-    fmt.setHorizontalAlignment(0, 2, HasHorizontalAlignment.ALIGN_RIGHT);
-    fmt.setHorizontalAlignment(0, 3, HasHorizontalAlignment.ALIGN_RIGHT);
-
-    final ChangeLink up = new ChangeLink("", patchSetId);
-    SafeHtml.set(up, SafeHtml.asis(Util.C.upToChangeIconLink()));
-    table.setWidget(0, 1, up);
-  }
-
-  void display(int patchIndex, PatchTable fileList,
-      List<InlineHyperlink> links, List<WebLinkInfo> webLinks) {
-    if (fileList != null) {
-      setupNav(Nav.PREV, fileList.getPreviousPatchLink(patchIndex));
-      setupNav(Nav.NEXT, fileList.getNextPatchLink(patchIndex));
-    } else {
-      setupNav(Nav.PREV, null);
-      setupNav(Nav.NEXT, null);
-    }
-
-    FlowPanel linkPanel = new FlowPanel();
-    linkPanel.setStyleName(Gerrit.RESOURCES.css().linkPanel());
-    for (InlineHyperlink link : links) {
-      linkPanel.add(link);
-    }
-    for (WebLinkInfo webLink : webLinks) {
-      linkPanel.add(webLink.toAnchor());
-    }
-    table.setWidget(0, 2, linkPanel);
-  }
-
-  protected void setupNav(final Nav nav, final InlineHyperlink link) {
-
-    /* setup the cells */
-    if (link != null) {
-      link.addStyleName(Gerrit.RESOURCES.css().nowrap());
-      table.setWidget(0, nav.col, link);
-    } else {
-      table.clearCell(0, nav.col);
-    }
-
-    /* setup the keys */
-    if (keys != null) {
-
-      if (cmds[nav.cmd] != null) {
-        keys.remove(cmds[nav.cmd]);
-      }
-
-      if (link != null) {
-        cmds[nav.cmd] = new KeyCommand(0, nav.key, nav.help) {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            link.go();
-          }
-        };
-      } else {
-        cmds[nav.cmd] = new UpToChangeCommand(patchSetId, 0, nav.key);
-      }
-
-      keys.add(cmds[nav.cmd]);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchBrowserPopup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchBrowserPopup.java
deleted file mode 100644
index 2962fb1..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchBrowserPopup.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gwt.event.logical.shared.ResizeEvent;
-import com.google.gwt.event.logical.shared.ResizeHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.DialogBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
-import com.google.gwt.user.client.ui.ScrollPanel;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.HidePopupPanelCommand;
-
-class PatchBrowserPopup extends DialogBox implements
-    PositionCallback, ResizeHandler {
-  private final Patch.Key callerKey;
-  private final PatchTable fileList;
-  private final ScrollPanel sp;
-  private HandlerRegistration regWindowResize;
-
-  PatchBrowserPopup(final Patch.Key pk, final PatchTable fl) {
-    super(true/* autohide */, false/* modal */);
-
-    callerKey = pk;
-    fileList = fl;
-    sp = new ScrollPanel(fileList);
-
-    final FlowPanel body = new FlowPanel();
-    body.setStyleName(Gerrit.RESOURCES.css().patchBrowserPopupBody());
-    body.add(sp);
-
-    setText(Util.M.patchSetHeader(callerKey.getParentKey().get()));
-    setWidget(body);
-    addStyleName(Gerrit.RESOURCES.css().patchBrowserPopup());
-  }
-
-  @Override
-  public void setPosition(final int myWidth, int myHeight) {
-    final int dLeft = (Window.getClientWidth() - myWidth) >> 1;
-    final int cHeight = Window.getClientHeight();
-    final int cHeight2 = 2 * cHeight / 3;
-    final int sLeft = Window.getScrollLeft();
-    final int sTop = Window.getScrollTop();
-
-    if (myHeight > cHeight2) {
-      sp.setHeight((cHeight2 - 50) + "px");
-      myHeight = getOffsetHeight();
-    }
-    setPopupPosition(sLeft + dLeft, (sTop + cHeight) - (myHeight + 10));
-  }
-
-  @Override
-  public void onResize(final ResizeEvent event) {
-    sp.setWidth((Window.getClientWidth() - 60) + "px");
-    setPosition(getOffsetWidth(), getOffsetHeight());
-  }
-
-  @Override
-  public void hide() {
-    if (regWindowResize != null) {
-      regWindowResize.removeHandler();
-      regWindowResize = null;
-    }
-    super.hide();
-  }
-
-  @Override
-  public void show() {
-    super.show();
-    if (regWindowResize == null) {
-      regWindowResize = Window.addResizeHandler(this);
-    }
-
-    GlobalKey.dialog(this);
-    GlobalKey.addApplication(this, new HidePopupPanelCommand(0, 'f', this));
-
-    if (!fileList.isLoaded()) {
-      fileList.onTableLoaded(new Command() {
-        @Override
-        public void execute() {
-          sp.setHeight("");
-          setPosition(getOffsetWidth(), getOffsetHeight());
-          fileList.setRegisterKeys(true);
-          fileList.movePointerTo(callerKey);
-        }
-      });
-    }
-  }
-
-  public void open() {
-    if (!fileList.isLoaded()) {
-      sp.setHeight("22px");
-    }
-    sp.setWidth((Window.getClientWidth() - 60) + "px");
-    setPopupPositionAndShow(this);
-    if (fileList.isLoaded()) {
-      fileList.setRegisterKeys(true);
-      fileList.movePointerTo(callerKey);
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
index 422a4dd..b199169 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
@@ -17,49 +17,25 @@
 import com.google.gwt.i18n.client.Constants;
 
 public interface PatchConstants extends Constants {
-  String draft();
-
-  String buttonReply();
-  String buttonEdit();
-  String buttonSave();
-  String buttonCancel();
-  String buttonDiscard();
-
-  String noDifference();
   String patchBase();
-  String patchBaseAutoMerge();
-  String patchHeaderPatchSet();
-  String patchHeaderOld();
-  String patchHeaderNew();
   String patchSet();
 
-  String patchHistoryTitle();
-  String disabledOnLargeFiles();
-  String intralineFailure();
-  String intralineTimeout();
-  String illegalNumberOfColumns();
-
   String upToChange();
   String openReply();
   String linePrev();
   String lineNext();
   String chunkPrev();
   String chunkNext();
-  String chunkPrev2();
-  String chunkNext2();
   String commentPrev();
   String commentNext();
   String focusSideA();
   String focusSideB();
-  String fileList();
   String expandComment();
   String expandAllCommentsOnCurrentLine();
   String toggleSideA();
   String toggleIntraline();
   String showPreferences();
 
-  String openEditScreen();
-
   String toggleReviewed();
   String markAsReviewedAndGoToNext();
 
@@ -76,22 +52,13 @@
   String previousFileHelp();
   String nextFileHelp();
 
-  String reviewedAnd();
-  String next();
   String download();
   String edit();
+  String blame();
   String addFileCommentToolTip();
-  String addFileCommentByDoubleClick();
 
-  String buttonReplyDone();
   String cannedReplyDone();
 
-  String fileTypeSymlink();
-  String fileTypeGitlink();
-
-  String patchSkipRegionStart();
-  String patchSkipRegionEnd();
-
   String sideBySideDiff();
   String unifiedDiff();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties
index aa6177b..13f0afa 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.properties
@@ -1,46 +1,24 @@
-draft = (Draft)
-
-buttonReply = Reply ...
-buttonReplyDone = Reply 'Done'
 cannedReplyDone = Done
-buttonEdit = Edit
-buttonSave = Save
-buttonCancel = Cancel
-buttonDiscard = Discard
 
-noDifference = No Differences
 patchBase = Base
-patchBaseAutoMerge = Auto Merge
-patchHeaderPatchSet = Patch Set
-patchHeaderOld = Old Version
-patchHeaderNew = New Version
-patchHistoryTitle = Patch History
 patchSet = Patch Set
-disabledOnLargeFiles = Disabled on very large source files.
-intralineFailure = Intraline difference not available due to server error.
-intralineTimeout = Intraline difference not available due to timeout.
-illegalNumberOfColumns = The number of columns cannot be zero or negative
 
 upToChange = Up to change
 openReply = Reply and score
 linePrev = Previous line
 lineNext = Next line
-chunkPrev = Previous diff chunk or comment
-chunkNext = Next diff chunk or comment
-chunkPrev2 = Previous diff chunk
-chunkNext2 = Next diff chunk or search result
+chunkPrev = Previous diff chunk
+chunkNext = Next diff chunk or search result
 commentPrev = Previous comment
 commentNext = Next comment
 focusSideA = Focus left side
 focusSideB = Focus right side
-fileList = Browse files in patch set
 expandComment = Expand or collapse comment
 expandAllCommentsOnCurrentLine = Expand or collapse all comments on current line
 toggleSideA = Toggle left side
 toggleIntraline = Toggle intraline difference
 showPreferences = Show diff preferences
 
-openEditScreen = Edit file in browser
 toggleReviewed = Toggle the reviewed flag
 markAsReviewedAndGoToNext = Mark patch as reviewed and go to next unreviewed patch
 
@@ -57,18 +35,10 @@
 previousFileHelp = Previous file
 nextFileHelp = Next file
 
-reviewedAnd = Reviewed &
-next = next
 download = Download
 edit = Edit
+blame = Blame
 addFileCommentToolTip = Click to add file comment
-addFileCommentByDoubleClick = Double click to add file comment
-
-fileTypeSymlink = Type: Symbolic Link
-fileTypeGitlink = Type: Git Commit in Subproject
-
-patchSkipRegionStart = ... skipped
-patchSkipRegionEnd = common lines ...
 
 sideBySideDiff = Side-by-side diff
 unifiedDiff = Unified diff
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchLine.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchLine.java
deleted file mode 100644
index 4863af2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchLine.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-class PatchLine {
-  static enum Type {
-    DELETE, INSERT, REPLACE, CONTEXT
-  }
-
-  private PatchLine.Type type;
-  private int lineA;
-  private int lineB;
-
-  PatchLine(final PatchLine.Type t, final int a, final int b) {
-    type = t;
-    lineA = a;
-    lineB = b;
-  }
-
-  PatchLine.Type getType() {
-    return type;
-  }
-
-  int getLineA() {
-    return lineA;
-  }
-
-  int getLineB() {
-    return lineB;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
index 822eff7..aaab1c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
@@ -16,12 +16,9 @@
 
 import com.google.gwt.i18n.client.Messages;
 
-import java.util.Date;
-
 public interface PatchMessages extends Messages {
   String expandBefore(int cnt);
   String expandAfter(int cnt);
-  String draftSaved(Date when);
   String patchSkipRegion(String lineNumber);
   String fileNameWithShortcutKey(String file, String key);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties
index fbb7d08..8dcebdc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.properties
@@ -1,5 +1,4 @@
 expandBefore = +{0}&#x21e7;
 expandAfter = +{0}&#x21e9;
-draftSaved = Draft saved at {0,time,short}
 patchSkipRegion = ... skipped {0} common lines ...
 fileNameWithShortcutKey = {0} (Shortcut: {1})
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties
index e24333e..8dcebdc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages_en.properties
@@ -1,3 +1,4 @@
 expandBefore = +{0}&#x21e7;
 expandAfter = +{0}&#x21e9;
 patchSkipRegion = ... skipped {0} common lines ...
+fileNameWithShortcutKey = {0} (Shortcut: {1})
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.java
deleted file mode 100644
index b7ba64b..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.java
+++ /dev/null
@@ -1,347 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.Util;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.ui.ListenableAccountDiffPreference;
-import com.google.gerrit.client.ui.NpIntTextBox;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.dom.client.NodeList;
-import com.google.gwt.dom.client.OptionElement;
-import com.google.gwt.dom.client.SelectElement;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FocusWidget;
-import com.google.gwt.user.client.ui.HasWidgets;
-import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtjsonrpc.common.VoidResult;
-
-public class PatchScriptSettingsPanel extends Composite {
-  private static MyUiBinder uiBinder = GWT.create(MyUiBinder.class);
-
-  interface MyUiBinder extends UiBinder<Widget, PatchScriptSettingsPanel> {
-  }
-
-  private final ListenableAccountDiffPreference listenablePrefs;
-  private boolean enableIntralineDifference = true;
-  private boolean enableSmallFileFeatures = true;
-
-  @UiField
-  ListBox ignoreWhitespace;
-
-  @UiField
-  NpIntTextBox tabWidth;
-
-  @UiField
-  NpIntTextBox colWidth;
-
-  @UiField
-  CheckBox syntaxHighlighting;
-
-  @UiField
-  CheckBox intralineDifference;
-
-  @UiField
-  ListBox context;
-
-  @UiField
-  CheckBox whitespaceErrors;
-
-  @UiField
-  CheckBox showLineEndings;
-
-  @UiField
-  CheckBox showTabs;
-
-  @UiField
-  CheckBox manualReview;
-
-  @UiField
-  CheckBox skipDeleted;
-
-  @UiField
-  CheckBox skipUncommented;
-
-  @UiField
-  CheckBox expandAllComments;
-
-  @UiField
-  CheckBox retainHeader;
-
-  @UiField
-  Button update;
-
-  @UiField
-  Button save;
-
-  /**
-   * Counts +1 for every setEnabled(true) and -1 for every setEnabled(false)
-   *
-   * The purpose is to prevent enabling widgets too early. It might happen that
-   * setEnabled(false) is called from this class and from an event handler
-   * of ValueChangeEvent in another class. The first setEnabled(true) would then
-   * enable widgets too early i.e. before the second setEnabled(true) is called.
-   *
-   * With this counter the setEnabled(true) will enable widgets only when
-   * setEnabledCounter == 0. Until it is less than zero setEnabled(true) will
-   * not enable the widgets.
-   */
-  private int setEnabledCounter;
-
-  public PatchScriptSettingsPanel(ListenableAccountDiffPreference prefs) {
-    listenablePrefs = prefs;
-    initWidget(uiBinder.createAndBindUi(this));
-    initIgnoreWhitespace(ignoreWhitespace);
-    initContext(context);
-    if (!Gerrit.isSignedIn()) {
-      save.setVisible(false);
-    }
-
-    KeyPressHandler onEnter = new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          save();
-        }
-      }
-    };
-    tabWidth.addKeyPressHandler(onEnter);
-    colWidth.addKeyPressHandler(onEnter);
-
-    display();
-  }
-
-  public void setEnabled(final boolean on) {
-    if (on) {
-      setEnabledCounter++;
-    } else {
-      setEnabledCounter--;
-    }
-    if (on && setEnabledCounter == 0 || !on) {
-      for (Widget w : (HasWidgets) getWidget()) {
-        if (w instanceof FocusWidget) {
-          ((FocusWidget) w).setEnabled(on);
-        }
-      }
-      toggleEnabledStatus(on);
-    }
-  }
-
-  public void setEnableSmallFileFeatures(final boolean on) {
-    enableSmallFileFeatures = on;
-    if (enableSmallFileFeatures) {
-      syntaxHighlighting.setValue(getValue().syntaxHighlighting);
-    } else {
-      syntaxHighlighting.setValue(false);
-    }
-
-    NodeList<OptionElement> options =
-        context.getElement().<SelectElement>cast().getOptions();
-    // WHOLE_FILE_CONTEXT is the last option in the list.
-    int lastIndex = options.getLength() - 1;
-    OptionElement currOption = options.getItem(lastIndex);
-    if (enableSmallFileFeatures) {
-      currOption.setDisabled(false);
-    } else {
-      currOption.setDisabled(true);
-      if (context.getSelectedIndex() == lastIndex) {
-        // Select the next longest context from WHOLE_FILE_CONTEXT
-        context.setSelectedIndex(lastIndex - 1);
-      }
-    }
-    toggleEnabledStatus(save.isEnabled());
-  }
-
-  public void setEnableIntralineDifference(final boolean on) {
-    enableIntralineDifference = on;
-    if (enableIntralineDifference) {
-      intralineDifference.setValue(getValue().intralineDifference);
-    } else {
-      intralineDifference.setValue(false);
-    }
-    toggleEnabledStatus(save.isEnabled());
-  }
-
-  private void toggleEnabledStatus(final boolean on) {
-    intralineDifference.setEnabled(on & enableIntralineDifference);
-    syntaxHighlighting.setEnabled(on & enableSmallFileFeatures);
-
-    final String title =
-        enableSmallFileFeatures ? null : PatchUtil.C.disabledOnLargeFiles();
-    syntaxHighlighting.setTitle(title);
-  }
-
-  public DiffPreferencesInfo getValue() {
-    return listenablePrefs.get();
-  }
-
-  public void setValue(final DiffPreferencesInfo dp) {
-    listenablePrefs.set(dp);
-    display();
-  }
-
-  protected void display() {
-    final DiffPreferencesInfo dp = getValue();
-    setIgnoreWhitespace(dp.ignoreWhitespace);
-    if (enableSmallFileFeatures) {
-      syntaxHighlighting.setValue(dp.syntaxHighlighting);
-    } else {
-      syntaxHighlighting.setValue(false);
-    }
-    setContext(dp.context);
-
-    tabWidth.setIntValue(dp.tabSize);
-    colWidth.setIntValue(dp.lineLength);
-    intralineDifference.setValue(dp.intralineDifference);
-    whitespaceErrors.setValue(dp.showWhitespaceErrors);
-    showLineEndings.setValue(dp.showLineEndings);
-    showTabs.setValue(dp.showTabs);
-    skipDeleted.setValue(dp.skipDeleted);
-    skipUncommented.setValue(dp.skipUncommented);
-    expandAllComments.setValue(dp.expandAllComments);
-    retainHeader.setValue(dp.retainHeader);
-    manualReview.setValue(dp.manualReview);
-  }
-
-  @UiHandler("update")
-  void onUpdate(@SuppressWarnings("unused") ClickEvent event) {
-    update();
-  }
-
-  @UiHandler("save")
-  void onSave(@SuppressWarnings("unused") ClickEvent event) {
-    save();
-  }
-
-  private void update() {
-    if (colWidth.getIntValue() <= 0) {
-      new ErrorDialog(PatchUtil.C.illegalNumberOfColumns()).center();
-      return;
-    }
-    DiffPreferencesInfo dp = getValue();
-    dp.ignoreWhitespace = getIgnoreWhitespace();
-    dp.context = getContext();
-    dp.tabSize = tabWidth.getIntValue();
-    dp.lineLength = colWidth.getIntValue();
-    dp.syntaxHighlighting = syntaxHighlighting.getValue();
-    dp.intralineDifference = intralineDifference.getValue();
-    dp.showWhitespaceErrors = whitespaceErrors.getValue();
-    dp.showLineEndings = showLineEndings.getValue();
-    dp.showTabs = showTabs.getValue();
-    dp.skipDeleted = skipDeleted.getValue();
-    dp.skipUncommented = skipUncommented.getValue();
-    dp.expandAllComments = expandAllComments.getValue();
-    dp.retainHeader = retainHeader.getValue();
-    dp.manualReview = manualReview.getValue();
-
-    listenablePrefs.set(dp);
-  }
-
-  private void save() {
-    update();
-    if (Gerrit.isSignedIn()) {
-      persistDiffPreferences();
-    }
-  }
-
-  private void persistDiffPreferences() {
-    setEnabled(false);
-    listenablePrefs.save(new GerritCallback<VoidResult>() {
-      @Override
-      public void onSuccess(VoidResult result) {
-        setEnabled(true);
-      }
-
-      @Override
-      public void onFailure(Throwable caught) {
-        setEnabled(true);
-      }
-    });
-  }
-
-  private void initIgnoreWhitespace(ListBox ws) {
-    ws.addItem(PatchUtil.C.whitespaceIGNORE_NONE(), //
-        Whitespace.IGNORE_NONE.name());
-    ws.addItem(PatchUtil.C.whitespaceIGNORE_TRAILING(), //
-        Whitespace.IGNORE_TRAILING.name());
-    ws.addItem(PatchUtil.C.whitespaceIGNORE_LEADING_AND_TRAILING(), //
-        Whitespace.IGNORE_LEADING_AND_TRAILING.name());
-    ws.addItem(PatchUtil.C.whitespaceIGNORE_ALL(), //
-        Whitespace.IGNORE_ALL.name());
-  }
-
-  private void initContext(ListBox context) {
-    for (final short v : DiffPreferencesInfo.CONTEXT_CHOICES) {
-      final String label;
-      if (v == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-        label = Util.C.contextWholeFile();
-      } else {
-        label = Util.M.lines(v);
-      }
-      context.addItem(label, String.valueOf(v));
-    }
-  }
-
-  private Whitespace getIgnoreWhitespace() {
-    final int sel = ignoreWhitespace.getSelectedIndex();
-    if (0 <= sel) {
-      return Whitespace.valueOf(ignoreWhitespace.getValue(sel));
-    }
-    return getValue().ignoreWhitespace;
-  }
-
-  private void setIgnoreWhitespace(Whitespace s) {
-    for (int i = 0; i < ignoreWhitespace.getItemCount(); i++) {
-      if (ignoreWhitespace.getValue(i).equals(s.name())) {
-        ignoreWhitespace.setSelectedIndex(i);
-        return;
-      }
-    }
-    ignoreWhitespace.setSelectedIndex(0);
-  }
-
-  private int getContext() {
-    final int sel = context.getSelectedIndex();
-    if (0 <= sel) {
-      return Short.parseShort(context.getValue(sel));
-    }
-    return getValue().context;
-  }
-
-  private void setContext(int ctx) {
-    String v = String.valueOf(ctx);
-    for (int i = 0; i < context.getItemCount(); i++) {
-      if (context.getValue(i).equals(v)) {
-        context.setSelectedIndex(i);
-        return;
-      }
-    }
-    context.setSelectedIndex(0);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.ui.xml
deleted file mode 100644
index 5164302..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScriptSettingsPanel.ui.xml
+++ /dev/null
@@ -1,209 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2010 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<ui:UiBinder
-  xmlns:ui='urn:ui:com.google.gwt.uibinder'
-  xmlns:g='urn:import:com.google.gwt.user.client.ui'
-  xmlns:my='urn:import:com.google.gerrit.client.ui'
-  ui:generateFormat='com.google.gwt.i18n.rebind.format.PropertiesFormat'
-  ui:generateKeys='com.google.gwt.i18n.rebind.keygen.MD5KeyGenerator'
-  ui:generateLocales='default,en'
-  >
-<ui:style gss='false'>
-  @external .gwt-TextBox;
-  @external .gwt-ListBox;
-
-  @def fontSize 8pt;
-
-  .controls {
-    border: none;
-    border-collapse: separate;
-    border-spacing: 0;
-  }
-
-  .controls td {
-    font-size: fontSize;
-    padding: 0;
-    white-space: nowrap;
-  }
-
-  .controls .gwt-TextBox {
-    font-size: fontSize;
-    padding: 0;
-    text-align: right;
-  }
-
-  .controls .gwt-ListBox {
-    font-size: fontSize;
-    padding: 0;
-    margin-right: 1em;
-  }
-
-  .updateButton {
-    margin-left: 1em;
-    margin-right: 1em;
-    font-size: fontSize;
-  }
-</ui:style>
-
-<g:HTMLPanel>
-<table class='{style.controls}'>
-  <tr valign='top'>
-    <ui:msg>
-      <td align='right'>Ignore Whitespace:</td>
-      <td align='right'>
-        <g:ListBox
-          ui:field='ignoreWhitespace'
-          visibleItemCount='1'
-          tabIndex='1'/>
-      </td>
-    </ui:msg>
-
-    <td align='right'>
-      <ui:msg>Tab Width:
-      <my:NpIntTextBox
-        ui:field='tabWidth'
-        width='2em'
-        visibleLength='2'
-        maxLength='2'
-        tabIndex='3'/>
-      </ui:msg>
-    </td>
-
-    <td rowspan='2'>
-      <g:CheckBox
-          ui:field='syntaxHighlighting'
-          text='Syntax Coloring'
-          tabIndex='5'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-      <br/>
-      <g:CheckBox
-          ui:field='intralineDifference'
-          text='Intraline Difference'
-          tabIndex='6'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-    </td>
-
-    <td rowspan='2'>
-      <g:CheckBox
-          ui:field='whitespaceErrors'
-          text='Whitespace Errors'
-          tabIndex='7'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-      <br/>
-      <g:CheckBox
-          ui:field='showLineEndings'
-          text='Show Line Endings'
-          tabIndex='8'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-    </td>
-
-    <td rowspan='2'>
-      <g:CheckBox
-          ui:field='showTabs'
-          text='Show Tabs'
-          tabIndex='9'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-      <br/>
-      <g:CheckBox
-          ui:field='expandAllComments'
-          text='Expand All Comments'
-          tabIndex='10'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-    </td>
-
-    <td rowspan='2'>
-      <g:CheckBox
-          ui:field='retainHeader'
-          text='Retain Header On File Switch'
-          tabIndex='11'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-      <br/>
-      <g:CheckBox
-          ui:field='skipUncommented'
-          text='Skip Uncommented Files'
-          tabIndex='12'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-    </td>
-
-    <td valign='bottom' rowspan='2'>
-      <g:CheckBox
-          ui:field='skipDeleted'
-          text='Skip Deleted Files'
-          tabIndex='13'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-      <br/>
-      <g:CheckBox
-          ui:field='manualReview'
-          text='Manual Review'
-          tabIndex='14'>
-        <ui:attribute name='text'/>
-      </g:CheckBox>
-    </td>
-
-    <td rowspan='2'>
-      <br/>
-      <g:Button
-          ui:field='update'
-          text='Update'
-          styleName='{style.updateButton}'
-          tabIndex='15'>
-        <ui:attribute name='text'/>
-      </g:Button>
-      <g:Button
-          ui:field='save'
-          text='Save'
-          styleName='{style.updateButton}'
-          tabIndex='16'>
-        <ui:attribute name='text'/>
-      </g:Button>
-    </td>
-  </tr>
-
-  <tr valign='top'>
-    <ui:msg>
-      <td align='right'>Context:</td>
-      <td align='right'>
-        <g:ListBox
-            ui:field='context'
-            visibleItemCount='1'
-            tabIndex='2'/>
-      </td>
-    </ui:msg>
-
-    <td align='right'>
-      <ui:msg>Columns:
-      <my:NpIntTextBox
-        ui:field='colWidth'
-        width='2.5em'
-        visibleLength='3'
-        maxLength='3'
-        tabIndex='4'/>
-      </ui:msg>
-    </td>
-  </tr>
-</table>
-</g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.java
deleted file mode 100644
index 6762383..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.java
+++ /dev/null
@@ -1,188 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwtorm.client.KeyUtil;
-
-import java.util.HashMap;
-import java.util.Map;
-
-public class PatchSetSelectBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, PatchSetSelectBox> {}
-  private static final Binder uiBinder = GWT.create(Binder.class);
-
-  interface BoxStyle extends CssResource {
-    String selected();
-    String hidden();
-    String sideMarker();
-    String patchSetLabel();
-  }
-
-  public enum Side {
-    A, B
-  }
-
-  PatchScript script;
-  Patch.Key patchKey;
-  PatchSet.Id idSideA;
-  PatchSet.Id idSideB;
-  PatchSet.Id idActive;
-  Side side;
-  Map<Integer, Anchor> links;
-  private Label patchSet;
-
-  @UiField
-  HTMLPanel linkPanel;
-
-  @UiField
-  BoxStyle style;
-
-  public PatchSetSelectBox(Side side) {
-    this.side = side;
-
-    initWidget(uiBinder.createAndBindUi(this));
-  }
-
-  public void display(final PatchSetDetail detail, final PatchScript script,
-      Patch.Key key, PatchSet.Id idSideA, PatchSet.Id idSideB) {
-    this.script = script;
-    this.patchKey = key;
-    this.idSideA = idSideA;
-    this.idSideB = idSideB;
-    this.idActive = (side == Side.A) ? idSideA : idSideB;
-    this.links = new HashMap<>();
-
-    linkPanel.clear();
-
-    if (isFileOrCommitMessage()) {
-      linkPanel.setTitle(PatchUtil.C.addFileCommentByDoubleClick());
-    }
-
-    patchSet = new Label(PatchUtil.C.patchSet());
-    patchSet.addStyleName(style.patchSetLabel());
-    linkPanel.add(patchSet);
-
-    Label sideMarker = new Label((side == Side.A) ? "(-)" : "(+)");
-    sideMarker.addStyleName(style.sideMarker());
-    linkPanel.add(sideMarker);
-
-    Anchor baseLink;
-    if (detail.getInfo().getParents().size() > 1) {
-      baseLink = createLink(PatchUtil.C.patchBaseAutoMerge(), null);
-    } else {
-      baseLink = createLink(PatchUtil.C.patchBase(), null);
-    }
-
-    links.put(0, baseLink);
-    linkPanel.add(baseLink);
-
-    if (side == Side.B) {
-      links.get(0).setStyleName(style.hidden());
-    }
-
-    for (Patch patch : script.getHistory()) {
-      PatchSet.Id psId = patch.getKey().getParentKey();
-      Anchor anchor = createLink(psId.getId(), psId);
-      links.put(psId.get(), anchor);
-      linkPanel.add(anchor);
-    }
-
-    if (idActive == null && side == Side.A) {
-      links.get(0).setStyleName(style.selected());
-    } else if (idActive != null) {
-      links.get(idActive.get()).setStyleName(style.selected());
-    }
-
-    Anchor downloadLink = createDownloadLink();
-    if (downloadLink != null) {
-      linkPanel.add(downloadLink);
-    }
-  }
-
-  public void addDoubleClickHandler(DoubleClickHandler handler) {
-    linkPanel.sinkEvents(Event.ONDBLCLICK);
-    linkPanel.addHandler(handler, DoubleClickEvent.getType());
-    patchSet.addDoubleClickHandler(handler);
-  }
-
-  private Anchor createLink(String label, final PatchSet.Id id) {
-    final Anchor anchor = new Anchor(label);
-    anchor.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        if (side == Side.A) {
-          idSideA = id;
-        } else {
-          idSideB = id;
-        }
-
-        Patch.Key keySideB = new Patch.Key(idSideB, patchKey.get());
-        Gerrit.display(Dispatcher.toUnified(idSideA, keySideB));
-      }
-    });
-    return anchor;
-  }
-
-  public boolean isFileOrCommitMessage() {
-    return !((side == Side.A && 0 >= script.getA().size()) || //
-    (side == Side.B && 0 >= script.getB().size()));
-  }
-
-  private Anchor createDownloadLink() {
-    boolean isCommitMessage = Patch.COMMIT_MSG.equals(script.getNewName());
-    if (isCommitMessage || //
-        (side == Side.A && 0 >= script.getA().size()) || //
-        (side == Side.B && 0 >= script.getB().size())) {
-      return null;
-    }
-
-    Patch.Key key = (idActive == null) ? //
-        patchKey : (new Patch.Key(idActive, patchKey.get()));
-
-    String sideURL = (idActive == null) ? "1" : "0";
-    final String base = GWT.getHostPageBaseURL() + "cat/";
-
-    Image image = new Image(Gerrit.RESOURCES.downloadIcon());
-
-    final Anchor anchor = new Anchor();
-    anchor.setHref(base + KeyUtil.encode(key.toString()) + "^" + sideURL);
-    anchor.setTitle(PatchUtil.C.download());
-    DOM.insertBefore(anchor.getElement(), image.getElement(),
-        DOM.getFirstChild(anchor.getElement()));
-
-    return anchor;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.ui.xml
deleted file mode 100644
index 8977876..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchSetSelectBox.ui.xml
+++ /dev/null
@@ -1,67 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<ui:UiBinder xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'>
-  <ui:with field='res' type='com.google.gerrit.client.GerritResources'/>
-  <ui:style gss='false' type='com.google.gerrit.client.patches.PatchSetSelectBox.BoxStyle'>
-    @eval selectionColor com.google.gerrit.client.Gerrit.getTheme().selectionColor;
-    @eval backgroundColor com.google.gerrit.client.Gerrit.getTheme().backgroundColor;
-
-    .linkPanel {
-      font-size: 12px;
-      white-space: normal;
-    }
-
-    .linkPanel > div {
-      padding-left: 3px;
-      padding-right: 3px;
-      vertical-align: middle;
-      display: inline-block;
-    }
-
-    .patchSetLabel {
-      font-weight: bold;
-    }
-
-    .sideMarker {
-      font-family: monospace;
-    }
-
-    .linkPanel > a {
-      padding-left: 3px;
-      padding-right: 3px;
-      text-decoration: none;
-      vertical-align: middle;
-      display: inline-block;
-    }
-
-    .selected {
-      font-weight: bold;
-      background-color: selectionColor;
-    }
-
-    .hidden {
-      visibility: hidden;
-    }
-  </ui:style>
-
-  <g:HTMLPanel>
-    <g:HTMLPanel styleName='{style.linkPanel}' ui:field='linkPanel'/>
-  </g:HTMLPanel>
-</ui:UiBinder>
-
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java
deleted file mode 100644
index d84f799..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java
+++ /dev/null
@@ -1,897 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.ListenableAccountDiffPreference;
-import com.google.gerrit.client.ui.NavigationTable;
-import com.google.gerrit.client.ui.PatchLink;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.Patch.PatchType;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.RepeatingCommand;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.KeyCodes;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.Window;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTMLTable.Cell;
-import com.google.gwt.user.client.ui.Image;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.progress.client.ProgressBar;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-class PatchTable extends Composite {
-  interface PatchValidator {
-    /**
-     * @param patch
-     * @return true if patch is valid.
-     */
-    boolean isValid(Patch patch);
-  }
-
-  final PatchValidator PREFERENCE_VALIDATOR =
-      new PatchValidator() {
-        @Override
-        public boolean isValid(Patch patch) {
-          return !((listenablePrefs.get().skipDeleted
-              && patch.getChangeType().equals(ChangeType.DELETED))
-              || (listenablePrefs.get().skipUncommented
-              && patch.getCommentCount() == 0));
-        }
-
-      };
-
-  private final FlowPanel myBody;
-  private PatchSetDetail detail;
-  private Command onLoadCommand;
-  private MyTable myTable;
-  private String savePointerId;
-  private PatchSet.Id base;
-  private List<Patch> patchList;
-  private Map<Patch.Key, Integer> patchMap;
-  private ListenableAccountDiffPreference listenablePrefs;
-
-  private List<ClickHandler> clickHandlers;
-  private boolean active;
-  private boolean registerKeys;
-
-  PatchTable(ListenableAccountDiffPreference prefs) {
-    listenablePrefs = prefs;
-    myBody = new FlowPanel();
-    initWidget(myBody);
-  }
-
-  PatchTable() {
-    this(new ListenableAccountDiffPreference());
-  }
-
-  int indexOf(Patch.Key patch) {
-    Integer i = patchMap().get(patch);
-    return i != null ? i : -1;
-  }
-
-  private Map<Patch.Key, Integer> patchMap() {
-    if (patchMap == null) {
-      patchMap = new HashMap<>();
-      for (int i = 0; i < patchList.size(); i++) {
-        patchMap.put(patchList.get(i).getKey(), i);
-      }
-    }
-    return patchMap;
-  }
-
-  void display(PatchSet.Id base, PatchSetDetail detail) {
-    this.base = base;
-    this.detail = detail;
-    this.patchList = detail.getPatches();
-    this.patchMap = null;
-    myTable = null;
-
-    final DisplayCommand cmd = new DisplayCommand(patchList, base);
-    if (cmd.execute()) {
-      cmd.initMeter();
-      Scheduler.get().scheduleIncremental(cmd);
-    } else {
-      cmd.showTable();
-    }
-  }
-
-  PatchSet.Id getBase() {
-    return base;
-  }
-
-  void setSavePointerId(final String id) {
-    savePointerId = id;
-  }
-
-  boolean isLoaded() {
-    return myTable != null;
-  }
-
-  void onTableLoaded(final Command cmd) {
-    if (myTable != null) {
-      cmd.execute();
-    } else {
-      onLoadCommand = cmd;
-    }
-  }
-
-  void addClickHandler(final ClickHandler clickHandler) {
-    if (myTable != null) {
-      myTable.addClickHandler(clickHandler);
-    } else {
-      if (clickHandlers == null) {
-        clickHandlers = new ArrayList<>(2);
-      }
-      clickHandlers.add(clickHandler);
-    }
-  }
-
-  void setRegisterKeys(final boolean on) {
-    registerKeys = on;
-    if (myTable != null) {
-      myTable.setRegisterKeys(on);
-    }
-  }
-
-  void movePointerTo(final Patch.Key k) {
-    if (myTable != null) {
-      myTable.movePointerTo(k);
-    }
-  }
-
-  void setActive(boolean active) {
-    this.active = active;
-    if (myTable != null) {
-      myTable.setActive(active);
-    }
-  }
-
-  void notifyDraftDelta(final Patch.Key k, final int delta) {
-    if (myTable != null) {
-      myTable.notifyDraftDelta(k, delta);
-    }
-  }
-
-  private void setMyTable(MyTable table) {
-    myBody.clear();
-    myBody.add(table);
-    myTable = table;
-
-    if (clickHandlers != null) {
-      for (ClickHandler ch : clickHandlers) {
-        myTable.addClickHandler(ch);
-      }
-      clickHandlers = null;
-    }
-
-    if (active) {
-      myTable.setActive(true);
-      active = false;
-    }
-
-    if (registerKeys) {
-      myTable.setRegisterKeys(registerKeys);
-      registerKeys = false;
-    }
-
-    myTable.finishDisplay();
-  }
-
-  /**
-   * @return a link to the previous file in this patch set, or null.
-   */
-  InlineHyperlink getPreviousPatchLink(int index) {
-    int previousPatchIndex = getPreviousPatch(index, PREFERENCE_VALIDATOR);
-    if (previousPatchIndex < 0) {
-      return null;
-    }
-    return createLink(previousPatchIndex,
-        SafeHtml.asis(Util.C.prevPatchLinkIcon()), null);
-  }
-
-  /**
-   * @return a link to the next file in this patch set, or null.
-   */
-  InlineHyperlink getNextPatchLink(int index) {
-    int nextPatchIndex = getNextPatch(index, false, PREFERENCE_VALIDATOR);
-    if (nextPatchIndex < 0) {
-      return null;
-    }
-    return createLink(nextPatchIndex, null,
-        SafeHtml.asis(Util.C.nextPatchLinkIcon()));
-  }
-
-  /**
-   * @return a link to the the given patch.
-   * @param index The patch to link to
-   * @param before A string to display at the beginning of the href text
-   * @param after A string to display at the end of the href text
-   */
-  PatchLink createLink(int index, SafeHtml before, SafeHtml after) {
-    Patch patch = patchList.get(index);
-    Patch.Key thisKey = patch.getKey();
-    PatchLink link;
-
-    if (isUnifiedPatchLink(patch)) {
-      link = new PatchLink.Unified("", base, thisKey);
-    } else {
-      link = new PatchLink.SideBySide("", base, thisKey);
-    }
-
-    SafeHtmlBuilder text = new SafeHtmlBuilder();
-    text.append(before);
-    text.append(getFileNameOnly(patch));
-    text.append(after);
-    SafeHtml.set(link, text);
-    return link;
-  }
-
-  private static boolean isUnifiedPatchLink(final Patch patch) {
-    return (patch.getPatchType().equals(PatchType.BINARY)
-        || (Gerrit.isSignedIn()
-            && Gerrit.getUserPreferences().diffView()
-                .equals(DiffView.UNIFIED_DIFF)));
-  }
-
-  private static String getFileNameOnly(Patch patch) {
-    // Note: use '/' here and not File.pathSeparator since git paths
-    // are always separated by /
-    //
-    String fileName = getDisplayFileName(patch);
-    int s = fileName.lastIndexOf('/');
-    if (s >= 0) {
-      fileName = fileName.substring(s + 1);
-    }
-    return fileName;
-  }
-
-  static String getDisplayFileName(Patch patch) {
-    return getDisplayFileName(patch.getKey());
-  }
-
-  static String getDisplayFileName(Patch.Key patchKey) {
-    if (Patch.COMMIT_MSG.equals(patchKey.get())) {
-      return Util.C.commitMessage();
-    }
-    return patchKey.get();
-  }
-
-  /**
-   * Update the reviewed status for the given patch.
-   */
-  void updateReviewedStatus(Patch.Key patchKey, boolean reviewed) {
-    if (myTable != null) {
-      myTable.updateReviewedStatus(patchKey, reviewed);
-    }
-  }
-
-  ListenableAccountDiffPreference getPreferences() {
-    return listenablePrefs;
-  }
-
-  private class MyTable extends NavigationTable<Patch> {
-    private static final int C_PATH = 2;
-    private static final int C_DRAFT = 3;
-    private static final int C_SIZE = 4;
-    private static final int C_SIDEBYSIDE = 5;
-    private int activeRow = -1;
-
-    MyTable() {
-      keysNavigation.add(new PrevKeyCommand(0, 'k', Util.C.patchTablePrev()));
-      keysNavigation.add(new NextKeyCommand(0, 'j', Util.C.patchTableNext()));
-      keysNavigation.add(new OpenKeyCommand(0, 'o', Util.C.patchTableOpenDiff()));
-      keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER, Util.C
-          .patchTableOpenDiff()));
-      keysNavigation.add(new OpenUnifiedDiffKeyCommand(0, 'O', Util.C
-          .patchTableOpenUnifiedDiff()));
-
-      table.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(final ClickEvent event) {
-          final Cell cell = table.getCellForEvent(event);
-          if (cell != null && cell.getRowIndex() > 0) {
-            movePointerTo(cell.getRowIndex());
-          }
-        }
-      });
-      setSavePointerId(PatchTable.this.savePointerId);
-    }
-
-    public void addClickHandler(final ClickHandler clickHandler) {
-      table.addClickHandler(clickHandler);
-    }
-
-    void updateReviewedStatus(final Patch.Key patchKey, boolean reviewed) {
-      int idx = patchMap().get(patchKey);
-      if (0 <= idx) {
-        Patch patch = patchList.get(idx);
-        if (patch.isReviewedByCurrentUser() != reviewed) {
-          int row = idx + 1;
-          int col = C_SIDEBYSIDE + 2;
-          if (patch.getPatchType() == Patch.PatchType.BINARY) {
-            col = C_SIDEBYSIDE + 3;
-          }
-          if (reviewed) {
-            table.setWidget(row, col, new Image(Gerrit.RESOURCES.greenCheck()));
-          } else {
-            table.clearCell(row, col);
-          }
-          patch.setReviewedByCurrentUser(reviewed);
-        }
-      }
-    }
-
-    void notifyDraftDelta(final Patch.Key key, final int delta) {
-      int idx = patchMap().get(key);
-      if (0 <= idx) {
-        Patch p = patchList.get(idx);
-        p.setDraftCount(p.getDraftCount() + delta);
-        SafeHtmlBuilder m = new SafeHtmlBuilder();
-        appendCommentCount(m, p);
-        SafeHtml.set(table, idx + 1, C_DRAFT, m);
-      }
-    }
-
-    @Override
-    public void resetHtml(final SafeHtml html) {
-      super.resetHtml(html);
-    }
-
-    @Override
-    public void movePointerTo(Object oldId) {
-      super.movePointerTo(oldId);
-    }
-
-    /** Activates / Deactivates the key navigation and the highlighting of the current row for this table */
-    void setActive(boolean active) {
-      if (active) {
-        if(activeRow > 0 && getCurrentRow() != activeRow) {
-          super.movePointerTo(activeRow);
-          activeRow = -1;
-        }
-      } else {
-        if(getCurrentRow() > 0) {
-          activeRow = getCurrentRow();
-          super.movePointerTo(-1);
-        }
-      }
-      setRegisterKeys(active);
-    }
-
-    void initializeRow(int row) {
-      Patch patch = PatchTable.this.patchList.get(row - 1);
-      setRowItem(row, patch);
-
-      Widget nameCol = new PatchLink.SideBySide(getDisplayFileName(patch), base,
-          patch.getKey());
-
-      if (patch.getSourceFileName() != null) {
-        final String text;
-        if (patch.getChangeType() == Patch.ChangeType.RENAMED) {
-          text = Util.M.renamedFrom(patch.getSourceFileName());
-        } else if (patch.getChangeType() == Patch.ChangeType.COPIED) {
-          text = Util.M.copiedFrom(patch.getSourceFileName());
-        } else {
-          text = Util.M.otherFrom(patch.getSourceFileName());
-        }
-        final Label line = new Label(text);
-        line.setStyleName(Gerrit.RESOURCES.css().sourceFilePath());
-        final FlowPanel cell = new FlowPanel();
-        cell.add(nameCol);
-        cell.add(line);
-        nameCol = cell;
-      }
-      table.setWidget(row, C_PATH, nameCol);
-
-      int C_UNIFIED = C_SIDEBYSIDE + 1;
-
-      PatchLink sideBySide = new PatchLink.SideBySide(
-          Util.C.patchTableDiffSideBySide(), base, patch.getKey());
-      sideBySide.setStyleName("gwt-Anchor");
-
-      PatchLink unified = new PatchLink.Unified(Util.C.patchTableDiffUnified(),
-          base, patch.getKey());
-      unified.setStyleName("gwt-Anchor");
-
-      table.setWidget(row, C_SIDEBYSIDE, sideBySide);
-      table.setWidget(row, C_UNIFIED, unified);
-    }
-
-    void initializeLastRow(int row) {
-      Anchor sideBySide = new Anchor(Util.C.diffAllSideBySide());
-      sideBySide.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          for (Patch p : detail.getPatches()) {
-            openWindow(Dispatcher.toSideBySide(base, p.getKey()));
-          }
-        }
-      });
-      table.setWidget(row, C_SIDEBYSIDE - 2, sideBySide);
-
-      int C_UNIFIED = C_SIDEBYSIDE - 2 + 1;
-      Anchor unified = new Anchor(Util.C.diffAllUnified());
-      unified.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          for (Patch p : detail.getPatches()) {
-            openWindow(Dispatcher.toUnified(base, p.getKey()));
-          }
-        }
-      });
-      table.setWidget(row, C_UNIFIED, unified);
-    }
-
-    private void openWindow(String token) {
-      String url = Window.Location.getPath() + "#" + token;
-      Window.open(url, "_blank", null);
-    }
-
-    void appendHeader(final SafeHtmlBuilder m) {
-      m.openTr();
-
-      // Cursor
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().iconHeader());
-      m.addStyleName(Gerrit.RESOURCES.css().leftMostCell());
-      m.nbsp();
-      m.closeTd();
-
-      // Mode
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().iconHeader());
-      m.nbsp();
-      m.closeTd();
-
-      // "File path"
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().dataHeader());
-      m.append(Util.C.patchTableColumnName());
-      m.closeTd();
-
-      // "Comments"
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().dataHeader());
-      m.append(Util.C.patchTableColumnComments());
-      m.closeTd();
-
-      // "Size"
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().dataHeader());
-      m.append(Util.C.patchTableColumnSize());
-      m.closeTd();
-
-      // "Diff"
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().dataHeader());
-      m.setAttribute("colspan", 3);
-      m.append(Util.C.patchTableColumnDiff());
-      m.closeTd();
-
-      // "Reviewed"
-      if (Gerrit.isSignedIn()) {
-        m.openTd();
-        m.setStyleName(Gerrit.RESOURCES.css().iconHeader());
-        m.addStyleName(Gerrit.RESOURCES.css().dataHeader());
-        m.append(Util.C.reviewed());
-        m.closeTd();
-      }
-
-      m.closeTr();
-    }
-
-    void appendRow(final SafeHtmlBuilder m, final Patch p,
-        final boolean isReverseDiff) {
-      m.openTr();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().iconCell());
-      m.addStyleName(Gerrit.RESOURCES.css().leftMostCell());
-      m.nbsp();
-      m.closeTd();
-
-      m.openTd();
-      m.setStyleName(Gerrit.RESOURCES.css().changeTypeCell());
-      if (isReverseDiff) {
-        m.addStyleName(Gerrit.RESOURCES.css().patchCellReverseDiff());
-      }
-
-      if (Patch.COMMIT_MSG.equals(p.getFileName())) {
-        m.nbsp();
-      } else {
-        m.append(p.getChangeType().getCode());
-      }
-      m.closeTd();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().dataCell());
-      m.addStyleName(Gerrit.RESOURCES.css().filePathCell());
-      m.closeTd();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().dataCell());
-      m.addStyleName(Gerrit.RESOURCES.css().commentCell());
-      appendCommentCount(m, p);
-      m.closeTd();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().dataCell());
-
-      m.addStyleName(Gerrit.RESOURCES.css().patchSizeCell());
-      if (isReverseDiff) {
-        m.addStyleName(Gerrit.RESOURCES.css().patchCellReverseDiff());
-      }
-
-      appendSize(m, p);
-      m.closeTd();
-
-      // Diff
-      openlink(m, 2);
-      m.closeTd();
-      openlink(m, 1);
-      m.closeTd();
-
-      // Green check mark if the user is logged in and they reviewed that file
-      if (Gerrit.isSignedIn()) {
-        m.openTd();
-        m.setStyleName(Gerrit.RESOURCES.css().dataCell());
-        if (p.isReviewedByCurrentUser()) {
-          m.openDiv();
-          m.setStyleName(Gerrit.RESOURCES.css().greenCheckClass());
-          m.closeSelf();
-        }
-        m.closeTd();
-      }
-
-      m.closeTr();
-    }
-
-    void appendLastRow(final SafeHtmlBuilder m, int ins, int dels,
-        final boolean isReverseDiff) {
-      m.openTr();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().iconCell());
-      m.addStyleName(Gerrit.RESOURCES.css().noborder());
-      m.nbsp();
-      m.closeTd();
-
-      m.openTd();
-      m.setAttribute("colspan", C_SIZE - 1);
-      m.closeTd();
-
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().dataCell());
-      m.addStyleName(Gerrit.RESOURCES.css().patchSizeCell());
-      m.addStyleName(Gerrit.RESOURCES.css().leftMostCell());
-
-      if (isReverseDiff) {
-        m.addStyleName(Gerrit.RESOURCES.css().patchCellReverseDiff());
-      }
-
-      m.append(Util.M.patchTableSize_Modify(ins, dels));
-      m.closeTd();
-
-      openlink(m, 2);
-      m.closeTd();
-
-      openlink(m, 1);
-      m.closeTd();
-
-      m.closeTr();
-    }
-
-    void appendCommentCount(final SafeHtmlBuilder m, final Patch p) {
-      if (p.getCommentCount() > 0) {
-        m.append(Util.M.patchTableComments(p.getCommentCount()));
-      }
-      if (p.getDraftCount() > 0) {
-        if (p.getCommentCount() > 0) {
-          m.append(", ");
-        }
-        m.openSpan();
-        m.setStyleName(Gerrit.RESOURCES.css().drafts());
-        m.append(Util.M.patchTableDrafts(p.getDraftCount()));
-        m.closeSpan();
-      }
-    }
-
-    void appendSize(final SafeHtmlBuilder m, final Patch p) {
-      if (Patch.COMMIT_MSG.equals(p.getFileName())) {
-        m.nbsp();
-        return;
-      }
-
-      if (p.getPatchType() == PatchType.UNIFIED) {
-        int ins = p.getInsertions();
-        int dels = p.getDeletions();
-
-        switch (p.getChangeType()) {
-          case ADDED:
-            m.append(Util.M.patchTableSize_Lines(ins));
-            break;
-
-          case DELETED:
-            m.nbsp();
-            break;
-
-          case MODIFIED:
-          case COPIED:
-          case RENAMED:
-            m.append(Util.M.patchTableSize_Modify(ins, dels));
-            break;
-
-          case REWRITE:
-            break;
-        }
-      } else {
-        m.nbsp();
-      }
-    }
-
-    private void openlink(final SafeHtmlBuilder m, final int colspan) {
-      m.openTd();
-      m.addStyleName(Gerrit.RESOURCES.css().dataCell());
-      m.addStyleName(Gerrit.RESOURCES.css().diffLinkCell());
-      m.setAttribute("colspan", colspan);
-    }
-
-    @Override
-    protected Object getRowItemKey(final Patch item) {
-      return item.getKey();
-    }
-
-    @Override
-    protected void onOpenRow(final int row) {
-      Widget link = table.getWidget(row, C_PATH);
-      if (link instanceof FlowPanel) {
-        link = ((FlowPanel) link).getWidget(0);
-      }
-      if (link instanceof InlineHyperlink) {
-        ((InlineHyperlink) link).go();
-      }
-    }
-
-    private final class OpenUnifiedDiffKeyCommand extends KeyCommand {
-
-      public OpenUnifiedDiffKeyCommand(int mask, char key, String help) {
-        super(mask, key, help);
-      }
-
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        Widget link = table.getWidget(getCurrentRow(), C_PATH);
-        if (link instanceof FlowPanel) {
-          link = ((FlowPanel) link).getWidget(0);
-        }
-        if (link instanceof PatchLink.Unified) {
-          ((InlineHyperlink) link).go();
-        } else {
-          link = table.getWidget(getCurrentRow(), C_SIDEBYSIDE + 1);
-          if (link instanceof PatchLink.Unified) {
-            ((InlineHyperlink) link).go();
-          }
-        }
-      }
-    }
-  }
-
-  private final class DisplayCommand implements RepeatingCommand {
-    private final MyTable table;
-    private final List<Patch> list;
-    private boolean attached;
-    private SafeHtmlBuilder nc = new SafeHtmlBuilder();
-    private int stage = 0;
-    private int row;
-    private double start;
-    private ProgressBar meter;
-
-    private int insertions;
-    private int deletions;
-
-    private final PatchSet.Id psIdToCompareWith;
-
-    private DisplayCommand(final List<Patch> list, final PatchSet.Id psIdToCompareWith) {
-      this.table = new MyTable();
-      this.list = list;
-      this.psIdToCompareWith = psIdToCompareWith;
-    }
-
-    /**
-     * Add the files contained in the list of patches to the table, one per row.
-     */
-    @Override
-    @SuppressWarnings("fallthrough")
-    public boolean execute() {
-      final boolean attachedNow = isAttached();
-      if (!attached && attachedNow) {
-        // Remember that we have been attached at least once. If
-        // later we find we aren't attached we should stop running.
-        //
-        attached = true;
-      } else if (attached && !attachedNow) {
-        // If the user navigated away, we aren't in the DOM anymore.
-        // Don't continue to render.
-        //
-        return false;
-      }
-
-      boolean isReverseDiff = false;
-
-      if (psIdToCompareWith != null
-          && list.get(0).getKey().getParentKey().get() < psIdToCompareWith.get()) {
-        isReverseDiff = true;
-      }
-
-      start = System.currentTimeMillis();
-      switch (stage) {
-        case 0:
-          if (row == 0) {
-            table.appendHeader(nc);
-            table.appendRow(nc, list.get(row++), isReverseDiff);
-          }
-          while (row < list.size()) {
-            Patch p = list.get(row);
-            insertions += p.getInsertions();
-            deletions += p.getDeletions();
-            table.appendRow(nc, p, isReverseDiff);
-            if ((++row % 10) == 0 && longRunning()) {
-              updateMeter();
-              return true;
-            }
-          }
-          table.appendLastRow(nc, insertions, deletions, isReverseDiff);
-          table.resetHtml(nc);
-          table.initializeLastRow(row + 1);
-          nc = null;
-          stage = 1;
-          row = 0;
-
-        case 1:
-          while (row < list.size()) {
-            table.initializeRow(row + 1);
-            if ((++row % 50) == 0 && longRunning()) {
-              updateMeter();
-              return true;
-            }
-          }
-          updateMeter();
-          showTable();
-      }
-      return false;
-    }
-
-    void showTable() {
-      setMyTable(table);
-
-      if (PatchTable.this.onLoadCommand != null) {
-        PatchTable.this.onLoadCommand.execute();
-        PatchTable.this.onLoadCommand = null;
-      }
-    }
-
-    void initMeter() {
-      if (meter == null) {
-        meter = new ProgressBar(Util.M.loadingPatchSet(detail.getPatchSet().getId().get()));
-        PatchTable.this.myBody.clear();
-        PatchTable.this.myBody.add(meter);
-      }
-      updateMeter();
-    }
-
-    void updateMeter() {
-      if (meter != null) {
-        final int n = list.size();
-        meter.setValue(((100 * (stage * n + row)) / (2 * n)));
-      }
-    }
-
-    private boolean longRunning() {
-      return System.currentTimeMillis() - start > 200;
-    }
-  }
-
-
-  /**
-   * Gets the next patch
-   *
-   * @param currentIndex
-   * @param validators
-   * @param loopAround loops back around to the front and traverses if this is
-   *        true
-   * @return index of next valid patch, or -1 if no valid patches
-   */
-  int getNextPatch(int currentIndex, boolean loopAround,
-      PatchValidator... validators) {
-    return getNextPatchHelper(currentIndex, loopAround, detail.getPatches()
-        .size(), validators);
-  }
-
-  /**
-   * Helper function for getNextPatch
-   *
-   * @param currentIndex
-   * @param validators
-   * @param loopAround
-   * @param maxIndex will only traverse up to this index
-   * @return index of next valid patch, or -1 if no valid patches
-   */
-  private int getNextPatchHelper(int currentIndex, boolean loopAround,
-      int maxIndex, PatchValidator... validators) {
-    for (int i = currentIndex + 1; i < maxIndex; i++) {
-      Patch patch = detail.getPatches().get(i);
-      if (patch != null && patchIsValid(patch, validators)) {
-        return i;
-      }
-    }
-
-    if (loopAround) {
-      return getNextPatchHelper(-1, false, currentIndex, validators);
-    }
-
-    return -1;
-  }
-
-  /**
-   * @return the index to the previous patch
-   */
-  int getPreviousPatch(int currentIndex, PatchValidator... validators) {
-    for (int i = currentIndex - 1; i >= 0; i--) {
-      Patch patch = detail.getPatches().get(i);
-      if (patch != null && patchIsValid(patch, validators)) {
-        return i;
-      }
-    }
-
-    return -1;
-  }
-
-  /**
-   * Helper function that returns whether a patch is valid or not
-   *
-   * @param patch
-   * @param validators
-   * @return whether the patch is valid based on the validators
-   */
-  private boolean patchIsValid(Patch patch, PatchValidator... validators) {
-    for (PatchValidator v : validators) {
-      if (!v.isValid(patch)) {
-        return false;
-      }
-    }
-    return true;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java
index e949194..d599756 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchUtil.java
@@ -14,22 +14,9 @@
 
 package com.google.gerrit.client.patches;
 
-import com.google.gerrit.common.data.ChangeDetailService;
-import com.google.gerrit.common.data.PatchDetailService;
 import com.google.gwt.core.client.GWT;
-import com.google.gwtjsonrpc.client.JsonUtil;
 
 public class PatchUtil {
   public static final PatchConstants C = GWT.create(PatchConstants.class);
   public static final PatchMessages M = GWT.create(PatchMessages.class);
-  public static final ChangeDetailService CHANGE_SVC;
-  public static final PatchDetailService PATCH_SVC;
-
-  static {
-    CHANGE_SVC = GWT.create(ChangeDetailService.class);
-    JsonUtil.bind(CHANGE_SVC, "rpc/ChangeDetailService");
-
-    PATCH_SVC = GWT.create(PatchDetailService.class);
-    JsonUtil.bind(PATCH_SVC, "rpc/PatchDetailService");
-  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/ReviewedPanels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/ReviewedPanels.java
deleted file mode 100644
index d889c79..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/ReviewedPanels.java
+++ /dev/null
@@ -1,176 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.VoidResult;
-import com.google.gerrit.client.changes.Util;
-import com.google.gerrit.client.patches.PatchTable.PatchValidator;
-import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.ui.ChangeLink;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Anchor;
-import com.google.gwt.user.client.ui.CheckBox;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-class ReviewedPanels {
-  final FlowPanel top;
-  final FlowPanel bottom;
-
-  private Patch.Key patchKey;
-  private PatchTable fileList;
-  private InlineHyperlink reviewedLink;
-  private CheckBox checkBoxTop;
-  private CheckBox checkBoxBottom;
-
-  ReviewedPanels() {
-    this.top = new FlowPanel();
-    this.bottom = new FlowPanel();
-    this.bottom.setStyleName(Gerrit.RESOURCES.css().reviewedPanelBottom());
-  }
-
-  void populate(Patch.Key pk, PatchTable pt, int patchIndex) {
-    patchKey = pk;
-    fileList = pt;
-    reviewedLink = createReviewedLink(patchIndex);
-
-    top.clear();
-    checkBoxTop = createReviewedCheckbox();
-    top.add(checkBoxTop);
-    top.add(createReviewedAnchor());
-
-    bottom.clear();
-    checkBoxBottom = createReviewedCheckbox();
-    bottom.add(checkBoxBottom);
-    bottom.add(createReviewedAnchor());
-  }
-
-  private CheckBox createReviewedCheckbox() {
-    final CheckBox checkBox = new CheckBox(PatchUtil.C.reviewedAnd() + " ");
-    checkBox.addValueChangeHandler(new ValueChangeHandler<Boolean>() {
-      @Override
-      public void onValueChange(ValueChangeEvent<Boolean> event) {
-        final boolean value = event.getValue();
-        setReviewedByCurrentUser(value);
-        if (checkBoxTop.getValue() != value) {
-          checkBoxTop.setValue(value);
-        }
-        if (checkBoxBottom.getValue() != value) {
-          checkBoxBottom.setValue(value);
-        }
-      }
-    });
-    return checkBox;
-  }
-
-  boolean getValue() {
-    return checkBoxTop.getValue();
-  }
-
-  void setValue(final boolean value) {
-    checkBoxTop.setValue(value);
-    checkBoxBottom.setValue(value);
-  }
-
-  void setReviewedByCurrentUser(boolean reviewed) {
-    PatchSet.Id ps = patchKey.getParentKey();
-    if (ps.get() != 0) {
-      if (fileList != null) {
-        fileList.updateReviewedStatus(patchKey, reviewed);
-      }
-
-      RestApi api = new RestApi("/changes/").id(ps.getParentKey().get())
-          .view("revisions").id(ps.get())
-          .view("files").id(patchKey.getFileName())
-          .view("reviewed");
-
-      AsyncCallback<VoidResult> cb = new AsyncCallback<VoidResult>() {
-        @Override
-        public void onFailure(Throwable arg0) {
-          // nop
-        }
-
-        @Override
-        public void onSuccess(VoidResult result) {
-          // nop
-        }
-      };
-      if (reviewed) {
-        api.put(cb);
-      } else {
-        api.delete(cb);
-      }
-    }
-  }
-
-  void go() {
-    if (reviewedLink != null) {
-      setReviewedByCurrentUser(true);
-      reviewedLink.go();
-    }
-  }
-
-  private InlineHyperlink createReviewedLink(final int patchIndex) {
-    final PatchValidator unreviewedValidator = new PatchValidator() {
-      @Override
-      public boolean isValid(Patch patch) {
-        return !patch.isReviewedByCurrentUser();
-      }
-    };
-
-    InlineHyperlink reviewedLink = new ChangeLink("", patchKey.getParentKey());
-    if (fileList != null) {
-      int nextUnreviewedPatchIndex =
-          fileList.getNextPatch(patchIndex, true, unreviewedValidator,
-              fileList.PREFERENCE_VALIDATOR);
-
-      if (nextUnreviewedPatchIndex > -1) {
-        // Create invisible patch link to change page
-        reviewedLink =
-            fileList.createLink(nextUnreviewedPatchIndex, null, null);
-        reviewedLink.setText("");
-      }
-    }
-    return reviewedLink;
-  }
-
-  private Anchor createReviewedAnchor() {
-    SafeHtmlBuilder text = new SafeHtmlBuilder();
-    text.append(PatchUtil.C.next());
-    text.append(SafeHtml.asis(Util.C.nextPatchLinkIcon()));
-
-    Anchor reviewedAnchor = new Anchor("");
-    SafeHtml.set(reviewedAnchor, text);
-
-    reviewedAnchor.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        setReviewedByCurrentUser(true);
-        reviewedLink.go();
-      }
-    });
-
-    return reviewedAnchor;
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java
deleted file mode 100644
index b3617d5..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java
+++ /dev/null
@@ -1,642 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import static com.google.gerrit.client.patches.PatchLine.Type.CONTEXT;
-import static com.google.gerrit.client.patches.PatchLine.Type.DELETE;
-import static com.google.gerrit.client.patches.PatchLine.Type.INSERT;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.data.CommentDetail;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchScript.DisplayMethod;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.prettify.client.SparseHtmlFile;
-import com.google.gerrit.prettify.common.EditList;
-import com.google.gerrit.prettify.common.EditList.Hunk;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.dom.client.Element;
-import com.google.gwt.user.client.DOM;
-import com.google.gwt.user.client.Event;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.UIObject;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Iterator;
-import java.util.List;
-
-public class UnifiedDiffTable extends AbstractPatchContentTable {
-  private static final int PC = 3;
-  private static final Comparator<PatchLineComment> BY_DATE =
-      new Comparator<PatchLineComment>() {
-        @Override
-        public int compare(final PatchLineComment o1, final PatchLineComment o2) {
-          return o1.getWrittenOn().compareTo(o2.getWrittenOn());
-        }
-      };
-
-  protected boolean isFileCommentBorderRowExist;
-  // Cursors.
-  protected int rowOfTableHeaderB;
-  protected int borderRowOfFileComment;
-
-  @Override
-  protected void onCellDoubleClick(final int row, final int column) {
-    if (column > C_ARROW && getRowItem(row) instanceof PatchLine) {
-      final PatchLine pl = (PatchLine) getRowItem(row);
-      switch (pl.getType()) {
-        case DELETE:
-        case CONTEXT:
-          createCommentEditor(row + 1, PC, pl.getLineA(), (short) 0);
-          break;
-        case INSERT:
-          createCommentEditor(row + 1, PC, pl.getLineB(), (short) 1);
-          break;
-        case REPLACE:
-          break;
-      }
-    }
-  }
-
-  @Override
-  protected void updateCursor(final PatchLineComment newComment) {
-    if (newComment.getLine() == R_HEAD) {
-      final PatchSet.Id psId =
-          newComment.getKey().getParentKey().getParentKey();
-      switch (newComment.getSide()) {
-        case FILE_SIDE_A:
-          if (idSideA == null && idSideB.equals(psId)) {
-            rowOfTableHeaderB++;
-            borderRowOfFileComment++;
-            return;
-          }
-          break;
-        case FILE_SIDE_B:
-          if (idSideA != null && idSideA.equals(psId)) {
-            rowOfTableHeaderB++;
-            borderRowOfFileComment++;
-          } else if (idSideB.equals(psId)) {
-            borderRowOfFileComment++;
-          }
-      }
-    }
-  }
-
-  @Override
-  protected void onCellSingleClick(Event event, int row, int column) {
-    super.onCellSingleClick(event, row, column);
-    if (column == 1 || column == 2) {
-      if (!"".equals(table.getText(row, column))) {
-        onCellDoubleClick(row, column);
-      }
-    }
-  }
-
-  @Override
-  protected void destroyCommentRow(final int row) {
-    super.destroyCommentRow(row);
-    if (this.rowOfTableHeaderB + 1 == row && row + 1 == borderRowOfFileComment) {
-      table.removeRow(row);
-      isFileCommentBorderRowExist = false;
-    }
-  }
-
-  @Override
-  public void remove(CommentEditorPanel panel) {
-    super.remove(panel);
-    if (panel.getComment().getLine() == AbstractPatchContentTable.R_HEAD) {
-      final PatchSet.Id psId =
-          panel.getComment().getKey().getParentKey().getParentKey();
-      switch (panel.getComment().getSide()) {
-        case FILE_SIDE_A:
-          if (idSideA == null && idSideB.equals(psId)) {
-            rowOfTableHeaderB--;
-            borderRowOfFileComment--;
-            return;
-          }
-          break;
-        case FILE_SIDE_B:
-          if (idSideA != null && idSideA.equals(psId)) {
-            rowOfTableHeaderB--;
-            borderRowOfFileComment--;
-          } else if (idSideB.equals(psId)) {
-            borderRowOfFileComment--;
-          }
-      }
-    }
-  }
-
-  @Override
-  protected void onInsertComment(final PatchLine pl) {
-    final int row = getCurrentRow();
-    switch (pl.getType()) {
-      case DELETE:
-      case CONTEXT:
-        createCommentEditor(row + 1, PC, pl.getLineA(), (short) 0);
-        break;
-      case INSERT:
-        createCommentEditor(row + 1, PC, pl.getLineB(), (short) 1);
-        break;
-      case REPLACE:
-        break;
-    }
-  }
-
-  private void appendImgTag(SafeHtmlBuilder nc, String url) {
-    nc.openElement("img");
-    nc.setAttribute("src", url);
-    nc.closeElement("img");
-  }
-
-  @Override
-  protected void createFileCommentEditorOnSideA() {
-    createCommentEditor(R_HEAD + 1, PC, R_HEAD, FILE_SIDE_A);
-  }
-
-  @Override
-  protected void createFileCommentEditorOnSideB() {
-    createCommentEditor(rowOfTableHeaderB + 1, PC, R_HEAD, FILE_SIDE_B);
-    createFileCommentBorderRow();
-  }
-
-  private void populateTableHeader(final PatchScript script,
-      final PatchSetDetail detail) {
-    initHeaders(script, detail);
-    table.setWidget(R_HEAD, PC, headerSideA);
-    table.setWidget(rowOfTableHeaderB, PC, headerSideB);
-    table.getFlexCellFormatter().addStyleName(R_HEAD, PC,
-        Gerrit.RESOURCES.css().unifiedTableHeader());
-    table.getFlexCellFormatter().addStyleName(rowOfTableHeaderB, PC,
-        Gerrit.RESOURCES.css().unifiedTableHeader());
-
-    // Add icons to lineNumber column header
-    if (headerSideA.isFileOrCommitMessage()) {
-      table.setWidget(R_HEAD, 1, iconA);
-    }
-    if (headerSideB.isFileOrCommitMessage()) {
-      table.setWidget(rowOfTableHeaderB, 2, iconB);
-    }
-  }
-
-  private void allocateTableHeader(SafeHtmlBuilder nc) {
-    rowOfTableHeaderB = 1;
-    borderRowOfFileComment = 2;
-    for (int i = R_HEAD; i < borderRowOfFileComment; i++) {
-      openTableHeaderLine(nc);
-      padLineNumberOnTableHeaderForSideA(nc);
-      padLineNumberOnTableHeaderForSideB(nc);
-      nc.openTd();
-      nc.setStyleName(Gerrit.RESOURCES.css().fileLine());
-      nc.addStyleName(Gerrit.RESOURCES.css().fileColumnHeader());
-      nc.closeTd();
-      closeLine(nc);
-    }
-  }
-
-  @Override
-  protected void render(final PatchScript script, final PatchSetDetail detail) {
-    final SafeHtmlBuilder nc = new SafeHtmlBuilder();
-    allocateTableHeader(nc);
-
-    // Display the patch header
-    for (final String line : script.getPatchHeader()) {
-      appendFileHeader(nc, line);
-    }
-    final ArrayList<PatchLine> lines = new ArrayList<>();
-
-    if (hasDifferences(script)) {
-      if (script.getDisplayMethodA() == DisplayMethod.IMG
-          || script.getDisplayMethodB() == DisplayMethod.IMG) {
-        appendImageDifferences(script, nc);
-      } else if (!isDisplayBinary) {
-        appendTextDifferences(script, nc, lines);
-      }
-    } else {
-      appendNoDifferences(nc);
-    }
-
-    resetHtml(nc);
-    populateTableHeader(script, detail);
-    if (hasDifferences(script)) {
-      initScript(script);
-      if (!isDisplayBinary) {
-        int row = script.getPatchHeader().size();
-        final CellFormatter fmt = table.getCellFormatter();
-        final Iterator<PatchLine> iLine = lines.iterator();
-        while (iLine.hasNext()) {
-          final PatchLine l = iLine.next();
-          final String n;
-          switch (l.getType()) {
-            case CONTEXT:
-              n = Gerrit.RESOURCES.css().diffTextCONTEXT();
-              break;
-            case DELETE:
-              n = Gerrit.RESOURCES.css().diffTextDELETE();
-              break;
-            case INSERT:
-              n = Gerrit.RESOURCES.css().diffTextINSERT();
-              break;
-            default:
-              continue;
-          }
-          while (!fmt.getStyleName(row, PC).contains(n)) {
-            row++;
-          }
-          setRowItem(row++, l);
-        }
-      }
-    }
-  }
-
-  private void appendImageLine(final SafeHtmlBuilder nc, final String url,
-      final boolean syntaxHighlighting, final boolean isInsert) {
-    nc.openTr();
-    nc.setAttribute("valign", "center");
-    nc.setAttribute("align", "center");
-
-    nc.openTd();
-    nc.setStyleName(Gerrit.RESOURCES.css().iconCell());
-    nc.closeTd();
-
-    padLineNumberForSideA(nc);
-    padLineNumberForSideB(nc);
-
-    nc.openTd();
-    nc.setStyleName(Gerrit.RESOURCES.css().fileLine());
-    if (isInsert) {
-      setStyleInsert(nc, syntaxHighlighting);
-    } else {
-      setStyleDelete(nc, syntaxHighlighting);
-    }
-    appendImgTag(nc, url);
-    nc.closeTd();
-
-    nc.closeTr();
-  }
-
-  private void appendImageDifferences(final PatchScript script,
-      final SafeHtmlBuilder nc) {
-    final boolean syntaxHighlighting =
-        script.getDiffPrefs().syntaxHighlighting;
-    if (script.getDisplayMethodA() == DisplayMethod.IMG) {
-      final String url = getUrlA();
-      appendImageLine(nc, url, syntaxHighlighting, false);
-    }
-    if (script.getDisplayMethodB() == DisplayMethod.IMG) {
-      final String url = getUrlB();
-      appendImageLine(nc, url, syntaxHighlighting, true);
-    }
-  }
-
-  private void appendTextDifferences(final PatchScript script,
-      final SafeHtmlBuilder nc, final ArrayList<PatchLine> lines) {
-    final SparseHtmlFile a = getSparseHtmlFileA(script);
-    final SparseHtmlFile b = getSparseHtmlFileB(script);
-    final boolean syntaxHighlighting =
-        script.getDiffPrefs().syntaxHighlighting;
-    for (final EditList.Hunk hunk : script.getHunks()) {
-      appendHunkHeader(nc, hunk);
-      while (hunk.next()) {
-        if (hunk.isContextLine()) {
-          openLine(nc);
-          appendLineNumberForSideA(nc, hunk.getCurA());
-          appendLineNumberForSideB(nc, hunk.getCurB());
-          appendLineText(nc, false, CONTEXT, a, hunk.getCurA());
-          closeLine(nc);
-          hunk.incBoth();
-          lines.add(new PatchLine(CONTEXT, hunk.getCurA(), hunk.getCurB()));
-
-        } else if (hunk.isDeletedA()) {
-          openLine(nc);
-          appendLineNumberForSideA(nc, hunk.getCurA());
-          padLineNumberForSideB(nc);
-          appendLineText(nc, syntaxHighlighting, DELETE, a, hunk.getCurA());
-          closeLine(nc);
-          hunk.incA();
-          lines.add(new PatchLine(DELETE, hunk.getCurA(), -1));
-          if (a.size() == hunk.getCurA()
-              && script.getA().isMissingNewlineAtEnd()) {
-            appendNoLF(nc);
-          }
-
-        } else if (hunk.isInsertedB()) {
-          openLine(nc);
-          padLineNumberForSideA(nc);
-          appendLineNumberForSideB(nc, hunk.getCurB());
-          appendLineText(nc, syntaxHighlighting, INSERT, b, hunk.getCurB());
-          closeLine(nc);
-          hunk.incB();
-          lines.add(new PatchLine(INSERT, -1, hunk.getCurB()));
-          if (b.size() == hunk.getCurB()
-              && script.getB().isMissingNewlineAtEnd()) {
-            appendNoLF(nc);
-          }
-        }
-      }
-    }
-  }
-
-  @Override
-  public void display(final CommentDetail cd, boolean expandComments) {
-    if (cd == null || cd.isEmpty()) {
-      return;
-    }
-    setAccountInfoCache(cd.getAccounts());
-
-    final ArrayList<PatchLineComment> all = new ArrayList<>();
-    for (int row = 0; row < table.getRowCount();) {
-      final List<PatchLineComment> fora;
-      final List<PatchLineComment> forb;
-      if (row == R_HEAD) {
-        fora = cd.getForA(R_HEAD);
-        forb = cd.getForB(R_HEAD);
-        row++;
-
-        if (!fora.isEmpty()) {
-          row = insert(fora, row);
-        }
-        rowOfTableHeaderB = row;
-        borderRowOfFileComment = row + 1;
-        if (!forb.isEmpty()) {
-          row++;// Skip the Header of sideB.
-          row = insert(forb, row);
-          borderRowOfFileComment = row;
-          createFileCommentBorderRow();
-        }
-      } else if (getRowItem(row) instanceof PatchLine) {
-        final PatchLine pLine = (PatchLine) getRowItem(row);
-        fora = cd.getForA(pLine.getLineA());
-        forb = cd.getForB(pLine.getLineB());
-        row++;
-
-        if (!fora.isEmpty() && !forb.isEmpty()) {
-          all.clear();
-          all.addAll(fora);
-          all.addAll(forb);
-          Collections.sort(all, BY_DATE);
-          row = insert(all, row);
-
-        } else if (!fora.isEmpty()) {
-          row = insert(fora, row);
-
-        } else if (!forb.isEmpty()) {
-          row = insert(forb, row);
-        }
-      } else {
-        row++;
-        continue;
-      }
-    }
-  }
-
-  private void defaultStyle(final int row, final CellFormatter fmt) {
-    fmt.addStyleName(row, PC - 2, Gerrit.RESOURCES.css().lineNumber());
-    fmt.addStyleName(row, PC - 2, Gerrit.RESOURCES.css().rightBorder());
-    fmt.addStyleName(row, PC - 1, Gerrit.RESOURCES.css().lineNumber());
-    fmt.addStyleName(row, PC, Gerrit.RESOURCES.css().diffText());
-  }
-
-  @Override
-  protected void insertRow(final int row) {
-    super.insertRow(row);
-    final CellFormatter fmt = table.getCellFormatter();
-    defaultStyle(row, fmt);
-  }
-
-  private int insert(final List<PatchLineComment> in, int row) {
-    for (Iterator<PatchLineComment> ci = in.iterator(); ci.hasNext();) {
-      final PatchLineComment c = ci.next();
-      if (c.getLine() == R_HEAD) {
-        insertFileCommentRow(row);
-      } else {
-        insertRow(row);
-      }
-      bindComment(row, PC, c, !ci.hasNext());
-      row++;
-    }
-    return row;
-  }
-
-  @Override
-  protected void insertFileCommentRow(final int row) {
-    table.insertRow(row);
-    final CellFormatter fmt = table.getCellFormatter();
-
-    fmt.addStyleName(row, C_ARROW, //
-        Gerrit.RESOURCES.css().iconCellOfFileCommentRow());
-    defaultStyle(row, fmt);
-
-    fmt.addStyleName(row, C_ARROW, //
-        Gerrit.RESOURCES.css().cellsNextToFileComment());
-    fmt.addStyleName(row, PC - 2, //
-        Gerrit.RESOURCES.css().cellsNextToFileComment());
-    fmt.addStyleName(row, PC - 1, //
-        Gerrit.RESOURCES.css().cellsNextToFileComment());
-  }
-
-  private void createFileCommentBorderRow() {
-    if (!isFileCommentBorderRowExist) {
-      isFileCommentBorderRowExist = true;
-      table.insertRow(borderRowOfFileComment);
-      final CellFormatter fmt = table.getCellFormatter();
-      fmt.addStyleName(borderRowOfFileComment, C_ARROW, //
-          Gerrit.RESOURCES.css().iconCellOfFileCommentRow());
-      defaultStyle(borderRowOfFileComment, fmt);
-
-      final Element iconCell =
-          fmt.getElement(borderRowOfFileComment, C_ARROW);
-      UIObject.setStyleName(DOM.getParent(iconCell), //
-          Gerrit.RESOURCES.css().fileCommentBorder(), true);
-    }
-  }
-
-  private void appendFileHeader(final SafeHtmlBuilder m, final String line) {
-    openLine(m);
-    padLineNumberForSideA(m);
-    padLineNumberForSideB(m);
-
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().fileLine());
-    m.addStyleName(Gerrit.RESOURCES.css().diffText());
-    m.addStyleName(Gerrit.RESOURCES.css().diffTextFileHeader());
-    m.append(line);
-    m.closeTd();
-    closeLine(m);
-  }
-
-  private void appendHunkHeader(final SafeHtmlBuilder m, final Hunk hunk) {
-    openLine(m);
-    padLineNumberForSideA(m);
-    padLineNumberForSideB(m);
-
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().fileLine());
-    m.addStyleName(Gerrit.RESOURCES.css().diffText());
-    m.addStyleName(Gerrit.RESOURCES.css().diffTextHunkHeader());
-    m.append("@@ -");
-    appendRange(m, hunk.getCurA() + 1, hunk.getEndA() - hunk.getCurA());
-    m.append(" +");
-    appendRange(m, hunk.getCurB() + 1, hunk.getEndB() - hunk.getCurB());
-    m.append(" @@");
-    m.closeTd();
-
-    closeLine(m);
-  }
-
-  private void appendRange(final SafeHtmlBuilder m, final int begin,
-      final int cnt) {
-    switch (cnt) {
-      case 0:
-        m.append(begin - 1);
-        m.append(",0");
-        break;
-
-      case 1:
-        m.append(begin);
-        break;
-
-      default:
-        m.append(begin);
-        m.append(',');
-        m.append(cnt);
-        break;
-    }
-  }
-
-  private void setStyleDelete(final SafeHtmlBuilder m,
-      boolean syntaxHighlighting) {
-    m.addStyleName(Gerrit.RESOURCES.css().diffTextDELETE());
-    if (syntaxHighlighting) {
-      m.addStyleName(Gerrit.RESOURCES.css().fileLineDELETE());
-    }
-  }
-
-  private void setStyleInsert(final SafeHtmlBuilder m,
-      boolean syntaxHighlighting) {
-    m.addStyleName(Gerrit.RESOURCES.css().diffTextINSERT());
-    if (syntaxHighlighting) {
-      m.addStyleName(Gerrit.RESOURCES.css().fileLineINSERT());
-    }
-  }
-
-  private void appendLineText(final SafeHtmlBuilder m,
-      boolean syntaxHighlighting, final PatchLine.Type type,
-      final SparseHtmlFile src, final int i) {
-    final SafeHtml text = src.getSafeHtmlLine(i);
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().fileLine());
-    m.addStyleName(Gerrit.RESOURCES.css().diffText());
-    switch (type) {
-      case CONTEXT:
-        m.addStyleName(Gerrit.RESOURCES.css().diffTextCONTEXT());
-        m.nbsp();
-        m.append(text);
-        break;
-      case DELETE:
-        setStyleDelete(m, syntaxHighlighting);
-        m.append("-");
-        m.append(text);
-        break;
-      case INSERT:
-        setStyleInsert(m, syntaxHighlighting);
-        m.append("+");
-        m.append(text);
-        break;
-      case REPLACE:
-        break;
-    }
-    m.closeTd();
-  }
-
-  private void appendNoLF(final SafeHtmlBuilder m) {
-    openLine(m);
-    padLineNumberForSideA(m);
-    padLineNumberForSideB(m);
-    m.openTd();
-    m.addStyleName(Gerrit.RESOURCES.css().diffText());
-    m.addStyleName(Gerrit.RESOURCES.css().diffTextNoLF());
-    m.append("\\ No newline at end of file");
-    m.closeTd();
-    closeLine(m);
-  }
-
-  private void openLine(final SafeHtmlBuilder m) {
-    m.openTr();
-    m.setAttribute("valign", "top");
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().iconCell());
-    m.closeTd();
-  }
-
-  private void openTableHeaderLine(final SafeHtmlBuilder m) {
-    m.openTr();
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().iconCell());
-    m.addStyleName(Gerrit.RESOURCES.css().fileColumnHeader());
-    m.closeTd();
-  }
-
-  private void closeLine(final SafeHtmlBuilder m) {
-    m.closeTr();
-  }
-
-  private void padLineNumberForSideB(final SafeHtmlBuilder m) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.closeTd();
-  }
-
-  private void padLineNumberForSideA(final SafeHtmlBuilder m) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.addStyleName(Gerrit.RESOURCES.css().rightBorder());
-    m.closeTd();
-  }
-
-  private void appendLineNumberForSideB(final SafeHtmlBuilder m, final int idx) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.append(SafeHtml.asis("<a href=\"javascript:void(0)\">"+ (idx + 1) + "</a>"));
-    m.closeTd();
-  }
-
-  private void appendLineNumberForSideA(final SafeHtmlBuilder m, final int idx) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.addStyleName(Gerrit.RESOURCES.css().rightBorder());
-    m.append(SafeHtml.asis("<a href=\"javascript:void(0)\">"+ (idx + 1) + "</a>"));
-    m.closeTd();
-  }
-
-  private void padLineNumberOnTableHeaderForSideB(final SafeHtmlBuilder m) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.addStyleName(Gerrit.RESOURCES.css().fileColumnHeader());
-    m.closeTd();
-  }
-
-  private void padLineNumberOnTableHeaderForSideA(final SafeHtmlBuilder m) {
-    m.openTd();
-    m.setStyleName(Gerrit.RESOURCES.css().lineNumber());
-    m.addStyleName(Gerrit.RESOURCES.css().fileColumnHeader());
-    m.addStyleName(Gerrit.RESOURCES.css().rightBorder());
-    m.closeTd();
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedPatchScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedPatchScreen.java
deleted file mode 100644
index e587aac..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedPatchScreen.java
+++ /dev/null
@@ -1,564 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.RpcStatus;
-import com.google.gerrit.client.diff.DiffApi;
-import com.google.gerrit.client.diff.DiffInfo;
-import com.google.gerrit.client.info.WebLinkInfo;
-import com.google.gerrit.client.projects.ConfigInfoCache;
-import com.google.gerrit.client.rpc.CallbackGroup;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
-import com.google.gerrit.client.ui.CommentLinkProcessor;
-import com.google.gerrit.client.ui.InlineHyperlink;
-import com.google.gerrit.client.ui.ListenableAccountDiffPreference;
-import com.google.gerrit.client.ui.Screen;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.prettify.client.ClientSideFormatter;
-import com.google.gerrit.prettify.client.PrettyFactory;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.ImageResourceRenderer;
-import com.google.gwtexpui.globalkey.client.GlobalKey;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtexpui.globalkey.client.KeyCommandSet;
-
-import java.util.Collections;
-import java.util.List;
-
-public class UnifiedPatchScreen extends Screen implements
-    CommentEditorContainer {
-  static final PrettyFactory PRETTY = ClientSideFormatter.FACTORY;
-  static final short LARGE_FILE_CONTEXT = 100;
-
-  /**
-   * What should be displayed in the top of the screen
-   */
-  public static enum TopView {
-    MAIN, COMMIT, PREFERENCES, PATCH_SETS, FILES
-  }
-
-  protected final Patch.Key patchKey;
-  protected PatchSetDetail patchSetDetail;
-  protected PatchTable fileList;
-  protected PatchSet.Id idSideA;
-  protected PatchSet.Id idSideB;
-  protected PatchScriptSettingsPanel settingsPanel;
-  protected TopView topView;
-  protected CommentLinkProcessor commentLinkProcessor;
-
-  private ReviewedPanels reviewedPanels;
-  private HistoryTable historyTable;
-  private FlowPanel topPanel;
-  private FlowPanel contentPanel;
-  private UnifiedDiffTable contentTable;
-  private CommitMessageBlock commitMessageBlock;
-  private NavLinks topNav;
-  private NavLinks bottomNav;
-
-  private int rpcSequence;
-  private PatchScript lastScript;
-
-  /** The index of the file we are currently looking at among the fileList */
-  private int patchIndex;
-  private ListenableAccountDiffPreference prefs;
-  private HandlerRegistration prefsHandler;
-
-  /** Keys that cause an action on this screen */
-  private KeyCommandSet keysNavigation;
-  private KeyCommandSet keysAction;
-  private HandlerRegistration regNavigation;
-  private HandlerRegistration regAction;
-  private boolean intralineFailure;
-  private boolean intralineTimeout;
-
-  public UnifiedPatchScreen(Patch.Key id, TopView top, PatchSet.Id baseId) {
-    patchKey = id;
-    topView = top;
-
-    idSideA = baseId; // null here means we're diff'ing from the Base
-    idSideB = id.getParentKey();
-
-    prefs = fileList != null
-        ? fileList.getPreferences()
-        : new ListenableAccountDiffPreference();
-    if (Gerrit.isSignedIn()) {
-      prefs.reset();
-    }
-    reviewedPanels = new ReviewedPanels();
-    settingsPanel = new PatchScriptSettingsPanel(prefs);
-  }
-
-  @Override
-  public void notifyDraftDelta(int delta) {
-    lastScript = null;
-  }
-
-  @Override
-  public void remove(CommentEditorPanel panel) {
-    lastScript = null;
-  }
-
-  private void update(DiffPreferencesInfo dp) {
-    // Did the user just turn on auto-review?
-    if (!reviewedPanels.getValue() && prefs.getOld().manualReview
-        && !dp.manualReview) {
-      reviewedPanels.setValue(true);
-      reviewedPanels.setReviewedByCurrentUser(true);
-    }
-
-    if (lastScript != null && canReuse(dp, lastScript)) {
-      lastScript.setDiffPrefs(dp);
-      RpcStatus.INSTANCE.onRpcStart(null);
-      settingsPanel.setEnabled(false);
-      Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-        @Override
-        public void execute() {
-          try {
-            onResult(lastScript, false /* not the first time */);
-          } finally {
-            RpcStatus.INSTANCE.onRpcComplete(null);
-          }
-        }
-      });
-    } else {
-      refresh(false);
-    }
-  }
-
-  private boolean canReuse(DiffPreferencesInfo dp, PatchScript last) {
-    if (last.getDiffPrefs().ignoreWhitespace != dp.ignoreWhitespace) {
-      // Whitespace ignore setting requires server computation.
-      return false;
-    }
-
-    final int ctx = dp.context;
-    if (ctx == DiffPreferencesInfo.WHOLE_FILE_CONTEXT
-        && !last.getA().isWholeFile()) {
-      // We don't have the entire file here, so we can't render it.
-      return false;
-    }
-
-    if (last.getDiffPrefs().context < ctx && !last.getA().isWholeFile()) {
-      // We don't have sufficient context.
-      return false;
-    }
-
-    if (dp.syntaxHighlighting && !last.getA().isWholeFile()) {
-      // We need the whole file to syntax highlight accurately.
-      return false;
-    }
-
-    return true;
-  }
-
-  @Override
-  protected void onInitUI() {
-    super.onInitUI();
-
-    if (Gerrit.isSignedIn()) {
-      setTitleFarEast(reviewedPanels.top);
-    }
-
-    keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    keysNavigation.add(new UpToChangeCommand(patchKey.getParentKey(), 0, 'u'));
-    keysNavigation.add(new FileListCmd(0, 'f', PatchUtil.C.fileList()));
-
-    if (Gerrit.isSignedIn()) {
-      keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-      keysAction
-          .add(new ToggleReviewedCmd(0, 'm', PatchUtil.C.toggleReviewed()));
-      keysAction.add(new MarkAsReviewedAndGoToNextCmd(0, 'M', PatchUtil.C
-          .markAsReviewedAndGoToNext()));
-    }
-
-    historyTable = new HistoryTable(this);
-
-    commitMessageBlock = new CommitMessageBlock();
-
-    topPanel = new FlowPanel();
-    add(topPanel);
-
-    contentTable = new UnifiedDiffTable();
-    contentTable.fileList = fileList;
-
-    topNav = new NavLinks(keysNavigation, patchKey.getParentKey());
-    bottomNav = new NavLinks(null, patchKey.getParentKey());
-
-    add(topNav);
-    contentPanel = new FlowPanel();
-    contentPanel.setStyleName(Gerrit.RESOURCES.css().unifiedTable());
-
-    contentPanel.add(contentTable);
-    add(contentPanel);
-    add(bottomNav);
-    if (Gerrit.isSignedIn()) {
-      add(reviewedPanels.bottom);
-    }
-
-    if (fileList != null) {
-      displayNav();
-    }
-  }
-
-  private void displayNav() {
-    DiffApi.diff(idSideB, patchKey.getFileName())
-      .base(idSideA)
-      .webLinksOnly()
-      .get(new GerritCallback<DiffInfo>() {
-        @Override
-        public void onSuccess(DiffInfo diffInfo) {
-          topNav.display(patchIndex, fileList,
-              getLinks(), getWebLinks(diffInfo));
-          bottomNav.display(patchIndex, fileList,
-              getLinks(), getWebLinks(diffInfo));
-        }
-      });
-  }
-
-  private List<InlineHyperlink> getLinks() {
-    InlineHyperlink toSideBySideDiffLink = new InlineHyperlink();
-    toSideBySideDiffLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
-    toSideBySideDiffLink.setTargetHistoryToken(getSideBySideDiffUrl());
-    toSideBySideDiffLink.setTitle(PatchUtil.C.sideBySideDiff());
-    return Collections.singletonList(toSideBySideDiffLink);
-  }
-
-  private List<WebLinkInfo> getWebLinks(DiffInfo diffInfo) {
-    return diffInfo.unifiedWebLinks();
-  }
-
-  private String getSideBySideDiffUrl() {
-    return Dispatcher.toPatch("sidebyside", idSideA,
-        new Patch.Key(idSideB, patchKey.getFileName()));
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-
-    if (patchSetDetail == null) {
-      PatchUtil.CHANGE_SVC.patchSetDetail(idSideB,
-          new GerritCallback<PatchSetDetail>() {
-            @Override
-            public void onSuccess(PatchSetDetail result) {
-              patchSetDetail = result;
-              if (fileList == null) {
-                fileList = new PatchTable(prefs);
-                fileList.display(idSideA, result);
-                patchIndex = fileList.indexOf(patchKey);
-              }
-              refresh(true);
-            }
-          });
-    } else {
-      refresh(true);
-    }
-  }
-
-  @Override
-  protected void onUnload() {
-    if (prefsHandler != null) {
-      prefsHandler.removeHandler();
-      prefsHandler = null;
-    }
-    if (regNavigation != null) {
-      regNavigation.removeHandler();
-      regNavigation = null;
-    }
-    if (regAction != null) {
-      regAction.removeHandler();
-      regAction = null;
-    }
-    super.onUnload();
-  }
-
-  @Override
-  public void registerKeys() {
-    super.registerKeys();
-    contentTable.setRegisterKeys(contentTable.isVisible());
-    if (regNavigation != null) {
-      regNavigation.removeHandler();
-      regNavigation = null;
-    }
-    regNavigation = GlobalKey.add(this, keysNavigation);
-    if (regAction != null) {
-      regAction.removeHandler();
-      regAction = null;
-    }
-    if (keysAction != null) {
-      regAction = GlobalKey.add(this, keysAction);
-    }
-  }
-
-  public PatchSet.Id getSideA() {
-    return idSideA;
-  }
-
-  public Patch.Key getPatchKey() {
-    return patchKey;
-  }
-
-  public int getPatchIndex() {
-    return patchIndex;
-  }
-
-  public PatchSetDetail getPatchSetDetail() {
-    return patchSetDetail;
-  }
-
-  public PatchTable getFileList() {
-    return fileList;
-  }
-
-  public TopView getTopView() {
-    return topView;
-  }
-
-  protected void refresh(final boolean isFirst) {
-    final int rpcseq = ++rpcSequence;
-    lastScript = null;
-    settingsPanel.setEnabled(false);
-    reviewedPanels.populate(patchKey, fileList, patchIndex);
-    if (isFirst && fileList != null && fileList.isLoaded()) {
-      fileList.movePointerTo(patchKey);
-    }
-
-    CallbackGroup cb = new CallbackGroup();
-    ConfigInfoCache.get(patchSetDetail.getProject(),
-        cb.add(new AsyncCallback<ConfigInfoCache.Entry>() {
-          @Override
-          public void onSuccess(ConfigInfoCache.Entry result) {
-            commentLinkProcessor = result.getCommentLinkProcessor();
-            contentTable.setCommentLinkProcessor(commentLinkProcessor);
-            setTheme(result.getTheme());
-          }
-
-          @Override
-          public void onFailure(Throwable caught) {
-            // Handled by ScreenLoadCallback.onFailure.
-          }
-        }));
-    PatchUtil.PATCH_SVC.patchScript(patchKey, idSideA, idSideB,
-        settingsPanel.getValue(), cb.addFinal(
-            new ScreenLoadCallback<PatchScript>(this) {
-              @Override
-              protected void preDisplay(final PatchScript result) {
-                if (rpcSequence == rpcseq) {
-                  onResult(result, isFirst);
-                }
-              }
-
-              @Override
-              public void onFailure(final Throwable caught) {
-                if (rpcSequence == rpcseq) {
-                  settingsPanel.setEnabled(true);
-                  super.onFailure(caught);
-                }
-              }
-        }));
-  }
-
-  private void onResult(final PatchScript script, final boolean isFirst) {
-    final String path = PatchTable.getDisplayFileName(patchKey);
-    String fileName = path;
-    final int last = fileName.lastIndexOf('/');
-    if (last >= 0) {
-      fileName = fileName.substring(last + 1);
-    }
-
-    setWindowTitle(fileName);
-    setPageTitle(path);
-
-    if (idSideB.equals(patchSetDetail.getPatchSet().getId())) {
-      commitMessageBlock.setVisible(true);
-      commitMessageBlock.display(patchSetDetail.getInfo().getMessage(),
-          commentLinkProcessor);
-    } else {
-      commitMessageBlock.setVisible(false);
-      PatchUtil.CHANGE_SVC.patchSetDetail(idSideB,
-          new GerritCallback<PatchSetDetail>() {
-            @Override
-            public void onSuccess(PatchSetDetail result) {
-              commitMessageBlock.setVisible(true);
-              commitMessageBlock.display(result.getInfo().getMessage(),
-                  commentLinkProcessor);
-            }
-          });
-    }
-
-    historyTable.display(script.getHistory());
-
-    for (Patch p : patchSetDetail.getPatches()) {
-      if (p.getKey().equals(patchKey)) {
-        if (p.getPatchType().equals(Patch.PatchType.BINARY)) {
-          contentTable.isDisplayBinary = true;
-        }
-        break;
-      }
-    }
-
-    if (script.isHugeFile()) {
-      DiffPreferencesInfo dp = script.getDiffPrefs();
-      int context = dp.context;
-      if (context == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-        context = Short.MAX_VALUE;
-      } else if (context > Short.MAX_VALUE) {
-        context = Short.MAX_VALUE;
-      }
-      dp.context = Math.min(context, LARGE_FILE_CONTEXT);
-      dp.syntaxHighlighting = false;
-      script.setDiffPrefs(dp);
-    }
-
-    contentTable.display(patchKey, idSideA, idSideB, script, patchSetDetail);
-    contentTable.display(script.getCommentDetail(), script.isExpandAllComments());
-    contentTable.finishDisplay();
-    contentTable.setRegisterKeys(isCurrentView());
-
-    settingsPanel.setEnableSmallFileFeatures(!script.isHugeFile());
-    settingsPanel.setEnableIntralineDifference(script.hasIntralineDifference());
-    settingsPanel.setEnabled(true);
-    lastScript = script;
-
-    if (fileList != null) {
-      displayNav();
-    }
-
-    if (Gerrit.isSignedIn()) {
-      boolean isReviewed = false;
-      if (isFirst && !prefs.get().manualReview) {
-        isReviewed = true;
-        reviewedPanels.setReviewedByCurrentUser(isReviewed);
-      } else {
-        for (Patch p : patchSetDetail.getPatches()) {
-          if (p.getKey().equals(patchKey)) {
-            isReviewed = p.isReviewedByCurrentUser();
-            break;
-          }
-        }
-      }
-      reviewedPanels.setValue(isReviewed);
-    }
-
-    intralineFailure = isFirst && script.hasIntralineFailure();
-    intralineTimeout = isFirst && script.hasIntralineTimeout();
-  }
-
-  @Override
-  public void onShowView() {
-    super.onShowView();
-    if (prefsHandler == null) {
-      prefsHandler = prefs.addValueChangeHandler(
-          new ValueChangeHandler<DiffPreferencesInfo>() {
-            @Override
-            public void onValueChange(ValueChangeEvent<DiffPreferencesInfo> event) {
-              update(event.getValue());
-            }
-          });
-    }
-    if (intralineFailure) {
-      intralineFailure = false;
-      new ErrorDialog(PatchUtil.C.intralineFailure()).show();
-    } else if (intralineTimeout) {
-      intralineTimeout = false;
-      new ErrorDialog(PatchUtil.C.intralineTimeout()).setText(
-          Gerrit.C.warnTitle()).show();
-    }
-    if (topView != null && prefs.get().retainHeader) {
-      setTopView(topView);
-    }
-  }
-
-  public void setTopView(TopView tv) {
-    topView = tv;
-    topPanel.clear();
-    switch(tv) {
-      case COMMIT:      topPanel.add(commitMessageBlock);
-        break;
-      case PREFERENCES: topPanel.add(settingsPanel);
-        break;
-      case PATCH_SETS:  topPanel.add(historyTable);
-        break;
-      case FILES:       topPanel.add(fileList);
-        break;
-      case MAIN:
-        break;
-    }
-  }
-
-  public class FileListCmd extends KeyCommand {
-    public FileListCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      if (fileList == null || fileList.isAttached()) {
-        final PatchSet.Id psid = patchKey.getParentKey();
-        fileList = new PatchTable(prefs);
-        fileList.setSavePointerId("PatchTable " + psid);
-        PatchUtil.CHANGE_SVC.patchSetDetail(psid,
-            new GerritCallback<PatchSetDetail>() {
-              @Override
-              public void onSuccess(final PatchSetDetail result) {
-                fileList.display(idSideA, result);
-              }
-            });
-      }
-
-      final PatchBrowserPopup p = new PatchBrowserPopup(patchKey, fileList);
-      p.open();
-    }
-  }
-
-  public class ToggleReviewedCmd extends KeyCommand {
-    public ToggleReviewedCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      final boolean isReviewed = !reviewedPanels.getValue();
-      reviewedPanels.setValue(isReviewed);
-      reviewedPanels.setReviewedByCurrentUser(isReviewed);
-    }
-  }
-
-  public class MarkAsReviewedAndGoToNextCmd extends KeyCommand {
-    public MarkAsReviewedAndGoToNextCmd(int mask, int key, String help) {
-      super(mask, key, help);
-    }
-
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      reviewedPanels.go();
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UpToChangeCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UpToChangeCommand.java
deleted file mode 100644
index 970e33d3..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UpToChangeCommand.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.patches;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwtexpui.globalkey.client.KeyCommand;
-
-class UpToChangeCommand extends KeyCommand {
-  private final PatchSet.Id patchSetId;
-
-  UpToChangeCommand(PatchSet.Id patchSetId, int mask, int key) {
-    super(mask, key, PatchUtil.C.upToChange());
-    this.patchSetId = patchSetId;
-  }
-
-  @Override
-  public void onKeyPress(final KeyPressEvent event) {
-    Gerrit.display(PageLinks.toChange(patchSetId));
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 322a354..9751b3b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -56,6 +56,9 @@
   public final native InheritedBooleanInfo requireSignedPush()
   /*-{ return this.require_signed_push; }-*/;
 
+  public final native InheritedBooleanInfo rejectImplicitMerges()
+  /*-{ return this.reject_implicit_merges; }-*/;
+
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
@@ -69,7 +72,7 @@
   public final native NativeMap<ActionInfo> actions()
   /*-{ return this.actions; }-*/;
 
-  private final native String submitTypeRaw()
+  private native String submitTypeRaw()
   /*-{ return this.submit_type }-*/;
 
   public final ProjectState state() {
@@ -78,13 +81,13 @@
     }
     return ProjectState.valueOf(stateRaw());
   }
-  private final native String stateRaw()
+  private native String stateRaw()
   /*-{ return this.state }-*/;
 
   public final native MaxObjectSizeLimitInfo maxObjectSizeLimit()
   /*-{ return this.max_object_size_limit; }-*/;
 
-  private final native NativeMap<CommentLinkInfo> commentlinks0()
+  private native NativeMap<CommentLinkInfo> commentlinks0()
   /*-{ return this.commentlinks; }-*/;
   final List<FindReplace> commentlinks() {
     JsArray<CommentLinkInfo> cls = commentlinks0().values();
@@ -143,7 +146,7 @@
     public final InheritableBoolean configuredValue() {
       return InheritableBoolean.valueOf(configuredValueRaw());
     }
-    private final native String configuredValueRaw()
+    private native String configuredValueRaw()
     /*-{ return this.configured_value }-*/;
 
     public final void setConfiguredValue(InheritableBoolean v) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index fffdd3f..10932bc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -118,6 +118,7 @@
       InheritableBoolean requireChangeId,
       InheritableBoolean enableSignedPush,
       InheritableBoolean requireSignedPush,
+      InheritableBoolean rejectImplicitMerges,
       String maxObjectSizeLimit,
       SubmitType submitType, ProjectState state,
       Map<String, Map<String, ConfigParameterValue>> pluginConfigValues,
@@ -135,6 +136,7 @@
     if (requireSignedPush != null) {
       in.setRequireSignedPush(requireSignedPush);
     }
+    in.setRejectImplicitMerges(rejectImplicitMerges);
     in.setMaxObjectSizeLimit(maxObjectSizeLimit);
     in.setSubmitType(submitType);
     in.setState(state);
@@ -228,58 +230,64 @@
     final void setUseContributorAgreements(InheritableBoolean v) {
       setUseContributorAgreementsRaw(v.name());
     }
-    private final native void setUseContributorAgreementsRaw(String v)
+    private native void setUseContributorAgreementsRaw(String v)
     /*-{ if(v)this.use_contributor_agreements=v; }-*/;
 
     final void setUseContentMerge(InheritableBoolean v) {
       setUseContentMergeRaw(v.name());
     }
-    private final native void setUseContentMergeRaw(String v)
+    private native void setUseContentMergeRaw(String v)
     /*-{ if(v)this.use_content_merge=v; }-*/;
 
     final void setUseSignedOffBy(InheritableBoolean v) {
       setUseSignedOffByRaw(v.name());
     }
-    private final native void setUseSignedOffByRaw(String v)
+    private native void setUseSignedOffByRaw(String v)
     /*-{ if(v)this.use_signed_off_by=v; }-*/;
 
     final void setRequireChangeId(InheritableBoolean v) {
       setRequireChangeIdRaw(v.name());
     }
-    private final native void setRequireChangeIdRaw(String v)
+    private native void setRequireChangeIdRaw(String v)
     /*-{ if(v)this.require_change_id=v; }-*/;
 
     final void setCreateNewChangeForAllNotInTarget(InheritableBoolean v) {
       setCreateNewChangeForAllNotInTargetRaw(v.name());
     }
-    private final native void setCreateNewChangeForAllNotInTargetRaw(String v)
+    private native void setCreateNewChangeForAllNotInTargetRaw(String v)
     /*-{ if(v)this.create_new_change_for_all_not_in_target=v; }-*/;
 
     final void setEnableSignedPush(InheritableBoolean v) {
       setEnableSignedPushRaw(v.name());
     }
-    private final native void setEnableSignedPushRaw(String v)
+    private native void setEnableSignedPushRaw(String v)
     /*-{ if(v)this.enable_signed_push=v; }-*/;
 
     final void setRequireSignedPush(InheritableBoolean v) {
       setRequireSignedPushRaw(v.name());
     }
-    private final native void setRequireSignedPushRaw(String v)
+    private native void setRequireSignedPushRaw(String v)
     /*-{ if(v)this.require_signed_push=v; }-*/;
 
+    final void setRejectImplicitMerges(InheritableBoolean v) {
+      setRejectImplicitMergesRaw(v.name());
+    }
+    private native void setRejectImplicitMergesRaw(String v)
+    /*-{ if(v)this.reject_implicit_merges=v; }-*/;
+
     final native void setMaxObjectSizeLimit(String l)
     /*-{ if(l)this.max_object_size_limit=l; }-*/;
 
     final void setSubmitType(SubmitType t) {
       setSubmitTypeRaw(t.name());
     }
-    private final native void setSubmitTypeRaw(String t)
+    private native void setSubmitTypeRaw(String t)
     /*-{ if(t)this.submit_type=t; }-*/;
 
     final void setState(ProjectState s) {
       setStateRaw(s.name());
     }
-    private final native void setStateRaw(String s)
+    private native void setStateRaw(String s)
     /*-{ if(s)this.state=s; }-*/;
 
     final void setPluginConfigValues(Map<String, Map<String, ConfigParameterValue>> pluginConfigValues) {
@@ -295,7 +303,7 @@
         setPluginConfigValuesRaw(configValues);
       }
     }
-    private final native void setPluginConfigValuesRaw(NativeMap<ConfigParameterValueMap> v)
+    private native void setPluginConfigValuesRaw(NativeMap<ConfigParameterValueMap> v)
     /*-{ this.plugin_config_values=v; }-*/;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
index 00a4034..eed9d1d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
@@ -36,7 +36,7 @@
     return ProjectState.valueOf(getStringState());
   }
 
-  private final native String getStringState() /*-{ return this.state; }-*/;
+  private native String getStringState() /*-{ return this.state; }-*/;
 
   @Override
   public final String getDisplayString() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
index 053dbd3..9801d60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
@@ -27,4 +27,4 @@
 
   protected RefInfo() {
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
index 071ca72..009deaf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
@@ -78,23 +78,7 @@
 
   public <T> HttpCallback<T> add(HttpCallback<T> cb) {
     checkFinalAdded();
-    if (failed) {
-      cb.onFailure(failedThrowable);
-      return new HttpCallback<T>() {
-        @Override
-        public void onSuccess(HttpResponse<T> result) {
-        }
-
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      };
-    }
-
-    HttpCallbackImpl<T> w = new HttpCallbackImpl<>(cb);
-    callbacks.add(w);
-    remaining.add(w);
-    return w;
+    return handleAdd(cb);
   }
 
   public <T> Callback<T> addFinal(final AsyncCallback<T> cb) {
@@ -103,6 +87,12 @@
     return handleAdd(cb);
   }
 
+  public <T> HttpCallback<T> addFinal(final HttpCallback<T> cb) {
+    checkFinalAdded();
+    finalAdded = true;
+    return handleAdd(cb);
+  }
+
   public void done() {
     finalAdded = true;
     apply();
@@ -161,6 +151,26 @@
     return wrapper;
   }
 
+  private <T> HttpCallback<T> handleAdd(HttpCallback<T> cb) {
+    if (failed) {
+      cb.onFailure(failedThrowable);
+      return new HttpCallback<T>() {
+        @Override
+        public void onSuccess(HttpResponse<T> result) {
+        }
+
+        @Override
+        public void onFailure(Throwable caught) {
+        }
+      };
+    }
+
+    HttpCallbackImpl<T> w = new HttpCallbackImpl<>(cb);
+    callbacks.add(w);
+    remaining.add(w);
+    return w;
+  }
+
   private void checkFinalAdded() {
     if (finalAdded) {
       throw new IllegalStateException("final callback already added");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
index bccd237..cd44fac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.NotSignedInDialog;
-import com.google.gerrit.common.errors.InactiveAccountException;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.common.errors.NoSuchEntityException;
@@ -38,18 +37,10 @@
   }
 
   public static void showFailure(Throwable caught) {
-    if (isNotSignedIn(caught) || isInvalidXSRF(caught)) {
+    if (isSigninFailure(caught)) {
       new NotSignedInDialog().center();
-
     } else if (isNoSuchEntity(caught)) {
-      if (Gerrit.isSignedIn()) {
-        new ErrorDialog(Gerrit.C.notFoundBody()).center();
-      } else {
-        new NotSignedInDialog().center();
-      }
-    } else if (isInactiveAccount(caught)) {
-      new ErrorDialog(Gerrit.C.inactiveAccountBody()).center();
-
+      new ErrorDialog(Gerrit.C.notFoundBody()).center();
     } else if (isNoSuchAccount(caught)) {
       final String msg = caught.getMessage();
       final String who = msg.substring(NoSuchAccountException.MESSAGE.length());
@@ -77,12 +68,20 @@
     }
   }
 
-  private static boolean isInvalidXSRF(final Throwable caught) {
+  public static boolean isSigninFailure(Throwable caught) {
+    if (isNotSignedIn(caught) || isInvalidXSRF(caught) ||
+        (isNoSuchEntity(caught) && !Gerrit.isSignedIn())) {
+      return true;
+    }
+    return false;
+  }
+
+  protected static boolean isInvalidXSRF(final Throwable caught) {
     return caught instanceof InvocationException
         && caught.getMessage().equals(JsonConstants.ERROR_INVALID_XSRF);
   }
 
-  private static boolean isNotSignedIn(Throwable caught) {
+  protected static boolean isNotSignedIn(Throwable caught) {
     return RestApi.isNotSignedIn(caught)
         || (caught instanceof RemoteJsonException
            && caught.getMessage().equals(NotSignedInException.MESSAGE));
@@ -94,22 +93,17 @@
             && caught.getMessage().equals(NoSuchEntityException.MESSAGE));
   }
 
-  protected static boolean isInactiveAccount(final Throwable caught) {
-    return caught instanceof RemoteJsonException
-        && caught.getMessage().startsWith(InactiveAccountException.MESSAGE);
-  }
-
-  private static boolean isNoSuchAccount(final Throwable caught) {
+  protected static boolean isNoSuchAccount(final Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NoSuchAccountException.MESSAGE);
   }
 
-  private static boolean isNameAlreadyUsed(final Throwable caught) {
+  protected static boolean isNameAlreadyUsed(final Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NameAlreadyUsedException.MESSAGE);
   }
 
-  private static boolean isNoSuchGroup(final Throwable caught) {
+  protected static boolean isNoSuchGroup(final Throwable caught) {
     return caught instanceof RemoteJsonException
     && caught.getMessage().startsWith(NoSuchGroupException.MESSAGE);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
index a0e25ad..111f19a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
@@ -89,13 +89,13 @@
   public static boolean isExpected(int statusCode) {
     switch (statusCode) {
       case SC_UNAVAILABLE:
-      case 400: // Bad Request
-      case 401: // Unauthorized
-      case 403: // Forbidden
-      case 404: // Not Found
-      case 405: // Method Not Allowed
-      case 409: // Conflict
-      case 412: // Precondition Failed
+      case Response.SC_BAD_REQUEST:
+      case Response.SC_UNAUTHORIZED:
+      case Response.SC_FORBIDDEN:
+      case Response.SC_NOT_FOUND:
+      case Response.SC_METHOD_NOT_ALLOWED:
+      case Response.SC_CONFLICT:
+      case Response.SC_PRECONDITION_FAILED:
       case 422: // Unprocessable Entity
       case 429: // Too Many Requests (RFC 6585)
         return true;
@@ -251,7 +251,7 @@
   }
 
   public RestApi id(String id) {
-    return idRaw(URL.encodeQueryString(id));
+    return idRaw(URL.encodePathSegment(id));
   }
 
   public RestApi id(int id) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
index 89e9367..620133d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
@@ -18,7 +18,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 public interface RpcConstants extends Constants {
-  public static final RpcConstants C = GWT.create(RpcConstants.class);
+  RpcConstants C = GWT.create(RpcConstants.class);
 
   String errorServerUnavailable();
   String errorRemoteJsonException();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
index 8128afe..97ed559 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
@@ -44,12 +44,10 @@
 
   @Override
   public void onFailure(final Throwable caught) {
-    if (isNoSuchEntity(caught)) {
-      if (Gerrit.isSignedIn()) {
-        Gerrit.display(screen.getToken(), new NotFoundScreen());
-      } else {
-        new NotSignedInDialog().center();
-      }
+    if (isSigninFailure(caught)) {
+      new NotSignedInDialog().center();
+    } else if (isNoSuchEntity(caught)) {
+      Gerrit.display(screen.getToken(), new NotFoundScreen());
     } else {
       super.onFailure(caught);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
index 5f4081b..a96624a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.client.ui;
 
-import com.google.gerrit.client.RpcStatus;
+import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.groups.GroupMap;
 import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 
 /** Suggestion Oracle for AccountGroup entities. */
@@ -34,26 +34,22 @@
 
   @Override
   public void _onRequestSuggestions(final Request req, final Callback callback) {
-    RpcStatus.hide(new Runnable() {
-      @Override
-      public void run() {
-        SuggestUtil.SVC.suggestAccountGroupForProject(
-            projectName, req.getQuery(), req.getLimit(),
-            new GerritCallback<List<GroupReference>>() {
-              @Override
-              public void onSuccess(final List<GroupReference> result) {
-                priorResults.clear();
-                final ArrayList<AccountGroupSuggestion> r =
-                    new ArrayList<>(result.size());
-                for (final GroupReference p : result) {
-                  r.add(new AccountGroupSuggestion(p));
-                  priorResults.put(p.getName(), p.getUUID());
-                }
-                callback.onSuggestionsReady(req, new Response(r));
-              }
-            });
-      }
-    });
+    GroupMap.suggestAccountGroupForProject(
+        projectName == null ? null : projectName.get(),
+        req.getQuery(),
+        req.getLimit(),
+        new GerritCallback<GroupMap>() {
+          @Override
+          public void onSuccess(GroupMap result) {
+            priorResults.clear();
+            ArrayList<AccountGroupSuggestion> r = new ArrayList<>(result.size());
+            for (GroupInfo group : Natives.asList(result.values())) {
+              r.add(new AccountGroupSuggestion(group));
+              priorResults.put(group.name(), group.getGroupUUID());
+            }
+            callback.onSuggestionsReady(req, new Response(r));
+          }
+        });
   }
 
   public void setProject(Project.NameKey projectName) {
@@ -62,20 +58,20 @@
 
   private static class AccountGroupSuggestion implements
       SuggestOracle.Suggestion {
-    private final GroupReference info;
+    private final GroupInfo info;
 
-    AccountGroupSuggestion(final GroupReference k) {
+    AccountGroupSuggestion(final GroupInfo k) {
       info = k;
     }
 
     @Override
     public String getDisplayString() {
-      return info.getName();
+      return info.name();
     }
 
     @Override
     public String getReplacementString() {
-      return info.getName();
+      return info.name();
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
index 288549f..eb3b1ff 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
@@ -19,32 +19,11 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AccountInfoCache;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gwt.user.client.ui.FlowPanel;
 
 /** Link to any user's account dashboard. */
 public class AccountLinkPanel extends FlowPanel {
-  /** Create a link after locating account details from an active cache. */
-  public static AccountLinkPanel link(AccountInfoCache cache, Account.Id id) {
-    com.google.gerrit.common.data.AccountInfo ai = cache.get(id);
-    return ai != null ? new AccountLinkPanel(ai) : null;
-  }
-
-  public AccountLinkPanel(com.google.gerrit.common.data.AccountInfo ai) {
-    this(FormatUtil.asInfo(ai));
-  }
-
-  public AccountLinkPanel(UserIdentity ident) {
-    this(AccountInfo.create(
-        ident.getAccount().get(),
-        ident.getName(),
-        ident.getEmail(),
-        ident.getUsername()));
-  }
-
   public AccountLinkPanel(AccountInfo info) {
     this(info, Change.Status.NEW);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
index 60c23df..3702e68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
@@ -35,28 +35,53 @@
           public void onSuccess(JsArray<AccountInfo> in) {
             List<AccountSuggestion> r = new ArrayList<>(in.length());
             for (AccountInfo p : Natives.asList(in)) {
-              r.add(new AccountSuggestion(p));
+              r.add(new AccountSuggestion(p, req.getQuery()));
             }
             cb.onSuggestionsReady(req, new Response(r));
           }
         });
   }
 
-  private static class AccountSuggestion implements SuggestOracle.Suggestion {
-    private final AccountInfo info;
+  public static class AccountSuggestion implements SuggestOracle.Suggestion {
+    private final String suggestion;
 
-    AccountSuggestion(final AccountInfo k) {
-      info = k;
+    AccountSuggestion(AccountInfo info, String query) {
+      this.suggestion = format(info, query);
     }
 
     @Override
     public String getDisplayString() {
-      return FormatUtil.nameEmail(info);
+      return suggestion;
     }
 
     @Override
     public String getReplacementString() {
-      return FormatUtil.nameEmail(info);
+      return suggestion;
+    }
+
+    public static String format(AccountInfo info, String query) {
+      String s = FormatUtil.nameEmail(info);
+      if (!containsQuery(s, query) && info.secondaryEmails() != null) {
+        for (String email : Natives.asList(info.secondaryEmails())) {
+          AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
+              email, info.username());
+          String s2 = FormatUtil.nameEmail(info2);
+          if (containsQuery(s2, query)) {
+            s = s2;
+            break;
+          }
+        }
+      }
+      return s;
+    }
+
+    private static boolean containsQuery(String s, String query) {
+      for (String qterm : query.split("\\s+")) {
+        if (!s.toLowerCase().contains(qterm.toLowerCase())) {
+          return false;
+        }
+      }
+      return true;
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
index 6af4b78..984653d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
@@ -52,9 +52,8 @@
   private static String text(String branch, String topic) {
     if (topic != null && !topic.isEmpty()) {
       return branch + " (" + topic + ")";
-    } else {
-      return branch;
     }
+    return branch;
   }
 
   public static String query(Project.NameKey project, Change.Status status,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java
index 72c80ac..1ae4489 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ChangeLink.java
@@ -16,9 +16,7 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.ChangeInfo;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 
 public class ChangeLink extends InlineHyperlink {
@@ -27,30 +25,11 @@
   }
 
   protected Change.Id cid;
-  protected PatchSet.Id psid;
 
   public ChangeLink(final String text, final Change.Id c) {
     super(text, PageLinks.toChange(c));
     getElement().setPropertyString("href", permalink(c));
     cid = c;
-    psid = null;
-  }
-
-  public ChangeLink(final String text, final PatchSet.Id ps) {
-    super(text, PageLinks.toChange(ps));
-    cid = ps.getParentKey();
-    psid = ps;
-  }
-
-  public ChangeLink(final String text, final ChangeInfo info) {
-    super(text, getTarget(info));
-    cid = info.getId();
-    psid = info.getPatchSetId();
-  }
-
-  public static String getTarget(final ChangeInfo info) {
-    PatchSet.Id ps = info.getPatchSetId();
-    return (ps == null) ? PageLinks.toChange(info) : PageLinks.toChange(ps);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
index f69e042..4e6f500 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
@@ -85,7 +85,7 @@
   static class BranchSuggestion implements Suggestion {
     private BranchInfo branch;
 
-    public BranchSuggestion(BranchInfo branch) {
+    BranchSuggestion(BranchInfo branch) {
       this.branch = branch;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java
deleted file mode 100644
index 77f40df..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java
+++ /dev/null
@@ -1,265 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.AvatarImage;
-import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gwt.event.dom.client.BlurEvent;
-import com.google.gwt.event.dom.client.BlurHandler;
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.DoubleClickEvent;
-import com.google.gwt.event.dom.client.DoubleClickHandler;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.HasBlurHandlers;
-import com.google.gwt.event.dom.client.HasDoubleClickHandlers;
-import com.google.gwt.event.dom.client.HasFocusHandlers;
-import com.google.gwt.event.shared.HandlerManager;
-import com.google.gwt.event.shared.HandlerRegistration;
-import com.google.gwt.user.client.ui.Button;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.FlexTable;
-import com.google.gwt.user.client.ui.FlowPanel;
-import com.google.gwt.user.client.ui.HTML;
-import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
-import com.google.gwt.user.client.ui.HasHorizontalAlignment;
-import com.google.gwt.user.client.ui.InlineLabel;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.Widget;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-import java.util.Date;
-
-public class CommentPanel extends Composite implements HasDoubleClickHandlers,
-    HasFocusHandlers, FocusHandler, HasBlurHandlers, BlurHandler {
-  private static final int SUMMARY_LENGTH = 75;
-  private final HandlerManager handlerManager = new HandlerManager(this);
-  private final FlowPanel body;
-  private final FlexTable header;
-  private final InlineLabel messageSummary;
-  private final FlowPanel content;
-  private final DoubleClickHTML messageText;
-  private CommentLinkProcessor commentLinkProcessor;
-  private FlowPanel buttons;
-  private boolean recent;
-
-  public CommentPanel(final AccountInfo author, final Date when, String message,
-      CommentLinkProcessor commentLinkProcessor) {
-    this(commentLinkProcessor);
-
-    setMessageText(message);
-    setAuthorNameText(author, FormatUtil.name(author));
-    setDateText(FormatUtil.shortFormatDayTime(when));
-
-    final CellFormatter fmt = header.getCellFormatter();
-    fmt.getElement(0, 1).setTitle(FormatUtil.nameEmail(author));
-    fmt.getElement(0, 3).setTitle(FormatUtil.mediumFormat(when));
-  }
-
-  protected CommentPanel(CommentLinkProcessor commentLinkProcessor) {
-    this.commentLinkProcessor = commentLinkProcessor;
-    body = new FlowPanel();
-    initWidget(body);
-    setStyleName(Gerrit.RESOURCES.css().commentPanel());
-
-    messageSummary = new InlineLabel();
-    messageSummary.setStyleName(Gerrit.RESOURCES.css().commentPanelSummary());
-
-    header = new FlexTable();
-    header.setStyleName(Gerrit.RESOURCES.css().commentPanelHeader());
-    header.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        setOpen(!isOpen());
-      }
-    });
-    header.setText(0, 1, "");
-    header.setWidget(0, 2, messageSummary);
-    header.setText(0, 3, "");
-    final CellFormatter fmt = header.getCellFormatter();
-    fmt.setStyleName(0, 1, Gerrit.RESOURCES.css().commentPanelAuthorCell());
-    fmt.setStyleName(0, 2, Gerrit.RESOURCES.css().commentPanelSummaryCell());
-    fmt.setStyleName(0, 3, Gerrit.RESOURCES.css().commentPanelDateCell());
-    fmt.setHorizontalAlignment(0, 3, HasHorizontalAlignment.ALIGN_RIGHT);
-    body.add(header);
-
-    content = new FlowPanel();
-    content.setStyleName(Gerrit.RESOURCES.css().commentPanelContent());
-    content.setVisible(false);
-    body.add(content);
-
-    messageText = new DoubleClickHTML();
-    messageText.setStyleName(Gerrit.RESOURCES.css().commentPanelMessage());
-    content.add(messageText);
-  }
-
-  @Override
-  public HandlerRegistration addDoubleClickHandler(DoubleClickHandler handler) {
-    return messageText.addDoubleClickHandler(handler);
-  }
-
-  protected void setMessageText(String message) {
-    if (message == null) {
-      message = "";
-    } else {
-      message = message.trim();
-    }
-
-    messageSummary.setText(summarize(message));
-    SafeHtml buf = new SafeHtmlBuilder().append(message).wikify();
-    buf = commentLinkProcessor.apply(buf);
-    SafeHtml.set(messageText, buf);
-  }
-
-  public void setAuthorNameText(final AccountInfo author, final String nameText) {
-    header.setWidget(0, 0, new AvatarImage(author));
-    header.setText(0, 1, nameText);
-    body.getElement().setAttribute("email", author.email());
-    body.getElement().setAttribute("name", author.name());
-  }
-
-  protected void setDateText(final String dateText) {
-    header.setText(0, 3, dateText);
-  }
-
-  protected void setMessageTextVisible(final boolean show) {
-    messageText.setVisible(show);
-  }
-
-  protected void addContent(final Widget w) {
-    if (buttons != null) {
-      content.insert(w, content.getWidgetIndex(buttons));
-    } else {
-      content.add(w);
-    }
-  }
-
-  /**
-   * Registers a {@link FocusHandler} for this comment panel.
-   * The comment panel is considered as being focused whenever any button in the
-   * comment panel gets focused.
-   *
-   * @param handler the focus handler to be registered
-   */
-  @Override
-  public HandlerRegistration addFocusHandler(final FocusHandler handler) {
-    return handlerManager.addHandler(FocusEvent.getType(), handler);
-  }
-
-  /**
-   * Registers a {@link BlurHandler} for this comment panel.
-   * The comment panel is considered as being blurred whenever any button in the
-   * comment panel gets blurred.
-   *
-   * @param handler the blur handler to be registered
-   */
-  @Override
-  public HandlerRegistration addBlurHandler(final BlurHandler handler) {
-    return handlerManager.addHandler(BlurEvent.getType(), handler);
-  }
-
-  protected void addButton(final Button button) {
-    // register focus and blur handler for each button, so that we can fire
-    // focus and blur events for the comment panel
-    button.addFocusHandler(this);
-    button.addBlurHandler(this);
-    getButtonPanel().add(button);
-  }
-
-  private Panel getButtonPanel() {
-    if (buttons == null) {
-      buttons = new FlowPanel();
-      buttons.setStyleName(Gerrit.RESOURCES.css().commentPanelButtons());
-      content.add(buttons);
-    }
-    return buttons;
-  }
-
-  @Override
-  public void onFocus(final FocusEvent event) {
-    // a button was focused -> fire focus event for the comment panel
-    handlerManager.fireEvent(event);
-  }
-
-  @Override
-  public void onBlur(final BlurEvent event) {
-    // a button was blurred -> fire blur event for the comment panel
-    handlerManager.fireEvent(event);
-  }
-
-  public void enableButtons(final boolean on) {
-    for (Widget w : getButtonPanel()) {
-      if (w instanceof Button) {
-        ((Button) w).setEnabled(on);
-      }
-    }
-  }
-
-  private static String summarize(final String message) {
-    if (message.length() < SUMMARY_LENGTH) {
-      return message;
-    }
-
-    int p = 0;
-    final StringBuilder r = new StringBuilder();
-    while (r.length() < SUMMARY_LENGTH) {
-      final int e = message.indexOf(' ', p);
-      if (e < 0) {
-        break;
-      }
-
-      final String word = message.substring(p, e).trim();
-      if (SUMMARY_LENGTH <= r.length() + word.length() + 1) {
-        break;
-      }
-      if (r.length() > 0) {
-        r.append(' ');
-      }
-      r.append(word);
-      p = e + 1;
-    }
-    r.append(" \u2026");
-    return r.toString();
-  }
-
-  public boolean isOpen() {
-    return content.isVisible();
-  }
-
-  public void setOpen(final boolean open) {
-    messageSummary.setVisible(!open);
-    content.setVisible(open);
-  }
-
-  public boolean isRecent() {
-    return recent;
-  }
-
-  public void setRecent(final boolean r) {
-    recent = r;
-  }
-
-  private static class DoubleClickHTML extends HTML implements
-      HasDoubleClickHandlers {
-    @Override
-    public HandlerRegistration addDoubleClickHandler(DoubleClickHandler handler) {
-      return addDomHandler(handler, DoubleClickEvent.getType());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
index 60b5f93..c21d5dc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
@@ -35,7 +35,7 @@
   protected final FlowPanel contentPanel;
   protected FocusWidget focusOn;
 
-  protected boolean sent = false;
+  protected boolean sent;
 
   public CommentedActionDialog(final String title, final String heading) {
     super(/* auto hide */false, /* modal */true);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
index e398e78..7cda8a3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
@@ -98,7 +98,7 @@
   static class BranchSuggestion implements Suggestion {
     private BranchInfo branch;
 
-    public BranchSuggestion(BranchInfo branch) {
+    BranchSuggestion(BranchInfo branch) {
       this.branch = branch;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ExpandAllCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ExpandAllCommand.java
deleted file mode 100644
index 9abf135..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ExpandAllCommand.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.user.client.Command;
-import com.google.gwt.user.client.ui.Panel;
-import com.google.gwt.user.client.ui.Widget;
-
-/** Expands all {@link CommentPanel} in a parent panel. */
-public class ExpandAllCommand implements Command {
-  private final Panel panel;
-  protected final boolean open;
-
-  public ExpandAllCommand(final Panel p, final boolean isOpen) {
-    panel = p;
-    open = isOpen;
-  }
-
-  @Override
-  public void execute() {
-    for (final Widget w : panel) {
-      if (w instanceof CommentPanel) {
-        expand((CommentPanel) w);
-      }
-    }
-  }
-
-  protected void expand(final CommentPanel w) {
-    w.setOpen(open);
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
index 88ee293..e77bc10 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
@@ -222,9 +222,9 @@
   protected static class MyFlexTable extends FlexTable {
   }
 
-  private static final native <ItemType> void setRowItem(Element td, ItemType c)
+  private static native <ItemType> void setRowItem(Element td, ItemType c)
   /*-{ td['__gerritRowItem'] = c; }-*/;
 
-  private static final native <ItemType> ItemType getRowItem(Element td)
+  private static native <ItemType> ItemType getRowItem(Element td)
   /*-{ return td['__gerritRowItem']; }-*/;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableAccountDiffPreference.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableAccountDiffPreference.java
deleted file mode 100644
index 0c31f41..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableAccountDiffPreference.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.account.Util;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gwtjsonrpc.common.VoidResult;
-
-public class ListenableAccountDiffPreference
-    extends ListenableOldValue<DiffPreferencesInfo> {
-
-  public ListenableAccountDiffPreference() {
-    reset();
-  }
-
-  public void save(final GerritCallback<VoidResult> cb) {
-    if (Gerrit.isSignedIn()) {
-      Util.ACCOUNT_SVC.changeDiffPreferences(get(),
-          new GerritCallback<VoidResult>() {
-        @Override
-        public void onSuccess(VoidResult result) {
-          Gerrit.setDiffPreferences(get());
-          cb.onSuccess(result);
-        }
-
-        @Override
-        public void onFailure(Throwable caught) {
-          cb.onFailure(caught);
-        }
-      });
-    }
-  }
-
-  public void reset() {
-    if (Gerrit.isSignedIn() && Gerrit.getDiffPreferences() != null) {
-      set(Gerrit.getDiffPreferences());
-    } else {
-      set(DiffPreferencesInfo.defaults());
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableOldValue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableOldValue.java
deleted file mode 100644
index 758dff4..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableOldValue.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-public class ListenableOldValue<T> extends ListenableValue<T> {
-
-  private T oldValue;
-
-  public T getOld() {
-    return oldValue;
-  }
-
-  @Override
-  public void set(final T value) {
-    try {
-      oldValue = get();
-      super.set(value);
-    } finally {
-      oldValue = null; // allow it to be gced before the next event
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableValue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableValue.java
deleted file mode 100644
index c8bb12e..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ListenableValue.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.event.logical.shared.HasValueChangeHandlers;
-import com.google.gwt.event.logical.shared.ValueChangeEvent;
-import com.google.gwt.event.logical.shared.ValueChangeHandler;
-import com.google.gwt.event.shared.GwtEvent;
-import com.google.gwt.event.shared.HandlerManager;
-import com.google.gwt.event.shared.HandlerRegistration;
-
-
-public class ListenableValue<T> implements HasValueChangeHandlers<T> {
-
-  private HandlerManager manager = new HandlerManager(this);
-
-  private T value;
-
-  public T get() {
-    return value;
-  }
-
-  public void set(final T value) {
-    this.value = value;
-    fireEvent(new ValueChangeEvent<T>(value) {});
-  }
-
-  @Override
-  public void fireEvent(GwtEvent<?> event) {
-    manager.fireEvent(event);
-  }
-
-  @Override
-  public HandlerRegistration addValueChangeHandler(
-      ValueChangeHandler<T> handler) {
-    return manager.addHandler(ValueChangeEvent.getType(), handler);
-  }
-
-  public int getHandlerCount() {
-    return manager.getHandlerCount(ValueChangeEvent.getType());
-  }
-
-  public ValueChangeHandler<?> getHandler(int index) {
-    return manager.getHandler(ValueChangeEvent.getType(), index);
-  }
-
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
index db7ad3c..1b99707 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
@@ -81,7 +81,7 @@
 
         /* Re-insert the widget right after the first visible widget found
            when scanning backwards from the current widget */
-        for (int pos = origPos -1; pos >=0 ; pos--) {
+        for (int pos = origPos - 1; pos >= 0 ; pos--) {
           int visiblePos = visibles.indexOf(widgets.get(pos));
           if (visiblePos != -1) {
             visibles.add(visiblePos + 1, w);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
index 0933153..3994381 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.client.ui;
 
-import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
@@ -31,11 +30,6 @@
     init();
   }
 
-  public NpIntTextBox(Element element) {
-    super(element);
-    init();
-  }
-
   private void init() {
     addKeyDownHandler(new KeyDownHandler() {
       @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
index 819a11f..4391477 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
@@ -73,6 +73,9 @@
     widget = w;
   }
 
+  public void updateOriginalValue(final TextBoxBase tb) {
+    originalValue = tb.getValue().trim();
+  }
 
   // Register input widgets to be listened to
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PatchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PatchLink.java
deleted file mode 100644
index 09244c9..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PatchLink.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.client.Dispatcher;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-
-public class PatchLink extends InlineHyperlink {
-  private PatchLink(String text, String historyToken) {
-    super(text, historyToken);
-  }
-
-  public static class SideBySide extends PatchLink {
-    public SideBySide(String text, PatchSet.Id base, Patch.Key id) {
-      super(text, Dispatcher.toSideBySide(base, id));
-    }
-  }
-
-  public static class Unified extends PatchLink {
-    public Unified(String text, PatchSet.Id base, Patch.Key id) {
-      super(text, Dispatcher.toUnified(base, id));
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
index 8b58403..36708dc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
@@ -43,7 +43,8 @@
   private HorizontalPanel filterPanel;
   private String match;
   private Query query;
-  private Button close;
+  private Button closeTop;
+  private Button closeBottom;
   private ScrollPanel sp;
   private PopupPanel.PositionCallback popupPosition;
   private int preferredTop;
@@ -55,10 +56,11 @@
     createWidgets(popupText, currentPageLink);
     final FlowPanel pfp = new FlowPanel();
     pfp.add(filterPanel);
+    pfp.add(closeTop);
     sp = new ScrollPanel(projectsTab);
     sp.setSize("100%", "100%");
     pfp.add(sp);
-    pfp.add(close);
+    pfp.add(closeBottom);
     popup.setWidget(pfp);
     popup.setHeight("100%");
     popupPosition = getPositionCallback();
@@ -147,17 +149,23 @@
     };
     projectsTab.setSavePointerId(currentPageLink);
 
-    close = new Button(Util.C.projectsClose());
+    closeTop = createCloseButton();
+    closeBottom = createCloseButton();
+
+    popup = new DialogBox();
+    popup.setModal(false);
+    popup.setText(popupText);
+  }
+
+  private Button createCloseButton() {
+    Button close = new Button(Util.C.projectsClose());
     close.addClickHandler(new ClickHandler() {
       @Override
       public void onClick(final ClickEvent event) {
         closePopup();
       }
     });
-
-    popup = new DialogBox();
-    popup.setModal(false);
-    popup.setText(popupText);
+    return close;
   }
 
   public void displayPopup() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
index dc2c73d..ad418a6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
@@ -27,6 +27,7 @@
     super(" ", PageLinks.toProjectDefaultDashboard(projectName));
     setTitle(Util.C.projectListQueryLink());
     final Image image = new Image(Gerrit.RESOURCES.queryIcon());
+    image.setStyleName(Gerrit.RESOURCES.css().queryIcon());
     DOM.insertBefore(getElement(), image.getElement(),
         DOM.getFirstChild(getElement()));
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
index 1a9fc4b..77c4e56 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
@@ -147,7 +147,7 @@
   private static class ChangeSuggestion implements Suggestion {
     private ChangeInfo change;
 
-    public ChangeSuggestion(ChangeInfo change) {
+    ChangeSuggestion(ChangeInfo change) {
       this.change = change;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
index 50f991c..62b8f2e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
@@ -50,9 +50,9 @@
       public void onKeyDown(KeyDownEvent e) {
         submitOnSelection = false;
 
-        if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ESCAPE) {
+        if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
           CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
-        } else if (e.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+        } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
           if (display.isSuggestionListShowing()) {
             if (textBox.getValue().equals(remoteSuggestOracle.getLast())) {
               submitOnSelection = true;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
index 5192d6d..1068b3e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
@@ -70,7 +70,7 @@
   public void registerKeys() {
   }
 
-  private static enum Cols {
+  private enum Cols {
     West, Title, East, FarEast
   }
 
@@ -184,7 +184,6 @@
     if (windowTitle != null) {
       Gerrit.setWindowTitle(this, windowTitle);
     }
-    Gerrit.updateMenus(this);
     Gerrit.EVENT_BUS.fireEvent(new ScreenLoadEvent(this));
     Gerrit.setQueryString(null);
     registerKeys();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java
index 45ba808..debd9a6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadEvent.java
@@ -36,7 +36,7 @@
     return TYPE;
   }
 
-  public Screen getScreen(){
+  public Screen getScreen() {
     return screen;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
index a91becf..9a5eb03 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
@@ -17,5 +17,5 @@
 import com.google.gwt.event.shared.EventHandler;
 
 public interface ScreenLoadHandler extends EventHandler {
-  public void onScreenLoad(ScreenLoadEvent event);
+  void onScreenLoad(ScreenLoadEvent event);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestUtil.java
deleted file mode 100644
index 6e81b71..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestUtil.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gerrit.common.data.SuggestService;
-import com.google.gwt.core.client.GWT;
-import com.google.gwtjsonrpc.client.JsonUtil;
-
-public class SuggestUtil {
-  public static final SuggestService SVC;
-
-  static {
-    SVC = GWT.create(SuggestService.class);
-    JsonUtil.bind(SVC, "rpc/SuggestService");
-  }
-
-  private SuggestUtil() {
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextBoxChangeListener.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextBoxChangeListener.java
deleted file mode 100644
index ca8ea1d..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/TextBoxChangeListener.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.ui;
-
-import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
-import com.google.gwt.event.dom.client.FocusEvent;
-import com.google.gwt.event.dom.client.FocusHandler;
-import com.google.gwt.event.dom.client.KeyDownEvent;
-import com.google.gwt.event.dom.client.KeyDownHandler;
-import com.google.gwt.event.dom.client.KeyPressEvent;
-import com.google.gwt.event.dom.client.KeyPressHandler;
-import com.google.gwt.event.dom.client.MouseUpEvent;
-import com.google.gwt.event.dom.client.MouseUpHandler;
-import com.google.gwt.event.shared.GwtEvent;
-import com.google.gwt.user.client.ui.TextBoxBase;
-
-public abstract class TextBoxChangeListener implements KeyPressHandler, KeyDownHandler, MouseUpHandler {
-
-  private String oldText;
-
-  public TextBoxChangeListener(final TextBoxBase tb) {
-    oldText = tb.getText();
-
-    tb.addKeyPressHandler(this);
-
-    // Is there another way to capture middle button X11 pastes in browsers
-    // which do not yet support ONPASTE events (Firefox)?
-    tb.addMouseUpHandler(this);
-
-    // Resetting the "original text" on focus ensures that we are
-    // up to date with non-user updates of the text (calls to
-    // setText()...) and also up to date with user changes which
-    // occurred after enabling "widget".
-    tb.addFocusHandler(new FocusHandler() {
-        @Override
-        public void onFocus(FocusEvent event) {
-          oldText = tb.getText();
-        }
-      });
-
-    // CTRL-V Pastes in Chrome seem only detectable via BrowserEvents or
-    // KeyDownEvents, the latter is better.
-    tb.addKeyDownHandler(this);
-  }
-
-
-  @Override
-  public void onKeyPress(final KeyPressEvent e) {
-    on(e);
-  }
-
-  @Override
-  public void onKeyDown(final KeyDownEvent e) {
-    on(e);
-  }
-
-  @Override
-  public void onMouseUp(final MouseUpEvent e) {
-    on(e);
-  }
-
-  private void on(final GwtEvent<?> e) {
-    final TextBoxBase tb = (TextBoxBase) e.getSource();
-
-    if (!tb.getText().equals(oldText)) {
-      onTextChanged(tb.getText());
-      oldText = tb.getText();
-    } else {
-      // The text appears to not always get updated until the handlers complete.
-      Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-        @Override
-        public void execute() {
-          if (!tb.getText().equals(oldText)) {
-            onTextChanged(tb.getText());
-            oldText = tb.getText();
-          }
-        }
-      });
-    }
-  }
-
-  public abstract void onTextChanged(String newText);
-}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
new file mode 100644
index 0000000..efed451
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.addon;
+
+import com.google.gwt.safehtml.shared.SafeUri;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+
+import net.codemirror.lib.Loader;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class AddonInjector {
+  private static final Map<String, SafeUri> addonUris = new HashMap<>();
+  static {
+    addonUris.put(Addons.I.merge_bundled().getName(),
+        Addons.I.merge_bundled().getSafeUri());
+  }
+
+  public static SafeUri getAddonScriptUri(String addon) {
+    return addonUris.get(addon);
+  }
+
+  private static boolean canLoad(String addon) {
+    return getAddonScriptUri(addon) != null;
+  }
+
+  private final Set<String> loading = new HashSet<>();
+  private int pending;
+  private AsyncCallback<Void> appCallback;
+
+  public AddonInjector add(String name) {
+    if (name == null) {
+      return this;
+    }
+
+    if (!canLoad(name)) {
+      Logger.getLogger("net.codemirror").log(
+        Level.WARNING,
+        "CodeMirror addon " + name + " not configured.");
+      return this;
+    }
+
+    loading.add(name);
+    return this;
+  }
+
+  public void inject(AsyncCallback<Void> appCallback) {
+    this.appCallback = appCallback;
+    for (String addon : loading) {
+      beginLoading(addon);
+    }
+    if (pending == 0) {
+      appCallback.onSuccess(null);
+    }
+  }
+
+  private void beginLoading(final String addon) {
+    pending++;
+    Loader.injectScript(
+      getAddonScriptUri(addon),
+      new AsyncCallback<Void>() {
+        @Override
+        public void onSuccess(Void result) {
+          pending--;
+          if (pending == 0) {
+            appCallback.onSuccess(null);
+          }
+        }
+
+        @Override
+        public void onFailure(Throwable caught) {
+          if (--pending == 0) {
+            appCallback.onFailure(caught);
+          }
+        }
+      });
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
new file mode 100644
index 0000000..7c8b362
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.addon;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.resources.client.ClientBundle;
+import com.google.gwt.resources.client.DataResource;
+import com.google.gwt.resources.client.DataResource.DoNotEmbed;
+
+public interface Addons extends ClientBundle {
+  Addons I = GWT.create(Addons.class);
+
+  @Source("merge_bundled.js") @DoNotEmbed DataResource merge_bundled();
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
new file mode 100644
index 0000000..7418795
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.lib;
+
+import com.google.gwt.i18n.client.Messages;
+
+public interface BlameConfig extends Messages {
+  String shortBlameMsg(String commitId, String date, String author);
+  String detailedBlameMsg(String commitId, String author, String time,
+      String msg);
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties
new file mode 100644
index 0000000..658b50f
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.properties
@@ -0,0 +1,2 @@
+shortBlameMsg={0} {1} {2}
+detailedBlameMsg=commit {0}\nAuthor: {1}\nDate: {2}\n\n{3}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
index 639e5e7..be914f3 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
@@ -138,7 +138,7 @@
     addLineClassNative(line, where.value(), className);
   }
 
-  private final native void addLineClassNative(int line, String where,
+  private native void addLineClassNative(int line, String where,
       String lineClass) /*-{
     this.addLineClass(line, where, lineClass)
   }-*/;
@@ -148,7 +148,7 @@
     addLineClassNative(line, where.value(), className);
   }
 
-  private final native void addLineClassNative(LineHandle line, String where,
+  private native void addLineClassNative(LineHandle line, String where,
       String lineClass) /*-{
     this.addLineClass(line, where, lineClass)
   }-*/;
@@ -158,7 +158,7 @@
     removeLineClassNative(line, where.value(), className);
   }
 
-  private final native void removeLineClassNative(int line, String where,
+  private native void removeLineClassNative(int line, String where,
       String lineClass) /*-{
     this.removeLineClass(line, where, lineClass)
   }-*/;
@@ -168,7 +168,7 @@
     removeLineClassNative(line, where.value(), className);
   }
 
-  private final native void removeLineClassNative(LineHandle line, String where,
+  private native void removeLineClassNative(LineHandle line, String where,
       String lineClass) /*-{
     this.removeLineClass(line, where, lineClass)
   }-*/;
@@ -364,6 +364,17 @@
     $wnd.CodeMirror.keyMap[name] = km
   }-*/;
 
+  public static final native void normalizeKeyMap(KeyMap km) /*-{
+    $wnd.CodeMirror.normalizeKeyMap(km);
+  }-*/;
+
+  public static final native void addCommand(String name, CommandRunner runner) /*-{
+    $wnd.CodeMirror.commands[name] = function(cm) {
+      runner.@net.codemirror.lib.CodeMirror.CommandRunner::run(
+        Lnet/codemirror/lib/CodeMirror;)(cm);
+    };
+  }-*/;
+
   public final native Vim vim() /*-{
     return this;
   }-*/;
@@ -376,6 +387,18 @@
     return Extras.get(this);
   }
 
+  public final native LineHandle setGutterMarker(int line, String gutterId, Element value) /*-{
+    return this.setGutterMarker(line, gutterId, value);
+  }-*/;
+
+  public final native LineHandle setGutterMarker(LineHandle line, String gutterId, Element value) /*-{
+    return this.setGutterMarker(line, gutterId, value);
+  }-*/;
+
+  public final native boolean hasSearchHighlight() /*-{
+    return this.state.search && !!this.state.search.query;
+  }-*/;
+
   protected CodeMirror() {
   }
 
@@ -401,23 +424,27 @@
   }
 
   public interface EventHandler {
-    public void handle(CodeMirror instance, NativeEvent event);
+    void handle(CodeMirror instance, NativeEvent event);
   }
 
   public interface RenderLineHandler {
-    public void handle(CodeMirror instance, LineHandle handle, Element element);
+    void handle(CodeMirror instance, LineHandle handle, Element element);
   }
 
   public interface GutterClickHandler {
-    public void handle(CodeMirror instance, int line, String gutter,
+    void handle(CodeMirror instance, int line, String gutter,
         NativeEvent clickEvent);
   }
 
   public interface BeforeSelectionChangeHandler {
-    public void handle(CodeMirror instance, Pos anchor, Pos head);
+    void handle(CodeMirror instance, Pos anchor, Pos head);
   }
 
   public interface ChangesHandler {
-    public void handle(CodeMirror instance);
+    void handle(CodeMirror instance);
+  }
+
+  public interface CommandRunner {
+    void run(CodeMirror instance);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
index 13186d1..d727e24 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
@@ -19,18 +19,31 @@
 import static net.codemirror.lib.CodeMirror.style;
 import static net.codemirror.lib.CodeMirror.LineClassWhere.WRAP;
 
+import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.RangeInfo;
+import com.google.gerrit.client.blame.BlameInfo;
 import com.google.gerrit.client.diff.DisplaySide;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.dom.client.Element;
+import com.google.gwt.i18n.client.DateTimeFormat;
 import com.google.gwt.user.client.DOM;
 
 import net.codemirror.lib.CodeMirror.LineHandle;
 
+import java.util.Date;
 import java.util.Objects;
 
 /** Additional features added to CodeMirror by Gerrit Code Review. */
 public class Extras {
+  private static final String ANNOTATION_GUTTER_ID = "CodeMirror-lint-markers";
+  private static final BlameConfig C = GWT.create(BlameConfig.class);
+
   static final native Extras get(CodeMirror c) /*-{ return c.gerritExtras }-*/;
-  private static final native void set(CodeMirror c, Extras e)
+  private static native void set(CodeMirror c, Extras e)
   /*-{ c.gerritExtras = e }-*/;
 
   static void attach(CodeMirror c) {
@@ -43,6 +56,7 @@
   private double charWidthPx;
   private double lineHeightPx;
   private LineHandle activeLine;
+  private boolean annotated;
 
   private Extras(CodeMirror cm) {
     this.cm = cm;
@@ -140,4 +154,68 @@
       activeLine = null;
     }
   }
+
+  public boolean isAnnotated() {
+    return annotated;
+  }
+
+  public final void clearAnnotations() {
+    JsArrayString gutters = ((JsArrayString) JsArrayString.createArray());
+    cm.setOption("gutters", gutters);
+    annotated = false;
+  }
+
+  public final void setAnnotations(JsArray<BlameInfo> blameInfos) {
+    if (blameInfos.length() > 0) {
+      setBlameInfo(blameInfos);
+      JsArrayString gutters = ((JsArrayString) JsArrayString.createArray());
+      gutters.push(ANNOTATION_GUTTER_ID);
+      cm.setOption("gutters", gutters);
+      annotated = true;
+      DateTimeFormat format = DateTimeFormat.getFormat(
+          DateTimeFormat.PredefinedFormat.DATE_SHORT);
+      JsArray<LintLine> annotations = JsArray.createArray().cast();
+      for (BlameInfo blameInfo : Natives.asList(blameInfos)) {
+        for (RangeInfo range : Natives.asList(blameInfo.ranges())) {
+          Date commitTime = new Date(blameInfo.time() * 1000L);
+          String shortId = blameInfo.id().substring(0, 8);
+          String shortBlame = C.shortBlameMsg(
+              shortId, format.format(commitTime), blameInfo.author());
+          String detailedBlame = C.detailedBlameMsg(blameInfo.id(),
+              blameInfo.author(), FormatUtil.mediumFormat(commitTime),
+              blameInfo.commitMsg());
+
+          annotations.push(LintLine.create(shortBlame, detailedBlame, shortId,
+              Pos.create(range.start() - 1)));
+        }
+      }
+      cm.setOption("lint", getAnnotation(annotations));
+    }
+  }
+
+  private native JavaScriptObject getAnnotation(JsArray<LintLine> annotations) /*-{
+     return {
+        getAnnotations: function(text, options, cm) { return annotations; }
+     };
+  }-*/;
+
+  public final native JsArray<BlameInfo> getBlameInfo() /*-{
+    return this.blameInfos;
+  }-*/;
+
+  public final native void setBlameInfo(JsArray<BlameInfo> blameInfos) /*-{
+    this['blameInfos'] = blameInfos;
+  }-*/;
+
+  public final void toggleAnnotation() {
+    toggleAnnotation(getBlameInfo());
+  }
+
+  public final void toggleAnnotation(JsArray<BlameInfo> blameInfos) {
+    if (isAnnotated()) {
+      clearAnnotations();
+    } else {
+      setAnnotations(blameInfos);
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java
index a3b1cf4..f205ef9 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Lib.java
@@ -21,7 +21,7 @@
 import com.google.gwt.resources.client.ExternalTextResource;
 
 interface Lib extends ClientBundle {
-  static final Lib I = GWT.create(Lib.class);
+  Lib I = GWT.create(Lib.class);
 
   @Source("cm.css")
   ExternalTextResource css();
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
new file mode 100644
index 0000000..b1e20c1
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.lib;
+
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.dom.client.StyleInjector;
+
+public class LintLine extends JavaScriptObject {
+  public static LintLine create(String shortMsg, String msg, String sev,
+      Pos line) {
+    StyleInjector.inject(".CodeMirror-lint-marker-" + sev + " {\n"
+        + "  visibility: hidden;\n"
+        + "  text-overflow: ellipsis;\n"
+        + "  white-space: nowrap;\n"
+        + "  overflow: hidden;\n"
+        + "  position: relative;\n"
+        + "}\n"
+        + ".CodeMirror-lint-marker-" + sev + ":after {\n"
+        + "  content:'" + shortMsg + "';\n"
+        + "  visibility: visible;\n"
+        + "}");
+    return create(msg, sev, line, null);
+  }
+
+  public static native LintLine create(String msg, String sev, Pos f, Pos t) /*-{
+    return {
+      message : msg,
+      severity : sev,
+      from : f,
+      to : t
+    };
+  }-*/;
+
+  public final native String message() /*-{ return this.message; }-*/;
+  public final native String detailedMessage() /*-{ return this.message; }-*/;
+  public final native String severity() /*-{ return this.severity; }-*/;
+  public final native Pos from() /*-{ return this.from; }-*/;
+  public final native Pos to() /*-{ return this.to; }-*/;
+
+  protected LintLine() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
new file mode 100644
index 0000000..d7e1430
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.lib;
+
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.dom.client.Element;
+
+/** Object that represents a text marker within CodeMirror */
+public class MergeView extends JavaScriptObject {
+  public static MergeView create(Element p, Configuration cfg) {
+    MergeView mv = newMergeView(p, cfg);
+    Extras.attach(mv.leftOriginal());
+    Extras.attach(mv.editor());
+    return mv;
+  }
+
+  private static native MergeView newMergeView(Element p, Configuration cfg) /*-{
+    return $wnd.CodeMirror.MergeView(p, cfg);
+  }-*/;
+
+  public final native CodeMirror leftOriginal() /*-{
+    return this.leftOriginal();
+  }-*/;
+
+  public final native CodeMirror editor() /*-{
+    return this.editor();
+  }-*/;
+
+  public final native void setShowDifferences(boolean b) /*-{
+    this.setShowDifferences(b);
+  }-*/;
+
+  public final native Element getGapElement() /*-{
+    return $doc.getElementsByClassName("CodeMirror-merge-gap")[0];
+  }-*/;
+
+  protected MergeView() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
index f1dee69..84b4a6a 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
@@ -32,7 +32,7 @@
     }
     for (String key : new String[] {
       "Ctrl-C", "Ctrl-O", "Ctrl-P", "Ctrl-S",
-      "Ctrl-F", "Ctrl-B", "Ctrl-R"}) {
+      "Ctrl-F", "Ctrl-B", "Ctrl-R",}) {
       km.propagate(key);
     }
     for (int i = 0; i <= 9; i++) {
@@ -57,6 +57,10 @@
     $wnd.CodeMirror.Vim.handleKey(this, key)
   }-*/;
 
+  public final native void handleEx(String exCommand) /*-{
+    $wnd.CodeMirror.Vim.handleEx(this, exCommand);
+  }-*/;
+
   public final native boolean hasSearchHighlight() /*-{
     var v = this.state.vim;
     return v && v.searchState_ && !!v.searchState_.getOverlay();
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css b/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
index 6ce70db..022a800 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
@@ -16,11 +16,15 @@
 @external .CodeMirror;
 @external .CodeMirror-lines;
 @external .CodeMirror-linenumber;
+@external .CodeMirror-lint-markers;
+@external .CodeMirror-lint-tooltip;
 @external .CodeMirror-overlayscroll-horizontal;
 @external .CodeMirror-overlayscroll-vertical;
+@external .CodeMirror-scrollbar-filler;
 @external .cm-tab;
 @external .cm-searching;
 @external .cm-trailingspace;
+@external .unifiedLineNumber;
 
 /* Reduce margins around CodeMirror to save space. */
 .CodeMirror-lines {
@@ -38,7 +42,17 @@
 .CodeMirror-overlayscroll-vertical div {
   min-height: 25px;
 }
-
+/* Ensure the scrollbars are not too narrow */
+.CodeMirror-overlayscroll-horizontal {
+  min-height: 12px;
+}
+.CodeMirror-overlayscroll-vertical {
+  min-width: 12px;
+}
+.CodeMirror-scrollbar-filler {
+  min-height: 12px;
+  min-width: 12px;
+}
 /* Stack the scrollbar so annotations can receive clicks. */
 .CodeMirror-overlayscroll-vertical {
   z-index: inherit;
@@ -50,7 +64,8 @@
 }
 
 /* Highlight current line number in the line gutter. */
-.activeLine .CodeMirror-linenumber {
+.activeLine .CodeMirror-linenumber,
+.activeLine .unifiedLineNumber {
   background-color: #bcf !important;
   color: #000;
 }
@@ -81,3 +96,29 @@
   z-index: 2;
   cursor: text;
 }
+
+.CodeMirror-lint-markers {
+  width: 250px;
+}
+
+.CodeMirror-lint-tooltip {
+  background-color: infobackground;
+  border: 1px solid black;
+  border-radius: 4px 4px 4px 4px;
+  color: infotext;
+  font-family: monospace;
+  font-size: 10pt;
+  overflow: hidden;
+  padding: 2px 5px;
+  position: fixed;
+  white-space: pre;
+  white-space: pre-wrap;
+  z-index: 100;
+  max-width: 600px;
+  opacity: 0;
+  transition: opacity .4s;
+  -moz-transition: opacity .4s;
+  -webkit-transition: opacity .4s;
+  -o-transition: opacity .4s;
+  -ms-transition: opacity .4s;
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index debf4b7..943be7e 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -37,48 +37,125 @@
 
   static {
     indexModes(new DataResource[] {
+      Modes.I.apl(),
+      Modes.I.asciiarmor(),
+      Modes.I.asn_1(),
+      Modes.I.asterisk(),
+      Modes.I.brainfuck(),
       Modes.I.clike(),
       Modes.I.clojure(),
+      Modes.I.cmake(),
+      Modes.I.cobol(),
       Modes.I.coffeescript(),
       Modes.I.commonlisp(),
+      Modes.I.crystal(),
       Modes.I.css(),
+      Modes.I.cypher(),
       Modes.I.d(),
       Modes.I.dart(),
       Modes.I.diff(),
+      Modes.I.django(),
       Modes.I.dockerfile(),
       Modes.I.dtd(),
+      Modes.I.dylan(),
+      Modes.I.ebnf(),
+      Modes.I.ecl(),
+      Modes.I.eiffel(),
+      Modes.I.elm(),
       Modes.I.erlang(),
+      Modes.I.factor(),
+      Modes.I.fcl(),
+      Modes.I.forth(),
+      Modes.I.fortran(),
       Modes.I.gas(),
       Modes.I.gerrit_commit(),
       Modes.I.gfm(),
+      Modes.I.gherkin(),
       Modes.I.go(),
       Modes.I.groovy(),
+      Modes.I.haml(),
+      Modes.I.handlebars(),
+      Modes.I.haskell_literate(),
       Modes.I.haskell(),
+      Modes.I.haxe(),
+      Modes.I.htmlembedded(),
       Modes.I.htmlmixed(),
+      Modes.I.http(),
+      Modes.I.idl(),
+      Modes.I.jade(),
       Modes.I.javascript(),
+      Modes.I.jinja2(),
+      Modes.I.jsx(),
+      Modes.I.julia(),
+      Modes.I.livescript(),
       Modes.I.lua(),
       Modes.I.markdown(),
+      Modes.I.mathematica(),
+      Modes.I.mbox(),
+      Modes.I.mirc(),
+      Modes.I.mllike(),
+      Modes.I.modelica(),
+      Modes.I.mscgen(),
+      Modes.I.mumps(),
+      Modes.I.nginx(),
+      Modes.I.nsis(),
+      Modes.I.ntriples(),
+      Modes.I.octave(),
+      Modes.I.oz(),
+      Modes.I.pascal(),
+      Modes.I.pegjs(),
       Modes.I.perl(),
       Modes.I.php(),
       Modes.I.pig(),
+      Modes.I.powershell(),
       Modes.I.properties(),
+      Modes.I.protobuf(),
       Modes.I.puppet(),
       Modes.I.python(),
+      Modes.I.q(),
       Modes.I.r(),
+      Modes.I.rpm(),
       Modes.I.rst(),
       Modes.I.ruby(),
+      Modes.I.rust(),
+      Modes.I.sas(),
+      Modes.I.sass(),
       Modes.I.scheme(),
       Modes.I.shell(),
       Modes.I.smalltalk(),
+      Modes.I.smarty(),
+      Modes.I.solr(),
       Modes.I.soy(),
+      Modes.I.sparql(),
+      Modes.I.spreadsheet(),
       Modes.I.sql(),
       Modes.I.stex(),
+      Modes.I.stylus(),
+      Modes.I.swift(),
       Modes.I.tcl(),
+      Modes.I.textile(),
+      Modes.I.tiddlywiki(),
+      Modes.I.tiki(),
+      Modes.I.toml(),
+      Modes.I.tornado(),
+      Modes.I.troff(),
+      Modes.I.ttcn_cfg(),
+      Modes.I.ttcn(),
+      Modes.I.turtle(),
+      Modes.I.twig(),
+      Modes.I.vb(),
+      Modes.I.vbscript(),
       Modes.I.velocity(),
       Modes.I.verilog(),
       Modes.I.vhdl(),
+      Modes.I.vue(),
+      Modes.I.webidl(),
       Modes.I.xml(),
+      Modes.I.xquery(),
+      Modes.I.yacas(),
+      Modes.I.yaml_frontmatter(),
       Modes.I.yaml(),
+      Modes.I.z80(),
     });
 
     alias("application/x-httpd-php-open", "application/x-httpd-php");
@@ -187,7 +264,7 @@
   public final native JsArrayString mimes()
   /*-{ return this.mimes || [this.mime] }-*/;
 
-  private final native JsArrayString ext()
+  private native JsArrayString ext()
   /*-{ return this.ext || [] }-*/;
 
   protected ModeInfo() {
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
index 42990b8..668a57f 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
@@ -20,50 +20,129 @@
 import com.google.gwt.resources.client.DataResource.DoNotEmbed;
 
 public interface Modes extends ClientBundle {
-  public static final Modes I = GWT.create(Modes.class);
+  Modes I = GWT.create(Modes.class);
 
+  @Source("apl.js") @DoNotEmbed DataResource apl();
+  @Source("asciiarmor.js") @DoNotEmbed DataResource asciiarmor();
+  @Source("asn.1.js") @DoNotEmbed DataResource asn_1();
+  @Source("asterisk.js") @DoNotEmbed DataResource asterisk();
+  @Source("brainfuck.js") @DoNotEmbed DataResource brainfuck();
   @Source("clike.js") @DoNotEmbed DataResource clike();
   @Source("clojure.js") @DoNotEmbed DataResource clojure();
+  @Source("cmake.js") @DoNotEmbed DataResource cmake();
+  @Source("cobol.js") @DoNotEmbed DataResource cobol();
   @Source("coffeescript.js") @DoNotEmbed DataResource coffeescript();
   @Source("commonlisp.js") @DoNotEmbed DataResource commonlisp();
+  @Source("crystal.js") @DoNotEmbed DataResource crystal();
   @Source("css.js") @DoNotEmbed DataResource css();
+  @Source("cypher.js") @DoNotEmbed DataResource cypher();
   @Source("d.js") @DoNotEmbed DataResource d();
   @Source("dart.js") @DoNotEmbed DataResource dart();
   @Source("diff.js") @DoNotEmbed DataResource diff();
+  @Source("django.js") @DoNotEmbed DataResource django();
   @Source("dockerfile.js") @DoNotEmbed DataResource dockerfile();
   @Source("dtd.js") @DoNotEmbed DataResource dtd();
+  @Source("dylan.js") @DoNotEmbed DataResource dylan();
+  @Source("ebnf.js") @DoNotEmbed DataResource ebnf();
+  @Source("ecl.js") @DoNotEmbed DataResource ecl();
+  @Source("eiffel.js") @DoNotEmbed DataResource eiffel();
+  @Source("elm.js") @DoNotEmbed DataResource elm();
   @Source("erlang.js") @DoNotEmbed DataResource erlang();
+  @Source("factor.js") @DoNotEmbed DataResource factor();
+  @Source("fcl.js") @DoNotEmbed DataResource fcl();
+  @Source("forth.js") @DoNotEmbed DataResource forth();
+  @Source("fortran.js") @DoNotEmbed DataResource fortran();
   @Source("gas.js") @DoNotEmbed DataResource gas();
   @Source("gerrit/commit.js") @DoNotEmbed DataResource gerrit_commit();
   @Source("gfm.js") @DoNotEmbed DataResource gfm();
+  @Source("gherkin.js") @DoNotEmbed DataResource gherkin();
   @Source("go.js") @DoNotEmbed DataResource go();
   @Source("groovy.js") @DoNotEmbed DataResource groovy();
+  @Source("haml.js") @DoNotEmbed DataResource haml();
+  @Source("handlebars.js") @DoNotEmbed DataResource handlebars();
+  @Source("haskell-literate.js") @DoNotEmbed DataResource haskell_literate();
   @Source("haskell.js") @DoNotEmbed DataResource haskell();
+  @Source("haxe.js") @DoNotEmbed DataResource haxe();
+  @Source("htmlembedded.js") @DoNotEmbed DataResource htmlembedded();
   @Source("htmlmixed.js") @DoNotEmbed DataResource htmlmixed();
+  @Source("http.js") @DoNotEmbed DataResource http();
+  @Source("idl.js") @DoNotEmbed DataResource idl();
+  @Source("jade.js") @DoNotEmbed DataResource jade();
   @Source("javascript.js") @DoNotEmbed DataResource javascript();
+  @Source("jinja2.js") @DoNotEmbed DataResource jinja2();
+  @Source("jsx.js") @DoNotEmbed DataResource jsx();
+  @Source("julia.js") @DoNotEmbed DataResource julia();
+  @Source("livescript.js") @DoNotEmbed DataResource livescript();
   @Source("lua.js") @DoNotEmbed DataResource lua();
   @Source("markdown.js") @DoNotEmbed DataResource markdown();
+  @Source("mathematica.js") @DoNotEmbed DataResource mathematica();
+  @Source("mbox.js") @DoNotEmbed DataResource mbox();
+  @Source("mirc.js") @DoNotEmbed DataResource mirc();
+  @Source("mllike.js") @DoNotEmbed DataResource mllike();
+  @Source("modelica.js") @DoNotEmbed DataResource modelica();
+  @Source("mscgen.js") @DoNotEmbed DataResource mscgen();
+  @Source("mumps.js") @DoNotEmbed DataResource mumps();
+  @Source("nginx.js") @DoNotEmbed DataResource nginx();
+  @Source("nsis.js") @DoNotEmbed DataResource nsis();
+  @Source("ntriples.js") @DoNotEmbed DataResource ntriples();
+  @Source("octave.js") @DoNotEmbed DataResource octave();
+  @Source("oz.js") @DoNotEmbed DataResource oz();
+  @Source("pascal.js") @DoNotEmbed DataResource pascal();
+  @Source("pegjs.js") @DoNotEmbed DataResource pegjs();
   @Source("perl.js") @DoNotEmbed DataResource perl();
   @Source("php.js") @DoNotEmbed DataResource php();
   @Source("pig.js") @DoNotEmbed DataResource pig();
+  @Source("powershell.js") @DoNotEmbed DataResource powershell();
   @Source("properties.js") @DoNotEmbed DataResource properties();
+  @Source("protobuf.js") @DoNotEmbed DataResource protobuf();
   @Source("puppet.js") @DoNotEmbed DataResource puppet();
   @Source("python.js") @DoNotEmbed DataResource python();
+  @Source("q.js") @DoNotEmbed DataResource q();
   @Source("r.js") @DoNotEmbed DataResource r();
+  @Source("rpm.js") @DoNotEmbed DataResource rpm();
   @Source("rst.js") @DoNotEmbed DataResource rst();
   @Source("ruby.js") @DoNotEmbed DataResource ruby();
+  @Source("rust.js") @DoNotEmbed DataResource rust();
+  @Source("sas.js") @DoNotEmbed DataResource sas();
+  @Source("sass.js") @DoNotEmbed DataResource sass();
   @Source("scheme.js") @DoNotEmbed DataResource scheme();
   @Source("shell.js") @DoNotEmbed DataResource shell();
+  @Source("sieve.js") @DoNotEmbed DataResource sieve();
+  @Source("slim.js") @DoNotEmbed DataResource slim();
   @Source("smalltalk.js") @DoNotEmbed DataResource smalltalk();
+  @Source("smarty.js") @DoNotEmbed DataResource smarty();
+  @Source("solr.js") @DoNotEmbed DataResource solr();
   @Source("soy.js") @DoNotEmbed DataResource soy();
+  @Source("sparql.js") @DoNotEmbed DataResource sparql();
+  @Source("spreadsheet.js") @DoNotEmbed DataResource spreadsheet();
   @Source("sql.js") @DoNotEmbed DataResource sql();
   @Source("stex.js") @DoNotEmbed DataResource stex();
+  @Source("stylus.js") @DoNotEmbed DataResource stylus();
+  @Source("swift.js") @DoNotEmbed DataResource swift();
   @Source("tcl.js") @DoNotEmbed DataResource tcl();
+  @Source("textile.js") @DoNotEmbed DataResource textile();
+  @Source("tiddlywiki.js") @DoNotEmbed DataResource tiddlywiki();
+  @Source("tiki.js") @DoNotEmbed DataResource tiki();
+  @Source("toml.js") @DoNotEmbed DataResource toml();
+  @Source("tornado.js") @DoNotEmbed DataResource tornado();
+  @Source("troff.js") @DoNotEmbed DataResource troff();
+  @Source("ttcn-cfg.js") @DoNotEmbed DataResource ttcn_cfg();
+  @Source("ttcn.js") @DoNotEmbed DataResource ttcn();
+  @Source("turtle.js") @DoNotEmbed DataResource turtle();
+  @Source("twig.js") @DoNotEmbed DataResource twig();
+  @Source("vb.js") @DoNotEmbed DataResource vb();
+  @Source("vbscript.js") @DoNotEmbed DataResource vbscript();
   @Source("velocity.js") @DoNotEmbed DataResource velocity();
   @Source("verilog.js") @DoNotEmbed DataResource verilog();
   @Source("vhdl.js") @DoNotEmbed DataResource vhdl();
+  @Source("vue.js") @DoNotEmbed DataResource vue();
+  @Source("webidl.js") @DoNotEmbed DataResource webidl();
   @Source("xml.js") @DoNotEmbed DataResource xml();
+  @Source("xquery.js") @DoNotEmbed DataResource xquery();
+  @Source("yacas.js") @DoNotEmbed DataResource yacas();
+  @Source("yaml-frontmatter.js") @DoNotEmbed DataResource yaml_frontmatter();
   @Source("yaml.js") @DoNotEmbed DataResource yaml();
+  @Source("z80.js") @DoNotEmbed DataResource z80();
 
   // When adding a resource, update static initializer in ModeInfo.
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
index c563c0a..20dd8c7 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
@@ -27,12 +27,50 @@
 /** Dynamically loads a known CodeMirror theme's CSS */
 public class ThemeLoader {
   private static final ExternalTextResource[] THEMES = {
+      Themes.I.day_3024(),
+      Themes.I.night_3024(),
+      Themes.I.abcdef(),
+      Themes.I.ambiance(),
+      Themes.I.base16_dark(),
+      Themes.I.base16_light(),
+      Themes.I.bespin(),
+      Themes.I.blackboard(),
+      Themes.I.cobalt(),
+      Themes.I.colorforth(),
+      Themes.I.dracula(),
       Themes.I.eclipse(),
       Themes.I.elegant(),
+      Themes.I.erlang_dark(),
+      Themes.I.hopscotch(),
+      Themes.I.icecoder(),
+      Themes.I.isotope(),
+      Themes.I.lesser_dark(),
+      Themes.I.liquibyte(),
+      Themes.I.material(),
+      Themes.I.mbo(),
+      Themes.I.mdn_like(),
       Themes.I.midnight(),
+      Themes.I.monokai(),
       Themes.I.neat(),
+      Themes.I.neo(),
       Themes.I.night(),
+      Themes.I.paraiso_dark(),
+      Themes.I.paraiso_light(),
+      Themes.I.pastel_on_dark(),
+      Themes.I.railscasts(),
+      Themes.I.rubyblue(),
+      Themes.I.seti(),
+      Themes.I.solarized(),
+      Themes.I.the_matrix(),
+      Themes.I.tomorrow_night_bright(),
+      Themes.I.tomorrow_night_eighties(),
+      Themes.I.ttcn(),
       Themes.I.twilight(),
+      Themes.I.vibrant_ink(),
+      Themes.I.xq_dark(),
+      Themes.I.xq_light(),
+      Themes.I.yeti(),
+      Themes.I.zenburn(),
   };
 
   private static final EnumSet<Theme> loaded = EnumSet.of(Theme.DEFAULT);
@@ -69,7 +107,7 @@
     }
   }
 
-  private static final ExternalTextResource findTheme(Theme theme) {
+  private static ExternalTextResource findTheme(Theme theme) {
     for (ExternalTextResource r : THEMES) {
       if (theme.name().toLowerCase().equals(r.getName())) {
         return r;
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
index ed0ffca..80304a3 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
@@ -19,14 +19,52 @@
 import com.google.gwt.resources.client.ExternalTextResource;
 
 public interface Themes extends ClientBundle {
-  public static final Themes I = GWT.create(Themes.class);
+  Themes I = GWT.create(Themes.class);
 
+  @Source("3024-day.css") ExternalTextResource day_3024();
+  @Source("3024-night.css") ExternalTextResource night_3024();
+  @Source("abcdef.css") ExternalTextResource abcdef();
+  @Source("ambiance.css") ExternalTextResource ambiance();
+  @Source("base16-dark.css") ExternalTextResource base16_dark();
+  @Source("base16-light.css") ExternalTextResource base16_light();
+  @Source("bespin.css") ExternalTextResource bespin();
+  @Source("blackboard.css") ExternalTextResource blackboard();
+  @Source("cobalt.css") ExternalTextResource cobalt();
+  @Source("colorforth.css") ExternalTextResource colorforth();
+  @Source("dracula.css") ExternalTextResource dracula();
   @Source("eclipse.css") ExternalTextResource eclipse();
   @Source("elegant.css") ExternalTextResource elegant();
+  @Source("erlang-dark.css") ExternalTextResource erlang_dark();
+  @Source("hopscotch.css") ExternalTextResource hopscotch();
+  @Source("icecoder.css") ExternalTextResource icecoder();
+  @Source("isotope.css") ExternalTextResource isotope();
+  @Source("lesser-dark.css") ExternalTextResource lesser_dark();
+  @Source("liquibyte.css") ExternalTextResource liquibyte();
+  @Source("material.css") ExternalTextResource material();
+  @Source("mbo.css") ExternalTextResource mbo();
+  @Source("mdn-like.css") ExternalTextResource mdn_like();
   @Source("midnight.css") ExternalTextResource midnight();
+  @Source("monokai.css") ExternalTextResource monokai();
   @Source("neat.css") ExternalTextResource neat();
+  @Source("neo.css") ExternalTextResource neo();
   @Source("night.css") ExternalTextResource night();
+  @Source("paraiso-dark.css") ExternalTextResource paraiso_dark();
+  @Source("paraiso-light.css") ExternalTextResource paraiso_light();
+  @Source("pastel-on-dark.css") ExternalTextResource pastel_on_dark();
+  @Source("railscasts.css") ExternalTextResource railscasts();
+  @Source("rubyblue.css") ExternalTextResource rubyblue();
+  @Source("seti.css") ExternalTextResource seti();
+  @Source("solarized.css") ExternalTextResource solarized();
+  @Source("the-matrix.css") ExternalTextResource the_matrix();
+  @Source("tomorrow-night-bright.css") ExternalTextResource tomorrow_night_bright();
+  @Source("tomorrow-night-eighties.css") ExternalTextResource tomorrow_night_eighties();
+  @Source("ttcn.css") ExternalTextResource ttcn();
   @Source("twilight.css") ExternalTextResource twilight();
+  @Source("vibrant-ink.css") ExternalTextResource vibrant_ink();
+  @Source("xq-dark.css") ExternalTextResource xq_dark();
+  @Source("xq-light.css") ExternalTextResource xq_light();
+  @Source("yeti.css") ExternalTextResource yeti();
+  @Source("zenburn.css") ExternalTextResource zenburn();
 
   // When adding a resource, update:
   // - static initializer in ThemeLoader
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java
deleted file mode 100644
index 0f659c9a..0000000
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client;
-
-import static com.google.gerrit.client.FormatUtil.formatBytes;
-import static org.junit.Assert.assertEquals;
-
-import com.googlecode.gwt.test.GwtModule;
-import com.googlecode.gwt.test.GwtTest;
-
-import org.junit.Ignore;
-import org.junit.Test;
-
-@GwtModule("com.google.gerrit.GerritGwtUI")
-@Ignore
-public class FormatUtilTest extends GwtTest {
-  @Test
-  public void testFormatBytes() {
-    assertEquals("+/- 0 B", formatBytes(0));
-    assertEquals("+27 B", formatBytes(27));
-    assertEquals("+999 B", formatBytes(999));
-    assertEquals("+1000 B", formatBytes(1000));
-    assertEquals("+1023 B", formatBytes(1023));
-    assertEquals("+1.0 KiB", formatBytes(1024));
-    assertEquals("+1.7 KiB", formatBytes(1728));
-    assertEquals("+108.0 KiB", formatBytes(110592));
-    assertEquals("+6.8 MiB", formatBytes(7077888));
-    assertEquals("+432.0 MiB", formatBytes(452984832));
-    assertEquals("+27.0 GiB", formatBytes(28991029248L));
-    assertEquals("+1.7 TiB", formatBytes(1855425871872L));
-    assertEquals("+8.0 EiB", formatBytes(9223372036854775807L));
-
-    assertEquals("-27 B", formatBytes(-27));
-    assertEquals("-1.7 MiB", formatBytes(-1728));
-  }
-}
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/EditIteratorTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/EditIteratorTest.java
deleted file mode 100644
index d751f34..0000000
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/EditIteratorTest.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.diff;
-
-import static org.junit.Assert.assertEquals;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArrayString;
-
-import com.googlecode.gwt.test.GwtModule;
-import com.googlecode.gwt.test.GwtTest;
-
-import net.codemirror.lib.Pos;
-
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Test;
-
-/** Unit tests for EditIterator */
-@GwtModule("com.google.gerrit.GerritGwtUI")
-@Ignore
-// TODO(davido): Enable this again when gwt-test-utils lib is fixed.
-public class EditIteratorTest extends GwtTest {
-  private JsArrayString lines;
-
-  private void assertLineChsEqual(Pos a, Pos b) {
-    assertEquals(a.line() + "," + a.ch(), b.line() + "," + b.ch());
-  }
-
-  @Before
-  public void initialize() {
-    lines = (JsArrayString) JavaScriptObject.createArray();
-    lines.push("1st");
-    lines.push("2nd");
-    lines.push("3rd");
-  }
-
-  @Test
-  public void testNegativeAdvance() {
-    EditIterator i = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(1, 1), i.advance(5));
-    assertLineChsEqual(Pos.create(0, 3), i.advance(-2));
-  }
-
-  @Test
-  public void testNoAdvance() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(0), iter.advance(0));
-  }
-
-  @Test
-  public void testSimpleAdvance() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(0, 1), iter.advance(1));
-  }
-
-  @Test
-  public void testEndsBeforeNewline() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(0, 3), iter.advance(3));
-  }
-
-  @Test
-  public void testEndsOnNewline() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(1), iter.advance(4));
-  }
-
-  @Test
-  public void testAcrossNewline() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(1, 1), iter.advance(5));
-  }
-
-  @Test
-  public void testContinueFromBeforeNewline() {
-    EditIterator iter = new EditIterator(lines, 0);
-    iter.advance(3);
-    assertLineChsEqual(Pos.create(2, 2), iter.advance(7));
-  }
-
-  @Test
-  public void testContinueFromAfterNewline() {
-    EditIterator iter = new EditIterator(lines, 0);
-    iter.advance(4);
-    assertLineChsEqual(Pos.create(2, 2), iter.advance(6));
-  }
-
-  @Test
-  public void testAcrossMultipleLines() {
-    EditIterator iter = new EditIterator(lines, 0);
-    assertLineChsEqual(Pos.create(2, 2), iter.advance(10));
-  }
-}
diff --git a/gerrit-gwtui/src/test/resources/META-INF/gwt-test-utils.properties b/gerrit-gwtui/src/test/resources/META-INF/gwt-test-utils.properties
deleted file mode 100644
index c0cbb30..0000000
--- a/gerrit-gwtui/src/test/resources/META-INF/gwt-test-utils.properties
+++ /dev/null
@@ -1 +0,0 @@
-com.google.gerrit.GerritGwtUI = gwt-module
diff --git a/gerrit-httpd/BUCK b/gerrit-httpd/BUCK
index 3085054..d52963a 100644
--- a/gerrit-httpd/BUCK
+++ b/gerrit-httpd/BUCK
@@ -33,10 +33,10 @@
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
-    '//lib/jgit:jgit-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
     '//lib/log:api',
-    '//lib/lucene:core-and-backward-codecs',
+    '//lib/lucene:lucene-core-and-backward-codecs',
   ],
   provided_deps = ['//lib:servlet-api-3_1'],
   visibility = ['PUBLIC'],
@@ -69,8 +69,8 @@
     '//lib/easymock:easymock',
     '//lib/guice:guice',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
-    '//lib/jgit:junit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
     '//lib/joda:joda-time',
   ],
   source_under_test = [':httpd'],
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
new file mode 100644
index 0000000..1341ad1
--- /dev/null
+++ b/gerrit-httpd/BUILD
@@ -0,0 +1,72 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRCS = glob(
+  ['src/main/java/**/*.java'],
+)
+RESOURCES = glob(['src/main/resources/**/*'])
+
+java_library(
+  name = 'httpd',
+  srcs = SRCS,
+  resources = RESOURCES,
+  deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-gwtexpui:linker_server',
+    '//gerrit-gwtexpui:server',
+    '//gerrit-launcher:launcher',
+    '//gerrit-patch-jgit:server',
+    '//gerrit-prettify:server',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-util-cli:cli',
+    '//gerrit-util-http:http',
+    '//lib:args4j',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm',
+    '//lib:jsch',
+    '//lib:mime-util',
+    '//lib:servlet-api-3_1',
+    '//lib/auto:auto-value',
+    '//lib/commons:codec',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
+    '//lib/log:api',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'httpd_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':httpd',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-util-http:http',
+    '//gerrit-util-http:testutil',
+    '//lib:jimfs',
+    '//lib:junit',
+    '//lib:gson',
+    '//lib:gwtorm',
+    '//lib:guava',
+    '//lib:servlet-api-3_1-without-neverlink',
+    '//lib:truth',
+    '//lib/easymock:easymock',
+    '//lib/guice:guice',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+    '//lib/joda:joda-time',
+  ],
+)
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index a1cfec7..fa2e0e3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
@@ -160,6 +161,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
+    user = identified.create(val.getAccountId());
   }
 
   /** Set the user account for this current request only. */
@@ -177,6 +179,7 @@
       key = null;
       val = null;
       saveCookie();
+      user = anonymousProvider.get();
     }
   }
 
@@ -202,9 +205,9 @@
     }
 
     String path = authConfig.getCookiePath();
-    if (path == null || path.isEmpty()) {
+    if (Strings.isNullOrEmpty(path)) {
       path = request.getContextPath();
-      if (path == null || path.isEmpty()) {
+      if (Strings.isNullOrEmpty(path)) {
         path = "/";
       }
     }
@@ -214,6 +217,12 @@
     }
 
     outCookie = new Cookie(ACCOUNT_COOKIE, token);
+
+    String domain = authConfig.getCookieDomain();
+    if (!Strings.isNullOrEmpty(domain)) {
+      outCookie.setDomain(domain);
+    }
+
     outCookie.setSecure(isSecure(request));
     outCookie.setPath(path);
     outCookie.setMaxAge(ageSeconds);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
index adfe86c..c1a0f44 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
@@ -14,20 +14,36 @@
 
 package com.google.gerrit.httpd;
 
+import org.eclipse.jgit.lib.Config;
+
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
+  private final boolean enablePolyGerrit;
+  private final boolean forcePolyGerritDev;
 
-  public GerritOptions(boolean headless, boolean slave) {
+  public GerritOptions(Config cfg, boolean headless, boolean slave,
+      boolean forcePolyGerritDev) {
     this.headless = headless;
     this.slave = slave;
+    this.enablePolyGerrit = forcePolyGerritDev
+        || cfg.getBoolean("gerrit", null, "enablePolyGerrit", false);
+    this.forcePolyGerritDev = forcePolyGerritDev;
   }
 
   public boolean enableDefaultUi() {
-    return !headless;
+    return !headless && !enablePolyGerrit;
   }
 
   public boolean enableMasterFeatures() {
     return !slave;
   }
+
+  public boolean enablePolyGerrit() {
+    return !headless && enablePolyGerrit;
+  }
+
+  public boolean forcePolyGerritDev() {
+    return !headless && forcePolyGerritDev;
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
index e1810ef..c85bb7a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.gerrit.reviewdb.client.AuthType.OAUTH;
+import static com.google.gerrit.httpd.plugins.LfsPluginServlet.LFS_REST;
+
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
@@ -24,6 +27,9 @@
 
 /** Configures Git access over HTTP with authentication. */
 public class GitOverHttpModule extends ServletModule {
+  private static final String LFS_URL_REGEX =
+      "^(?:(?!/a/))" + LFS_REST;
+
   private final AuthConfig authConfig;
   private final DownloadConfig downloadConfig;
 
@@ -40,7 +46,11 @@
     if (authConfig.isTrustContainerAuth()) {
       authFilter = ContainerAuthFilter.class;
     } else if (authConfig.isGitBasicAuth()) {
-      authFilter = ProjectBasicAuthFilter.class;
+      if (authConfig.getAuthType() == OAUTH) {
+        authFilter = ProjectOAuthFilter.class;
+      } else {
+        authFilter = ProjectBasicAuthFilter.class;
+      }
     } else {
       authFilter = ProjectDigestFilter.class;
     }
@@ -51,10 +61,11 @@
       serveRegex(git).with(GitOverHttpServlet.class);
     }
 
+    filterRegex(LFS_URL_REGEX).through(authFilter);
     filter("/a/*").through(authFilter);
   }
 
-  private boolean isHttpEnabled(){
+  private boolean isHttpEnabled() {
     return downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
         || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP);
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 89d62dd..bae6d19 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -16,6 +16,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -23,17 +24,16 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
-import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ReceiveCommits;
-import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.AbstractModule;
@@ -50,8 +50,8 @@
 import org.eclipse.jgit.http.server.resolver.AsIsFileService;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.PostReceiveHookChain;
+import org.eclipse.jgit.transport.PostUploadHook;
+import org.eclipse.jgit.transport.PostUploadHookChain;
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
@@ -101,7 +101,7 @@
 
     private final boolean enableReceive;
 
-    public Module(boolean enableReceive) {
+    Module(boolean enableReceive) {
       this.enableReceive = enableReceive;
     }
 
@@ -183,9 +183,8 @@
       if (!pc.isVisible()) {
         if (user instanceof AnonymousUser) {
           throw new ServiceNotAuthorizedException();
-        } else {
-          throw new ServiceNotEnabledException();
         }
+        throw new ServiceNotEnabledException();
       }
       req.setAttribute(ATT_CONTROL, pc);
 
@@ -201,11 +200,15 @@
   static class UploadFactory implements UploadPackFactory<HttpServletRequest> {
     private final TransferConfig config;
     private final DynamicSet<PreUploadHook> preUploadHooks;
+    private final DynamicSet<PostUploadHook> postUploadHooks;
 
     @Inject
-    UploadFactory(TransferConfig tc, DynamicSet<PreUploadHook> preUploadHooks) {
+    UploadFactory(TransferConfig tc,
+        DynamicSet<PreUploadHook> preUploadHooks,
+        DynamicSet<PostUploadHook> postUploadHooks) {
       this.config = tc;
       this.preUploadHooks = preUploadHooks;
+      this.postUploadHooks = postUploadHooks;
     }
 
     @Override
@@ -215,6 +218,8 @@
       up.setTimeout(config.getTimeout());
       up.setPreUploadHook(PreUploadHookChain.newChain(
           Lists.newArrayList(preUploadHooks)));
+      up.setPostUploadHook(
+          PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
       return up;
     }
   }
@@ -222,14 +227,18 @@
   static class UploadFilter implements Filter {
     private final Provider<ReviewDb> db;
     private final TagCache tagCache;
-    private final ChangeCache changeCache;
+    private final ChangeNotes.Factory changeNotesFactory;
+    @Nullable private final SearchingChangeCacheImpl changeCache;
     private final UploadValidators.Factory uploadValidatorsFactory;
 
     @Inject
-    UploadFilter(Provider<ReviewDb> db, TagCache tagCache, ChangeCache changeCache,
+    UploadFilter(Provider<ReviewDb> db, TagCache tagCache,
+        ChangeNotes.Factory changeNotesFactory,
+        @Nullable SearchingChangeCacheImpl changeCache,
         UploadValidators.Factory uploadValidatorsFactory) {
       this.db = db;
       this.tagCache = tagCache;
+      this.changeNotesFactory = changeNotesFactory;
       this.changeCache = changeCache;
       this.uploadValidatorsFactory = uploadValidatorsFactory;
     }
@@ -254,10 +263,8 @@
           uploadValidatorsFactory.create(pc.getProject(), repo, request.getRemoteHost());
       up.setPreUploadHook(PreUploadHookChain.newChain(
           Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      if (!pc.allRefsAreVisible()) {
-        up.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeCache,
-            repo, pc, db.get(), true));
-      }
+      up.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeNotesFactory,
+          changeCache, repo, pc, db.get(), true));
 
       next.doFilter(request, response);
     }
@@ -273,18 +280,10 @@
 
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     private final AsyncReceiveCommits.Factory factory;
-    private final TransferConfig config;
-    private DynamicSet<ReceivePackInitializer> receivePackInitializers;
-    private DynamicSet<PostReceiveHook> postReceiveHooks;
 
     @Inject
-    ReceiveFactory(AsyncReceiveCommits.Factory factory, TransferConfig config,
-        DynamicSet<ReceivePackInitializer> receivePackInitializers,
-        DynamicSet<PostReceiveHook> postReceiveHooks) {
+    ReceiveFactory(AsyncReceiveCommits.Factory factory) {
       this.factory = factory;
-      this.config = config;
-      this.receivePackInitializers = receivePackInitializers;
-      this.postReceiveHooks = postReceiveHooks;
     }
 
     @Override
@@ -297,24 +296,13 @@
         throw new ServiceNotAuthorizedException();
       }
 
-      final IdentifiedUser user = pc.getUser().asIdentifiedUser();
-      final ReceiveCommits rc = factory.create(pc, db).getReceiveCommits();
+      ReceiveCommits rc = factory.create(pc, db).getReceiveCommits();
+      rc.init();
+
       ReceivePack rp = rc.getReceivePack();
-      rp.setRefLogIdent(user.newRefLogIdent());
-      rp.setTimeout(config.getTimeout());
-      rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
-      init(pc.getProject().getNameKey(), rp);
-      rp.setPostReceiveHook(PostReceiveHookChain.newChain(
-          Lists.newArrayList(postReceiveHooks)));
       req.setAttribute(ATT_RC, rc);
       return rp;
     }
-
-    private void init(Project.NameKey project, ReceivePack rp) {
-      for (ReceivePackInitializer initializer : receivePackInitializers) {
-        initializer.init(project, rp);
-      }
-    }
   }
 
   static class DisabledReceiveFactory implements
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
index 5196e9c..0492e86 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
@@ -60,9 +60,8 @@
           // an HTTP request scope. Callers must handle null.
           //
           return null;
-        } else {
-          throw noWeb;
         }
+        throw noWeb;
       }
       return CanonicalWebUrl.computeFromRequest(req);
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
index 177ff04..a4874a9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
@@ -23,13 +23,12 @@
 public class LoginUrlToken {
   private static final String DEFAULT_TOKEN = '#' + PageLinks.MINE;
 
-  public static String getToken(final HttpServletRequest req){
+  public static String getToken(final HttpServletRequest req) {
     String token = req.getPathInfo();
     if (Strings.isNullOrEmpty(token)) {
       return DEFAULT_TOKEN;
-    } else {
-      return CharMatcher.is('/').trimLeadingFrom(token);
     }
+    return CharMatcher.is('/').trimLeadingFrom(token);
   }
 
   private LoginUrlToken() {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 6e6324e..fab0aeb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -19,7 +19,9 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
@@ -140,12 +142,16 @@
       return false;
     }
 
-    if (!authConfig.isLdapAuthType()
-        && !passwordMatchesTheUserGeneratedOne(who, username, password)) {
-      log.warn("Authentication failed for " + username
-          + ": password does not match the one stored in Gerrit");
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
+    GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
+        || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
+      if (passwordMatchesTheUserGeneratedOne(who, username, password)) {
+        return succeedAuthentication(who);
+      }
+    }
+
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP) {
+      return failAuthentication(rsp, username);
     }
 
     AuthRequest whoAuth = AuthRequest.forUser(username);
@@ -153,23 +159,15 @@
 
     try {
       AuthResult whoAuthResult = accountManager.authenticate(whoAuth);
-      WebSession ws = session.get();
-      ws.setUserAccountId(whoAuthResult.getAccountId());
-      ws.setAccessPathOk(AccessPath.GIT, true);
-      ws.setAccessPathOk(AccessPath.REST_API, true);
+      setUserIdentified(whoAuthResult.getAccountId());
       return true;
     } catch (NoSuchUserException e) {
       if (password.equals(who.getPassword(who.getUserName()))) {
-        WebSession ws = session.get();
-        ws.setUserAccountId(who.getAccount().getId());
-        ws.setAccessPathOk(AccessPath.GIT, true);
-        ws.setAccessPathOk(AccessPath.REST_API, true);
-        return true;
-      } else {
-        log.warn("Authentication failed for " + username, e);
-        rsp.sendError(SC_UNAUTHORIZED);
-        return false;
+        return succeedAuthentication(who);
       }
+      log.warn("Authentication failed for " + username, e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
     } catch (AuthenticationFailedException e) {
       log.warn("Authentication failed for " + username + ": " + e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
@@ -181,6 +179,26 @@
     }
   }
 
+  private boolean succeedAuthentication(final AccountState who) {
+    setUserIdentified(who.getAccount().getId());
+    return true;
+  }
+
+  private boolean failAuthentication(Response rsp, String username)
+      throws IOException {
+    log.warn("Authentication failed for {}: password does not match the one"
+        + " stored in Gerrit", username);
+    rsp.sendError(SC_UNAUTHORIZED);
+    return false;
+  }
+
+  private void setUserIdentified(Account.Id id) {
+    WebSession ws = session.get();
+    ws.setUserAccountId(id);
+    ws.setAccessPathOk(AccessPath.GIT, true);
+    ws.setAccessPathOk(AccessPath.REST_API, true);
+  }
+
   private boolean passwordMatchesTheUserGeneratedOne(AccountState who,
       String username, String password) {
     String accountPassword = who.getPassword(username);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
index 38dd118..f66f397 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
@@ -171,21 +171,19 @@
           ws.setAccessPathOk(AccessPath.REST_API, true);
           return true;
 
-        } else {
-          rsp.stale = true;
-          rsp.sendError(SC_UNAUTHORIZED);
-          return false;
         }
+        rsp.stale = true;
+        rsp.sendError(SC_UNAUTHORIZED);
+        return false;
       } catch (XsrfException e) {
         context.log("Error validating nonce for digest authentication", e);
         rsp.sendError(SC_INTERNAL_SERVER_ERROR);
         return false;
       }
 
-    } else {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
     }
+    rsp.sendError(SC_UNAUTHORIZED);
+    return false;
   }
 
   private static String H(String data) {
@@ -212,7 +210,7 @@
 
   private static final char[] LHEX =
       {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', //
-          'a', 'b', 'c', 'd', 'e', 'f'};
+          'a', 'b', 'c', 'd', 'e', 'f',};
 
   private static String LHEX(byte[] bin) {
     StringBuilder r = new StringBuilder(bin.length * 2);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
new file mode 100644
index 0000000..7cadbae37
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -0,0 +1,352 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.Locale;
+import java.util.NoSuchElementException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+/**
+ * Authenticates the current user with an OAuth2 server.
+ *
+ * @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
+ */
+@Singleton
+class ProjectOAuthFilter implements Filter {
+
+  private static final Logger log = LoggerFactory
+      .getLogger(ProjectOAuthFilter.class);
+
+  private static final String REALM_NAME = "Gerrit Code Review";
+  private static final String AUTHORIZATION = "Authorization";
+  private static final String BASIC = "Basic ";
+  private static final String GIT_COOKIE_PREFIX = "git-";
+
+  private final DynamicItem<WebSession> session;
+  private final DynamicMap<OAuthLoginProvider> loginProviders;
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final String gitOAuthProvider;
+  private final boolean userNameToLowerCase;
+
+  private String defaultAuthPlugin;
+  private String defaultAuthProvider;
+
+  @Inject
+  ProjectOAuthFilter(DynamicItem<WebSession> session,
+      DynamicMap<OAuthLoginProvider> pluginsProvider,
+      AccountCache accountCache,
+      AccountManager accountManager,
+      @GerritServerConfig Config gerritConfig) {
+    this.session = session;
+    this.loginProviders = pluginsProvider;
+    this.accountCache = accountCache;
+    this.accountManager = accountManager;
+    this.gitOAuthProvider =
+        gerritConfig.getString("auth", null, "gitOAuthProvider");
+    this.userNameToLowerCase =
+        gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
+  }
+
+  @Override
+  public void init(FilterConfig config) throws ServletException {
+    if (Strings.isNullOrEmpty(gitOAuthProvider)) {
+      pickOnlyProvider();
+    } else {
+      pickConfiguredProvider();
+    }
+  }
+
+  @Override
+  public void destroy() {
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    HttpServletRequest req = (HttpServletRequest) request;
+    Response rsp = new Response((HttpServletResponse) response);
+    if (verify(req, rsp)) {
+      chain.doFilter(req, rsp);
+    }
+  }
+
+  private boolean verify(HttpServletRequest req, Response rsp)
+      throws IOException {
+    AuthInfo authInfo = null;
+
+    // first check if there is a BASIC authentication header
+    String hdr = req.getHeader(AUTHORIZATION);
+    if (hdr != null && hdr.startsWith(BASIC)) {
+      authInfo = extractAuthInfo(hdr, encoding(req));
+      if (authInfo == null) {
+        rsp.sendError(SC_UNAUTHORIZED);
+        return false;
+      }
+    } else {
+      // if there is no BASIC authentication header, check if there is
+      // a cookie starting with the prefix "git-"
+      Cookie cookie = findGitCookie(req);
+      if (cookie != null) {
+        authInfo = extractAuthInfo(cookie);
+        if (authInfo == null) {
+          rsp.sendError(SC_UNAUTHORIZED);
+          return false;
+        }
+      } else {
+        // if there is no authentication information at all, it might be
+        // an anonymous connection, or there might be a session cookie
+        return true;
+      }
+    }
+
+    // if there is authentication information but no secret => 401
+    if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AccountState who = accountCache.getByUsername(authInfo.username);
+    if (who == null || !who.getAccount().isActive()) {
+      log.warn("Authentication failed for " + authInfo.username
+          + ": account inactive or not provisioned in Gerrit");
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AuthRequest authRequest = AuthRequest.forExternalUser(
+        authInfo.username);
+    authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
+    authRequest.setDisplayName(who.getAccount().getFullName());
+    authRequest.setPassword(authInfo.tokenOrSecret);
+    authRequest.setAuthPlugin(authInfo.pluginName);
+    authRequest.setAuthProvider(authInfo.exportName);
+
+    try {
+      AuthResult authResult = accountManager.authenticate(authRequest);
+      WebSession ws = session.get();
+      ws.setUserAccountId(authResult.getAccountId());
+      ws.setAccessPathOk(AccessPath.GIT, true);
+      ws.setAccessPathOk(AccessPath.REST_API, true);
+      return true;
+    } catch (AccountException e) {
+      log.warn("Authentication failed for " + authInfo.username, e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+  }
+
+  /**
+   * Picks the only installed OAuth provider. If there is a multiude
+   * of providers available, the actual provider must be determined
+   * from the authentication request.
+   *
+   * @throws ServletException if there is no {@code OAuthLoginProvider}
+   * installed at all.
+   */
+  private void pickOnlyProvider() throws ServletException {
+    try {
+      Entry<OAuthLoginProvider> loginProvider =
+          Iterables.getOnlyElement(loginProviders);
+      defaultAuthPlugin = loginProvider.getPluginName();
+      defaultAuthProvider = loginProvider.getExportName();
+    } catch (NoSuchElementException e) {
+      throw new ServletException("No OAuth login provider installed");
+    } catch (IllegalArgumentException e) {
+      // multiple providers found => do not pick any
+    }
+  }
+
+  /**
+   * Picks the {@code OAuthLoginProvider} configured with
+   * <tt>auth.gitOAuthProvider</tt>.
+   *
+   * @throws ServletException if the configured provider was not found.
+   */
+  private void pickConfiguredProvider() throws ServletException {
+    int splitPos = gitOAuthProvider.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      throw new ServletException("OAuth login provider configuration is"
+          + " invalid: Must be of the form pluginName:providerName");
+    }
+    defaultAuthPlugin = gitOAuthProvider.substring(0, splitPos);
+    defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin,
+        defaultAuthProvider);
+    if (provider == null) {
+      throw new ServletException("Configured OAuth login provider "
+          + gitOAuthProvider + " wasn't installed");
+    }
+  }
+
+  private AuthInfo extractAuthInfo(String hdr, String encoding)
+      throws UnsupportedEncodingException {
+    byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
+    String usernamePassword = new String(decoded, encoding);
+    int splitPos = usernamePassword.indexOf(':');
+    if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
+      return null;
+    }
+    return new AuthInfo(usernamePassword.substring(0, splitPos),
+        usernamePassword.substring(splitPos + 1), defaultAuthPlugin,
+        defaultAuthProvider);
+  }
+
+  private AuthInfo extractAuthInfo(Cookie cookie)
+      throws UnsupportedEncodingException {
+    String username = URLDecoder.decode(cookie.getName()
+        .substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
+    String value = cookie.getValue();
+    int splitPos = value.lastIndexOf('@');
+    if (splitPos < 1 || splitPos == value.length() - 1) {
+      // no providerId in the cookie value => assume default provider
+      // note: a leading/trailing at sign is considered to belong to
+      // the access token rather than being a separator
+      return new AuthInfo(username, cookie.getValue(),
+          defaultAuthPlugin, defaultAuthProvider);
+    }
+    String token = value.substring(0, splitPos);
+    String providerId = value.substring(splitPos + 1);
+    splitPos = providerId.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == providerId.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      return null;
+    }
+    String pluginName = providerId.substring(0, splitPos);
+    String exportName = providerId.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(pluginName, exportName);
+    if (provider == null) {
+      return null;
+    }
+    return new AuthInfo(username, token, pluginName, exportName);
+  }
+
+  private static String encoding(HttpServletRequest req) {
+    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
+  }
+
+  private static Cookie findGitCookie(HttpServletRequest req) {
+    Cookie[] cookies = req.getCookies();
+    if (cookies != null) {
+      for (Cookie cookie : cookies) {
+        if (cookie.getName().startsWith(GIT_COOKIE_PREFIX)) {
+          return cookie;
+        }
+      }
+    }
+    return null;
+  }
+
+  private class AuthInfo {
+    private final String username;
+    private final String tokenOrSecret;
+    private final String pluginName;
+    private final String exportName;
+
+    private AuthInfo(String username, String tokenOrSecret,
+        String pluginName, String exportName) {
+      this.username = userNameToLowerCase
+          ? username.toLowerCase(Locale.US)
+          : username;
+      this.tokenOrSecret = tokenOrSecret;
+      this.pluginName = pluginName;
+      this.exportName = exportName;
+    }
+  }
+
+  private static class Response extends HttpServletResponseWrapper {
+    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+
+    Response(HttpServletResponse rsp) {
+      super(rsp);
+    }
+
+    private void status(int sc) {
+      if (sc == SC_UNAUTHORIZED) {
+        StringBuilder v = new StringBuilder();
+        v.append(BASIC);
+        v.append("realm=\"").append(REALM_NAME).append("\"");
+        setHeader(WWW_AUTHENTICATE, v.toString());
+      } else if (containsHeader(WWW_AUTHENTICATE)) {
+        setHeader(WWW_AUTHENTICATE, null);
+      }
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 7116cf0..479a5e5 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -51,11 +51,10 @@
       // the identity out of the Authorization header and honor it.
       String auth = req.getHeader(AUTHORIZATION);
       return extractUsername(auth);
-    } else {
-      // Nonstandard HTTP header. We have been told to trust this
-      // header blindly as-is.
-      return emptyToNull(req.getHeader(loginHeader));
     }
+    // Nonstandard HTTP header. We have been told to trust this
+    // header blindly as-is.
+    return emptyToNull(req.getHeader(loginHeader));
   }
 
   /**
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
new file mode 100644
index 0000000..ec193c9
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RequestMetrics {
+  final Counter1<Integer> errors;
+  final Counter1<Integer> successes;
+
+  @Inject
+  public RequestMetrics(MetricMaker metricMaker) {
+    errors = metricMaker.newCounter(
+        "http/server/error_count",
+        new Description("Rate of REST API error responses")
+          .setRate()
+          .setUnit("errors"),
+        Field.ofInteger("status", "HTTP status code"));
+    successes = metricMaker.newCounter(
+        "http/server/success_count",
+        new Description("Rate of REST API success responses")
+          .setRate()
+          .setUnit("successes"),
+        Field.ofInteger("status", "HTTP status code"));
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
new file mode 100644
index 0000000..48b2a2f
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+
+import java.io.IOException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+@Singleton
+public class RequestMetricsFilter implements Filter {
+  public static Module module() {
+    return new ServletModule() {
+      @Override
+      protected void configureServlets() {
+        filter("/*").through(RequestMetricsFilter.class);
+      }
+    };
+  }
+
+  private final RequestMetrics metrics;
+
+  @Inject
+  RequestMetricsFilter(RequestMetrics metrics) {
+    this.metrics = metrics;
+  }
+
+  @Override
+  public void destroy() {
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    Response rsp = new Response((HttpServletResponse) response, metrics);
+
+    chain.doFilter(request, rsp);
+  }
+
+  @Override
+  public void init(FilterConfig cfg) throws ServletException {
+  }
+
+  private static class Response extends HttpServletResponseWrapper {
+    private final RequestMetrics metrics;
+
+    Response(HttpServletResponse response, RequestMetrics metrics) {
+      super(response);
+      this.metrics = metrics;
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+
+    private void status(int sc) {
+      if (sc >= SC_BAD_REQUEST) {
+        metrics.errors.increment(sc);
+      } else {
+        metrics.successes.increment(sc);
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
index 92809c0..4cb8e92 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -34,11 +34,20 @@
 
 /** Requires the connection to use SSL, redirects if not. */
 @Singleton
-class RequireSslFilter implements Filter {
-  static class Module extends ServletModule {
+public class RequireSslFilter implements Filter {
+  public static class Module extends ServletModule {
+    private final boolean wantSsl;
+
+    @Inject
+    Module(@Nullable @CanonicalWebUrl String canonicalUrl) {
+      this.wantSsl = canonicalUrl != null && canonicalUrl.startsWith("https:");
+    }
+
     @Override
     protected void configureServlets() {
-      filter("/*").through(RequireSslFilter.class);
+      if (wantSsl) {
+        filter("/*").through(RequireSslFilter.class);
+      }
     }
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index f6b79e5..210800d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -20,11 +20,13 @@
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
 
@@ -55,14 +57,17 @@
     }
   }
 
+  private final Provider<ReviewDb> db;
   private final boolean enabled;
   private final DynamicItem<WebSession> session;
   private final AccountResolver accountResolver;
 
   @Inject
-  RunAsFilter(AuthConfig config,
+  RunAsFilter(Provider<ReviewDb> db,
+      AuthConfig config,
       DynamicItem<WebSession> session,
       AccountResolver accountResolver) {
+    this.db = db;
     this.enabled = config.isRunAsEnabled();
     this.session = session;
     this.accountResolver = accountResolver;
@@ -95,7 +100,7 @@
 
       Account target;
       try {
-        target = accountResolver.find(runas);
+        target = accountResolver.find(db.get(), runas);
       } catch (OrmException e) {
         log.warn("cannot resolve account for " + RUN_AS, e);
         replyError(req, res,
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
index 4b3eca7..2c67182 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
@@ -22,15 +22,14 @@
 import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.httpd.raw.LegacyGerritServlet;
 import com.google.gerrit.httpd.raw.SshInfoServlet;
-import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.httpd.raw.ToolServlet;
-import com.google.gerrit.httpd.rpc.access.AccessRestApiServlet;
-import com.google.gerrit.httpd.rpc.account.AccountsRestApiServlet;
-import com.google.gerrit.httpd.rpc.change.ChangesRestApiServlet;
-import com.google.gerrit.httpd.rpc.config.ConfigRestApiServlet;
+import com.google.gerrit.httpd.restapi.AccessRestApiServlet;
+import com.google.gerrit.httpd.restapi.AccountsRestApiServlet;
+import com.google.gerrit.httpd.restapi.ChangesRestApiServlet;
+import com.google.gerrit.httpd.restapi.ConfigRestApiServlet;
+import com.google.gerrit.httpd.restapi.GroupsRestApiServlet;
+import com.google.gerrit.httpd.restapi.ProjectsRestApiServlet;
 import com.google.gerrit.httpd.rpc.doc.QueryDocumentationFilter;
-import com.google.gerrit.httpd.rpc.group.GroupsRestApiServlet;
-import com.google.gerrit.httpd.rpc.project.ProjectsRestApiServlet;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -64,9 +63,12 @@
     bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
 
     if (options.enableDefaultUi()) {
+      filter("/").through(XsrfCookieFilter.class);
       serve("/").with(HostPageServlet.class);
       serve("/Gerrit").with(LegacyGerritServlet.class);
       serve("/Gerrit/*").with(legacyGerritScreen());
+      // Forward PolyGerrit URLs to their respective GWT equivalents.
+      serveRegex("^/(c|q|x|admin|dashboard|settings)/(.*)").with(gerritUrl());
     }
     serve("/cat/*").with(CatServlet.class);
 
@@ -103,8 +105,6 @@
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
 
     filter("/Documentation/").through(QueryDocumentationFilter.class);
-
-    install(new StaticModule());
   }
 
   private Key<HttpServlet> notFound() {
@@ -119,6 +119,18 @@
     });
   }
 
+  private Key<HttpServlet> gerritUrl() {
+    return key(new HttpServlet() {
+      private static final long serialVersionUID = 1L;
+
+      @Override
+      protected void doGet(final HttpServletRequest req,
+          final HttpServletResponse rsp) throws IOException {
+        toGerrit(req.getRequestURI(), req, rsp);
+      }
+    });
+  }
+
   private Key<HttpServlet> screen(final String target) {
     return key(new HttpServlet() {
       private static final long serialVersionUID = 1L;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
index 9e425e9..3e3b7c4 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
@@ -16,9 +16,6 @@
 
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.httpd.auth.become.BecomeAnyAccountModule;
 import com.google.gerrit.httpd.auth.container.HttpAuthModule;
 import com.google.gerrit.httpd.auth.container.HttpsClientSslCertModule;
@@ -28,7 +25,6 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
@@ -42,17 +38,14 @@
 
 public class WebModule extends LifecycleModule {
   private final AuthConfig authConfig;
-  private final boolean wantSSL;
   private final GitwebCgiConfig gitwebCgiConfig;
   private final GerritOptions options;
 
   @Inject
   WebModule(AuthConfig authConfig,
-      @CanonicalWebUrl @Nullable String canonicalUrl,
       GerritOptions options,
       GitwebCgiConfig gitwebCgiConfig) {
     this.authConfig = authConfig;
-    this.wantSSL = canonicalUrl != null && canonicalUrl.startsWith("https:");
     this.options = options;
     this.gitwebCgiConfig = gitwebCgiConfig;
   }
@@ -62,9 +55,6 @@
     bind(RequestScopePropagator.class).to(GuiceRequestScopePropagator.class);
     bind(HttpRequestContext.class);
 
-    if (wantSSL) {
-      install(new RequireSslFilter.Module());
-    }
     install(new RunAsFilter.Module());
 
     installAuthModule();
@@ -79,8 +69,6 @@
       install(new GitwebModule());
     }
 
-    DynamicSet.setOf(binder(), WebUiPlugin.class);
-
     install(new AsyncReceiveCommits.Module());
 
     bind(SocketAddress.class).annotatedWith(RemotePeer.class).toProvider(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index b2d32fc..327aaa3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -21,18 +21,18 @@
 import com.google.gerrit.server.account.AuthResult;
 
 public interface WebSession {
-  public boolean isSignedIn();
-  public String getXGerritAuth();
-  public boolean isValidXGerritAuth(String keyIn);
-  public AccountExternalId.Key getLastLoginExternalId();
-  public CurrentUser getUser();
-  public void login(AuthResult res, boolean rememberMe);
+  boolean isSignedIn();
+  String getXGerritAuth();
+  boolean isValidXGerritAuth(String keyIn);
+  AccountExternalId.Key getLastLoginExternalId();
+  CurrentUser getUser();
+  void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
-  public void setUserAccountId(Account.Id id);
-  public boolean isAccessPathOk(AccessPath path);
-  public void setAccessPathOk(AccessPath path, boolean ok);
+  void setUserAccountId(Account.Id id);
+  boolean isAccessPathOk(AccessPath path);
+  void setAccessPathOk(AccessPath path, boolean ok);
 
-  public void logout();
-  public String getSessionId();
+  void logout();
+  String getSessionId();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 0250939..902bf00 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -133,14 +133,13 @@
       // own cache, after which it will certainly be invalid.
       //
       return (int) MILLISECONDS.toSeconds(sessionMaxAgeMillis);
-    } else {
-      // Client should not store the cookie, as the user asked for us
-      // to not remember them long-term. Sending -1 as the age will
-      // cause the cookie to be only for this "browser session", which
-      // is usually until the user exits their browser.
-      //
-      return -1;
     }
+    // Client should not store the cookie, as the user asked for us
+    // to not remember them long-term. Sending -1 as the age will
+    // cause the cookie to be only for this "browser session", which
+    // is usually until the user exits their browser.
+    //
+    return -1;
   }
 
   Val get(final Key key) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
new file mode 100644
index 0000000..842b2b4
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.common.data.HostPageData;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class XsrfCookieFilter implements Filter {
+  private final Provider<CurrentUser> user;
+  private final DynamicItem<WebSession> session;
+  private final AuthConfig authConfig;
+
+  @Inject
+  XsrfCookieFilter(
+      Provider<CurrentUser> user,
+      DynamicItem<WebSession> session,
+      AuthConfig authConfig) {
+    this.user = user;
+    this.session = session;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public void doFilter(ServletRequest req, ServletResponse rsp,
+      FilterChain chain) throws IOException, ServletException {
+    WebSession s = user.get().isIdentifiedUser() ? session.get() : null;
+    setXsrfTokenCookie(
+        (HttpServletRequest) req, (HttpServletResponse) rsp, s);
+    chain.doFilter(req, rsp);
+  }
+
+  private void setXsrfTokenCookie(HttpServletRequest req,
+      HttpServletResponse rsp, WebSession session) {
+    String v = session != null ? session.getXGerritAuth() : "";
+    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
+    c.setPath("/");
+    c.setSecure(authConfig.getCookieSecure() && isSecure(req));
+    c.setMaxAge(session != null
+        ? -1 // Set the cookie for this browser session.
+        : 0); // Remove the cookie (expire immediately).
+    rsp.addCookie(c);
+  }
+
+  private boolean isSecure(HttpServletRequest req) {
+    return req.isSecure() || "https".equals(req.getScheme());
+  }
+
+  @Override
+  public void init(FilterConfig config) {
+  }
+
+  @Override
+  public void destroy() {
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 56c5cbd..8d5aff6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
@@ -58,16 +60,19 @@
   private final DynamicItem<WebSession> webSession;
   private final AccountManager accountManager;
   private final SiteHeaderFooter headers;
+  private final InternalAccountQuery accountQuery;
 
   @Inject
-  BecomeAnyAccountLoginServlet(final DynamicItem<WebSession> ws,
-      final SchemaFactory<ReviewDb> sf,
-      final AccountManager am,
-      SiteHeaderFooter shf) {
+  BecomeAnyAccountLoginServlet(DynamicItem<WebSession> ws,
+      SchemaFactory<ReviewDb> sf,
+      AccountManager am,
+      SiteHeaderFooter shf,
+      InternalAccountQuery aq) {
     webSession = ws;
     schema = sf;
     accountManager = am;
     headers = shf;
+    accountQuery = aq;
   }
 
   @Override
@@ -184,12 +189,25 @@
   }
 
   private AuthResult byUserName(final String userName) {
-    try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key =
+    try {
+      AccountExternalId.Key extKey =
           new AccountExternalId.Key(SCHEME_USERNAME, userName);
-      return auth(db.accountExternalIds().get(key));
+      List<AccountState> accountStates =
+          accountQuery.byExternalId(extKey.get());
+      if (accountStates.isEmpty()) {
+        getServletContext()
+            .log("No accounts with username " + userName + " found");
+        return null;
+      }
+      if (accountStates.size() > 1) {
+        getServletContext()
+            .log("Multiple accounts with username " + userName + " found");
+        return null;
+      }
+      return auth(new AccountExternalId(
+          accountStates.get(0).getAccount().getId(), extKey));
     } catch (OrmException e) {
-      getServletContext().log("cannot query database", e);
+      getServletContext().log("cannot query account index", e);
       return null;
     }
   }
@@ -219,7 +237,7 @@
     }
   }
 
-  private AuthResult create() {
+  private AuthResult create() throws IOException {
     String fakeId = AccountExternalId.SCHEME_UUID + UUID.randomUUID();
     try {
       return accountManager.authenticate(new AuthRequest(fakeId));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index bd7014a..978f081 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -18,6 +18,8 @@
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HtmlDomUtil;
@@ -143,26 +145,24 @@
 
   String getRemoteDisplayname(HttpServletRequest req) {
     if (displaynameHeader != null) {
-      return emptyToNull(req.getHeader(displaynameHeader));
-    } else {
-      return null;
+      String raw = req.getHeader(displaynameHeader);
+      return emptyToNull(new String(raw.getBytes(ISO_8859_1), UTF_8));
     }
+    return null;
   }
 
   String getRemoteEmail(HttpServletRequest req) {
     if (emailHeader != null) {
       return emptyToNull(req.getHeader(emailHeader));
-    } else {
-      return null;
     }
+    return null;
   }
 
   String getRemoteExternalIdToken(HttpServletRequest req) {
-    if(externalIdHeader != null) {
+    if (externalIdHeader != null) {
       return emptyToNull(req.getHeader(externalIdHeader));
-    } else {
-      return null;
     }
+    return null;
   }
 
   String getLoginHeader() {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index bfbf1ff..40e0f60 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -153,7 +153,7 @@
   }
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     AccountExternalId remoteAuthExtId =
         new AccountExternalId(arsp.getAccountId(), new AccountExternalId.Key(
             SCHEME_EXTERNAL, remoteAuthToken));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index b80b69f..4b66023 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -232,7 +232,11 @@
       p.print("    if (( $secure && $ENV{'SERVER_PORT'} != 443)\n");
       p.print("     || (!$secure && $ENV{'SERVER_PORT'} != 80)\n");
       p.print("    );\n");
-      p.print("  $http_url .= qq{$ENV{'GERRIT_CONTEXT_PATH'}p};\n");
+      p.print("  my $context = $ENV{'GERRIT_CONTEXT_PATH'};\n");
+      p.print("  chop($context);\n");
+      p.print("  $http_url .= qq{$context};\n");
+      p.print("  $http_url .= qq{/a}\n");
+      p.print("    unless $ENV{'GERRIT_ANONYMOUS_READ'};\n");
       p.print("  push @git_base_url_list, $http_url;\n");
       p.print("}\n");
 
@@ -314,8 +318,7 @@
         p.print("}\n");
       }
 
-      Path root = repoManager.getBasePath();
-      p.print("$projectroot = " + quoteForPerl(root) + ";\n");
+      p.print("$projectroot = $ENV{'GITWEB_PROJECTROOT'};\n");
 
       // Permit exporting only the project we were started for.
       // We use the name under $projectroot in case symlinks
@@ -546,6 +549,10 @@
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
     env.set("GERRIT_PROJECT_NAME", project.getProject().getName());
 
+    env.set("GITWEB_PROJECTROOT",
+        repoManager.getBasePath(project.getProject().getNameKey())
+            .toAbsolutePath().toString());
+
     if (project.forUser(anonymousUserProvider.get()).isVisible()) {
       env.set("GERRIT_ANONYMOUS_READ", "1");
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
index 6afe52a..b51bfb9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
@@ -25,7 +25,7 @@
   private final String base;
   private final String authorizedBase;
 
-  public ContextMapper(String contextPath) {
+  ContextMapper(String contextPath) {
     base = Strings.nullToEmpty(contextPath) + PLUGINS_PREFIX;
     authorizedBase = Strings.nullToEmpty(contextPath) + AUTHORIZED_PREFIX;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
index 0e81a0d..8ae0e5c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
@@ -14,33 +14,28 @@
 
 package com.google.gerrit.httpd.plugins;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.plugins.AutoRegisterUtil.calculateBindAnnotation;
 
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.annotations.Export;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.webui.JavaScriptPlugin;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.server.plugins.HttpModuleGenerator;
 import com.google.gerrit.server.plugins.InvalidPluginException;
+import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
 import com.google.inject.servlet.ServletModule;
 
 import java.lang.annotation.Annotation;
+import java.util.HashMap;
 import java.util.Map;
 
 import javax.servlet.http.HttpServlet;
 
 class HttpAutoRegisterModuleGenerator extends ServletModule
-    implements HttpModuleGenerator {
-  private final Map<String, Class<HttpServlet>> serve = Maps.newHashMap();
+    implements ModuleGenerator {
+  private final Map<String, Class<HttpServlet>> serve = new HashMap<>();
   private final Multimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
-  private String javascript;
 
   @Override
   protected void configureServlets() {
@@ -58,10 +53,6 @@
       Annotation n = calculateBindAnnotation(impl);
       bind(type).annotatedWith(n).to(impl);
     }
-    if (javascript != null) {
-      DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(
-          new JavaScriptPlugin(javascript));
-    }
   }
 
   @Override
@@ -89,14 +80,6 @@
   }
 
   @Override
-  public void export(String javascript) {
-    checkState(this.javascript == null,
-        "Multiple JavaScript plugins detected: %s, %s", this.javascript,
-        javascript);
-    this.javascript = javascript;
-  }
-
-  @Override
   public void listen(TypeLiteral<?> tl, Class<?> clazz) {
     listeners.put(tl, clazz);
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index 2016942..3c72ec5 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.plugins.HttpModuleGenerator;
+import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.inject.internal.UniqueAnnotations;
@@ -32,6 +32,9 @@
     bind(HttpPluginServlet.class);
     serveRegex("^/(?:a/)?plugins/(.*)?$").with(HttpPluginServlet.class);
 
+    bind(LfsPluginServlet.class);
+    serveRegex(LfsPluginServlet.URL_REGEX).with(LfsPluginServlet.class);
+
     bind(StartPluginListener.class)
       .annotatedWith(UniqueAnnotations.create())
       .to(HttpPluginServlet.class);
@@ -40,7 +43,15 @@
       .annotatedWith(UniqueAnnotations.create())
       .to(HttpPluginServlet.class);
 
-    bind(HttpModuleGenerator.class)
+    bind(StartPluginListener.class)
+      .annotatedWith(UniqueAnnotations.create())
+      .to(LfsPluginServlet.class);
+
+    bind(ReloadPluginListener.class)
+      .annotatedWith(UniqueAnnotations.create())
+      .to(LfsPluginServlet.class);
+
+    bind(ModuleGenerator.class)
       .to(HttpAutoRegisterModuleGenerator.class);
 
     install(new CacheModule() {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 4e635b2..8594e30 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -21,9 +21,11 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.io.ByteStreams;
@@ -66,8 +68,9 @@
 import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Enumeration;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentMap;
@@ -100,7 +103,7 @@
   private final int sshPort;
   private final RestApiServlet managerApi;
 
-  private List<Plugin> pending = Lists.newArrayList();
+  private List<Plugin> pending = new ArrayList<>();
   private ContextMapper wrapper;
   private final ConcurrentMap<String, PluginHolder> plugins
       = Maps.newConcurrentMap();
@@ -125,7 +128,7 @@
       int c = host.lastIndexOf(':');
       if (0 <= c) {
         sshHost = host.substring(0, c);
-        sshPort = Integer.parseInt(host.substring(c+1));
+        sshPort = Integer.parseInt(host.substring(c + 1));
       } else {
         sshHost = host;
         sshPort = 22;
@@ -346,7 +349,7 @@
       int nameOffset) throws IOException {
     if (!entries.isEmpty()) {
       md.append("## ").append(sectionTitle).append(" ##\n");
-      for(PluginEntry entry : entries) {
+      for (PluginEntry entry : entries) {
         String rsrc = entry.getName().substring(prefix.length());
         String entryTitle;
         if (rsrc.endsWith(".html")) {
@@ -367,38 +370,62 @@
   }
 
   private void sendAutoIndex(PluginContentScanner scanner,
-      String prefix, String pluginName,
+      final String prefix, final String pluginName,
       PluginResourceKey cacheKey, HttpServletResponse res,long lastModifiedTime)
       throws IOException {
-    List<PluginEntry> cmds = Lists.newArrayList();
-    List<PluginEntry> servlets = Lists.newArrayList();
-    List<PluginEntry> restApis = Lists.newArrayList();
-    List<PluginEntry> docs = Lists.newArrayList();
+    List<PluginEntry> cmds = new ArrayList<>();
+    List<PluginEntry> servlets = new ArrayList<>();
+    List<PluginEntry> restApis = new ArrayList<>();
+    List<PluginEntry> docs = new ArrayList<>();
     PluginEntry about = null;
-    Enumeration<PluginEntry> entries = scanner.entries();
-    while (entries.hasMoreElements()) {
-      PluginEntry entry = entries.nextElement();
-      String name = entry.getName();
-      Optional<Long> size = entry.getSize();
-      if (name.startsWith(prefix)
-          && (name.endsWith(".md")
-              || name.endsWith(".html"))
-              && size.isPresent()
-          && 0 < size.get() && size.get() <= SMALL_RESOURCE) {
-        name = name.substring(prefix.length());
-        if (name.startsWith("cmd-")) {
-          cmds.add(entry);
-        } else if (name.startsWith("servlet-")) {
-          servlets.add(entry);
-        } else if (name.startsWith("rest-api-")) {
-          restApis.add(entry);
-        } else if (name.startsWith("about.")) {
-          if (about == null) {
-            about = entry;
+
+    Predicate<PluginEntry> filter = new Predicate<PluginEntry>() {
+      @Override
+      public boolean apply(PluginEntry entry) {
+        String name = entry.getName();
+        Optional<Long> size = entry.getSize();
+        if (name.startsWith(prefix)
+            && (name.endsWith(".md") || name.endsWith(".html"))
+            && size.isPresent()) {
+          if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
+            log.warn(String.format(
+                "Plugin %s: %s omitted from document index. "
+                  + "Size %d out of range (0,%d).",
+                pluginName,
+                name.substring(prefix.length()),
+                size.get(),
+                SMALL_RESOURCE));
+            return false;
           }
-        } else {
-          docs.add(entry);
+          return true;
         }
+        return false;
+      }
+    };
+
+    List<PluginEntry> entries = FluentIterable
+        .from(Collections.list(scanner.entries()))
+        .filter(filter)
+        .toList();
+    for (PluginEntry entry: entries) {
+      String name = entry.getName().substring(prefix.length());
+      if (name.startsWith("cmd-")) {
+        cmds.add(entry);
+      } else if (name.startsWith("servlet-")) {
+        servlets.add(entry);
+      } else if (name.startsWith("rest-api-")) {
+        restApis.add(entry);
+      } else if (name.startsWith("about.")) {
+        if (about == null) {
+          about = entry;
+        } else {
+          log.warn(String.format(
+              "Plugin %s: Multiple 'about' documents found; using %s",
+              pluginName,
+              about.getName().substring(prefix.length())));
+        }
+      } else {
+        docs.add(entry);
       }
     }
 
@@ -443,7 +470,7 @@
   private void sendMarkdownAsHtml(String md, String pluginName,
       PluginResourceKey cacheKey, HttpServletResponse res, long lastModifiedTime)
       throws UnsupportedEncodingException, IOException {
-    Map<String, String> macros = Maps.newHashMap();
+    Map<String, String> macros = new HashMap<>();
     macros.put("PLUGIN", pluginName);
     macros.put("SSH_HOST", sshHost);
     macros.put("SSH_PORT", "" + sshPort);
@@ -486,7 +513,6 @@
       String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
       String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
       String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
-      String u = main.getValue(Attributes.Name.IMPLEMENTATION_URL);
       String a = main.getValue("Gerrit-ApiVersion");
 
       html.append("<table class=\"plugin_info\">");
@@ -505,11 +531,6 @@
             .append(v)
             .append("</td></tr>\n");
       }
-      if (!Strings.isNullOrEmpty(u)) {
-        html.append("<tr><th>URL</th><td>")
-            .append(String.format("<a href=\"%s\">%s</a>", u, u))
-            .append("</td></tr>\n");
-      }
       if (!Strings.isNullOrEmpty(a)) {
         html.append("<tr><th>API Version</th><td>")
             .append(a)
@@ -672,9 +693,8 @@
             scanner.getManifest().getMainAttributes().getValue(attr);
         if (prefix != null) {
           return CharMatcher.is('/').trimFrom(prefix) + "/";
-        } else {
-          return def;
         }
+        return def;
       } catch (IOException e) {
         log.warn(String.format("Error getting %s for plugin %s, using default",
             attr, plugin.getName()), e);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
new file mode 100644
index 0000000..d298026
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.plugins;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.httpd.resources.Resource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.ReloadPluginListener;
+import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.GuiceFilter;
+
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class LfsPluginServlet extends HttpServlet
+    implements StartPluginListener, ReloadPluginListener {
+  private static final long serialVersionUID = 1L;
+  private static final Logger log
+      = LoggerFactory.getLogger(LfsPluginServlet.class);
+
+  public static final String LFS_REST =
+      "(?:/p/|/)(.+)(?:/info/lfs/objects/batch)$";
+  public static final String URL_REGEX =
+      "^(?:/a)?" + LFS_REST;
+
+  private static final String CONTENTTYPE_VND_GIT_LFS_JSON =
+      "application/vnd.git-lfs+json; charset=utf-8";
+  private static final String MESSAGE_LFS_NOT_CONFIGURED =
+      "{\"message\":\"No LFS plugin is configured to handle LFS requests.\"}";
+
+  private List<Plugin> pending = new ArrayList<>();
+  private final String pluginName;
+  private final FilterChain chain;
+  private AtomicReference<GuiceFilter> filter;
+
+  @Inject
+  LfsPluginServlet(@GerritServerConfig Config cfg) {
+    this.pluginName = cfg.getString("lfs", null, "plugin");
+    this.chain = new FilterChain() {
+      @Override
+      public void doFilter(ServletRequest req, ServletResponse res)
+          throws IOException {
+        Resource.NOT_FOUND.send(
+            (HttpServletRequest) req, (HttpServletResponse) res);
+      }
+    };
+    this.filter = new AtomicReference<>();
+  }
+
+  @Override
+  protected void service(HttpServletRequest req, HttpServletResponse res)
+      throws ServletException, IOException {
+    if (filter.get() == null) {
+      responseLfsNotConfigured(res);
+      return;
+    }
+    filter.get().doFilter(req, res, chain);
+  }
+
+  @Override
+  public synchronized void init(ServletConfig config) throws ServletException {
+    super.init(config);
+
+    for (Plugin plugin : pending) {
+      install(plugin);
+    }
+    pending = null;
+  }
+
+  @Override
+  public synchronized void onStartPlugin(Plugin plugin) {
+    if (pending != null) {
+      pending.add(plugin);
+    } else {
+      install(plugin);
+    }
+  }
+
+  @Override
+  public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin) {
+    install(newPlugin);
+  }
+
+  private void responseLfsNotConfigured(HttpServletResponse res)
+      throws IOException {
+    CacheHeaders.setNotCacheable(res);
+    res.setContentType(CONTENTTYPE_VND_GIT_LFS_JSON);
+    res.setStatus(SC_NOT_IMPLEMENTED);
+    Writer w = new BufferedWriter(
+        new OutputStreamWriter(res.getOutputStream(), UTF_8));
+    w.write(MESSAGE_LFS_NOT_CONFIGURED);
+    w.flush();
+  }
+
+  private void install(Plugin plugin) {
+    if (!plugin.getName().equals(pluginName)) {
+      return;
+    }
+    final GuiceFilter guiceFilter = load(plugin);
+    plugin.add(new RegistrationHandle() {
+      @Override
+      public void remove() {
+        filter.compareAndSet(guiceFilter, null);
+      }
+    });
+    filter.set(guiceFilter);
+  }
+
+  private GuiceFilter load(Plugin plugin) {
+    if (plugin.getHttpInjector() != null) {
+      final String name = plugin.getName();
+      final GuiceFilter guiceFilter;
+      try {
+        guiceFilter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
+      } catch (RuntimeException e) {
+        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
+        return null;
+      }
+
+      try {
+        ServletContext ctx =
+            PluginServletContext.create(plugin, "/");
+        guiceFilter.init(new WrappedFilterConfig(ctx));
+      } catch (ServletException e) {
+        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
+        return null;
+      }
+
+      plugin.add(new RegistrationHandle() {
+        @Override
+        public void remove() {
+          guiceFilter.destroy();
+        }
+      });
+      return guiceFilter;
+    }
+    return null;
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index c3395b5..476dba8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -36,7 +36,8 @@
 import javax.servlet.ServletContext;
 
 class PluginServletContext {
-  private static final Logger log = LoggerFactory.getLogger("plugin");
+  private static final Logger log =
+      LoggerFactory.getLogger(PluginServletContext.class);
 
   static ServletContext create(Plugin plugin, String contextPath) {
     return (ServletContext) Proxy.newProxyInstance(
@@ -69,7 +70,7 @@
             method.getParameterTypes());
       } catch (NoSuchMethodException e) {
         throw new NoSuchMethodError(String.format(
-            "%s does not implement%s",
+            "%s does not implement %s",
             PluginServletContext.class,
             method.toGenericString()));
       }
@@ -207,7 +208,7 @@
     }
   }
 
-  static interface API {
+  interface API {
     String getContextPath();
     String getInitParameter(String name);
     @SuppressWarnings("rawtypes")
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
new file mode 100644
index 0000000..ef55e34
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+class BowerComponentsServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path zip;
+  private final Path bowerComponents;
+
+  BowerComponentsServlet(Cache<Path, Resource> cache, Path buckOut)
+      throws IOException {
+    super(cache, true);
+    zip = getZipPath(buckOut);
+    if (zip == null || !Files.exists(zip)) {
+      bowerComponents = null;
+    } else {
+      bowerComponents = GerritLauncher
+          .newZipFileSystem(zip)
+          .getPath("bower_components/");
+    }
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    if (bowerComponents == null) {
+      throw new IOException("No polymer components found: " + zip
+          + ". Run `buck build //polygerrit-ui:polygerrit_components`?");
+    }
+    return bowerComponents.resolve(pathInfo);
+  }
+
+  private static Path getZipPath(Path buckOut) {
+    if (buckOut == null) {
+      return null;
+    }
+    return buckOut.resolve("gen")
+        .resolve("polygerrit-ui")
+        .resolve("polygerrit_components")
+        .resolve("polygerrit_components.bower_components.zip");
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
new file mode 100644
index 0000000..0b4a02e
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.escape.Escaper;
+import com.google.common.html.HtmlEscapers;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gwtexpui.server.CacheHeaders;
+
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+
+import javax.servlet.http.HttpServletResponse;
+
+class BuckUtils {
+  private static final Logger log =
+      LoggerFactory.getLogger(BuckUtils.class);
+
+  static void build(Path root, Path gen, String target)
+      throws IOException, BuildFailureException {
+    log.info("buck build " + target);
+    Properties properties = loadBuckProperties(gen);
+    String buck = firstNonNull(properties.getProperty("buck"), "buck");
+    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
+        .directory(root.toFile())
+        .redirectErrorStream(true);
+    if (properties.containsKey("PATH")) {
+      proc.environment().put("PATH", properties.getProperty("PATH"));
+    }
+    long start = TimeUtil.nowMs();
+    Process rebuild = proc.start();
+    byte[] out;
+    try (InputStream in = rebuild.getInputStream()) {
+      out = ByteStreams.toByteArray(in);
+    } finally {
+      rebuild.getOutputStream().close();
+    }
+
+    int status;
+    try {
+      status = rebuild.waitFor();
+    } catch (InterruptedException e) {
+      throw new InterruptedIOException("interrupted waiting for " + buck);
+    }
+    if (status != 0) {
+      throw new BuildFailureException(out);
+    }
+
+    long time = TimeUtil.nowMs() - start;
+    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
+  }
+
+  private static Properties loadBuckProperties(Path gen) throws IOException {
+    Properties properties = new Properties();
+    Path p = gen.resolve(Paths.get("tools/buck/buck.properties"));
+    try (InputStream in = Files.newInputStream(p)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
+  static void displayFailure(String rule, byte[] why, HttpServletResponse res)
+      throws IOException {
+    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    res.setContentType("text/html");
+    res.setCharacterEncoding(UTF_8.name());
+    CacheHeaders.setNotCacheable(res);
+
+    Escaper html = HtmlEscapers.htmlEscaper();
+    try (PrintWriter w = res.getWriter()) {
+      w.write("<html><title>BUILD FAILED</title><body>");
+      w.format("<h1>%s FAILED</h1>", html.escape(rule));
+      w.write("<pre>");
+      w.write(html.escape(RawParseUtils.decode(why)));
+      w.write("</pre>");
+      w.write("</body></html>");
+    }
+  }
+
+  static class BuildFailureException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final byte[] why;
+
+    BuildFailureException(byte[] why) {
+      this.why = why;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
index ce696a1..4047279 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.project.ChangeControl;
@@ -53,16 +54,19 @@
   private final Provider<CurrentUser> userProvider;
   private final ChangeControl.GenericFactory changeControl;
   private final ChangeEditUtil changeEditUtil;
+  private final PatchSetUtil psUtil;
 
   @Inject
   CatServlet(Provider<ReviewDb> sf,
       ChangeControl.GenericFactory ccf,
       Provider<CurrentUser> usrprv,
-      ChangeEditUtil ceu) {
+      ChangeEditUtil ceu,
+      PatchSetUtil psu) {
     requestDb = sf;
     changeControl = ccf;
     userProvider = usrprv;
     changeEditUtil = ceu;
+    psUtil = psu;
   }
 
   @Override
@@ -118,7 +122,7 @@
     String revision;
     try {
       final ReviewDb db = requestDb.get();
-      final ChangeControl control = changeControl.validateFor(changeId,
+      final ChangeControl control = changeControl.validateFor(db, changeId,
           userProvider.get());
       if (patchKey.getParentKey().get() == 0) {
         // change edit
@@ -135,7 +139,8 @@
           return;
         }
       } else {
-        PatchSet patchSet = db.patchSets().get(patchKey.getParentKey());
+        PatchSet patchSet =
+            psUtil.get(db, control.getNotes(), patchKey.getParentKey());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
new file mode 100644
index 0000000..3a8c8cb
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+class FontsServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path zip;
+  private final Path fonts;
+
+  FontsServlet(Cache<Path, Resource> cache, Path buckOut)
+      throws IOException {
+    super(cache, true);
+    zip = getZipPath(buckOut);
+    if (zip == null || !Files.exists(zip)) {
+      fonts = null;
+    } else {
+      fonts = GerritLauncher
+          .newZipFileSystem(zip)
+          .getPath("/");
+    }
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    if (fonts == null) {
+      throw new IOException("No fonts found: " + zip
+          + ". Run `buck build //polygerrit-ui:fonts`?");
+    }
+    return fonts.resolve(pathInfo);
+  }
+
+  private static Path getZipPath(Path buckOut) {
+    if (buckOut == null) {
+      return null;
+    }
+    return buckOut.resolve("gen")
+        .resolve("polygerrit-ui")
+        .resolve("fonts")
+        .resolve("fonts.zip");
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index cac402b..bb3eff7 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -18,25 +18,21 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.common.primitives.Bytes;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GetDiffPreferences;
-import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -68,7 +64,6 @@
 
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
-import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -84,7 +79,6 @@
   private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
 
   private final Provider<CurrentUser> currentUser;
-  private final DynamicItem<WebSession> session;
   private final DynamicSet<WebUiPlugin> plugins;
   private final DynamicSet<MessageOfTheDay> messages;
   private final HostPageData.Theme signedOutTheme;
@@ -96,37 +90,34 @@
   private final SiteStaticDirectoryServlet staticServlet;
   private final boolean isNoteDbEnabled;
   private final Integer pluginsLoadTimeout;
+  private final boolean canLoadInIFrame;
   private final GetDiffPreferences getDiff;
-  private final AuthConfig authConfig;
   private volatile Page page;
 
   @Inject
   HostPageServlet(
       Provider<CurrentUser> cu,
-      DynamicItem<WebSession> w,
       SitePaths sp,
       ThemeFactory themeFactory,
       ServletContext servletContext,
       DynamicSet<WebUiPlugin> webUiPlugins,
       DynamicSet<MessageOfTheDay> motd,
       @GerritServerConfig Config cfg,
-      AuthConfig authCfg,
       SiteStaticDirectoryServlet ss,
       NotesMigration migration,
       GetDiffPreferences diffPref)
       throws IOException, ServletException {
     currentUser = cu;
-    session = w;
     plugins = webUiPlugins;
     messages = motd;
     signedOutTheme = themeFactory.getSignedOutTheme();
     signedInTheme = themeFactory.getSignedInTheme();
     site = sp;
     refreshHeaderFooter = cfg.getBoolean("site", "refreshHeaderFooter", true);
-    authConfig = authCfg;
     staticServlet = ss;
-    isNoteDbEnabled = migration.enabled();
+    isNoteDbEnabled = migration.readChanges();
     pluginsLoadTimeout = getPluginsLoadTimeout(cfg);
+    canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
     getDiff = diffPref;
 
     String pageName = "HostPage.html";
@@ -197,7 +188,6 @@
     StringWriter w = new StringWriter();
     CurrentUser user = currentUser.get();
     if (user.isIdentifiedUser()) {
-      setXGerritAuthCookie(req, rsp, session.get());
       w.write(HPD_ID + ".accountDiffPref=");
       json(getDiffPreferences(user.asIdentifiedUser()), w);
       w.write(";");
@@ -206,7 +196,6 @@
       json(signedInTheme, w);
       w.write(";");
     } else {
-      setXGerritAuthCookie(req, rsp, null);
       w.write(HPD_ID + ".theme=");
       json(signedOutTheme, w);
       w.write(";");
@@ -233,23 +222,6 @@
     }
   }
 
-  private void setXGerritAuthCookie(HttpServletRequest req,
-      HttpServletResponse rsp, WebSession session) {
-    String v = session != null ? session.getXGerritAuth() : "";
-    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
-    c.setPath("/");
-    c.setHttpOnly(false);
-    c.setSecure(authConfig.getCookieSecure() && isSecure(req));
-    c.setMaxAge(session != null
-        ? -1 // Set the cookie for this browser session.
-        : 0); // Remove the cookie (expire immediately).
-    rsp.addCookie(c);
-  }
-
-  private static boolean isSecure(HttpServletRequest req) {
-    return req.isSecure() || "https".equals(req.getScheme());
-  }
-
   private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
     try {
       return getDiff.apply(new AccountResource(user));
@@ -260,7 +232,7 @@
   }
 
   private void plugins(StringWriter w) {
-    List<String> urls = Lists.newArrayList();
+    List<String> urls = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
       urls.add(String.format("plugins/%s/%s",
           u.getPluginName(),
@@ -352,6 +324,7 @@
       pageData.version = Version.getVersion();
       pageData.isNoteDbEnabled = isNoteDbEnabled;
       pageData.pluginsLoadTimeout = pluginsLoadTimeout;
+      pageData.canLoadInIFrame = canLoadInIFrame;
 
       StringWriter w = new StringWriter();
       w.write("var " + HPD_ID + "=");
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
new file mode 100644
index 0000000..4ca8b1c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.common.cache.Cache;
+
+import java.nio.file.Path;
+
+class PolyGerritUiServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path ui;
+
+  PolyGerritUiServlet(Cache<Path, Resource> cache, Path ui) {
+    super(cache, true);
+    this.ui = ui;
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) {
+    return ui.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
index a5bc6c6..1984cbb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
@@ -14,33 +14,16 @@
 
 package com.google.gerrit.httpd.raw;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.escape.Escaper;
-import com.google.common.html.HtmlEscapers;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.httpd.raw.BuckUtils.BuildFailureException;
 import com.google.gwtexpui.linker.server.UserAgentRule;
-import com.google.gwtexpui.server.CacheHeaders;
-
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.PrintWriter;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Enumeration;
 import java.util.HashSet;
-import java.util.Properties;
 import java.util.Set;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -55,9 +38,6 @@
 import javax.servlet.http.HttpServletResponse;
 
 class RecompileGwtUiFilter implements Filter {
-  private static final Logger log =
-      LoggerFactory.getLogger(RecompileGwtUiFilter.class);
-
   private final boolean gwtuiRecompile =
       System.getProperty("gerrit.disable-gwtui-recompile") == null;
   private final UserAgentRule rule = new UserAgentRule();
@@ -92,9 +72,9 @@
 
       synchronized (this) {
         try {
-          build(root, gen, rule);
+          BuckUtils.build(root, gen, rule);
         } catch (BuildFailureException e) {
-          displayFailure(rule, e.why, (HttpServletResponse) res);
+          BuckUtils.displayFailure(rule, e.why, (HttpServletResponse) res);
           return;
         }
 
@@ -109,24 +89,6 @@
     chain.doFilter(request, res);
   }
 
-  private void displayFailure(String rule, byte[] why, HttpServletResponse res)
-      throws IOException {
-    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-    res.setContentType("text/html");
-    res.setCharacterEncoding(UTF_8.name());
-    CacheHeaders.setNotCacheable(res);
-
-    Escaper html = HtmlEscapers.htmlEscaper();
-    try (PrintWriter w = res.getWriter()) {
-      w.write("<html><title>BUILD FAILED</title><body>");
-      w.format("<h1>%s FAILED</h1>", html.escape(rule));
-      w.write("<pre>");
-      w.write(html.escape(RawParseUtils.decode(why)));
-      w.write("</pre>");
-      w.write("</body></html>");
-    }
-  }
-
   @Override
   public void init(FilterConfig config) {
   }
@@ -166,59 +128,6 @@
     }
   }
 
-  private static void build(Path root, Path gen, String target)
-      throws IOException, BuildFailureException {
-    log.info("buck build " + target);
-    Properties properties = loadBuckProperties(gen);
-    String buck = MoreObjects.firstNonNull(properties.getProperty("buck"), "buck");
-    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
-        .directory(root.toFile())
-        .redirectErrorStream(true);
-    if (properties.containsKey("PATH")) {
-      proc.environment().put("PATH", properties.getProperty("PATH"));
-    }
-    long start = TimeUtil.nowMs();
-    Process rebuild = proc.start();
-    byte[] out;
-    try (InputStream in = rebuild.getInputStream()) {
-      out = ByteStreams.toByteArray(in);
-    } finally {
-      rebuild.getOutputStream().close();
-    }
-
-    int status;
-    try {
-      status = rebuild.waitFor();
-    } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + buck);
-    }
-    if (status != 0) {
-      throw new BuildFailureException(out);
-    }
-
-    long time = TimeUtil.nowMs() - start;
-    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
-  }
-
-  private static Properties loadBuckProperties(Path gen)
-      throws FileNotFoundException, IOException {
-    Properties properties = new Properties();
-    try (InputStream in = new FileInputStream(
-        gen.resolve(Paths.get("tools/buck/buck.properties")).toFile())) {
-      properties.load(in);
-    }
-    return properties;
-  }
-
-  @SuppressWarnings("serial")
-  private static class BuildFailureException extends Exception {
-    final byte[] why;
-
-    BuildFailureException(byte[] why) {
-      this.why = why;
-    }
-  }
-
   private static void mkdir(File dir) throws IOException {
     if (!dir.isDirectory()) {
       mkdir(dir.getParentFile());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 8116404..4f07ac2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -98,17 +98,24 @@
 
   private final Cache<Path, Resource> cache;
   private final boolean refresh;
+  private final boolean cacheOnClient;
   private final int cacheFileSizeLimitBytes;
 
   protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh) {
-    this(cache, refresh, CACHE_FILE_SIZE_LIMIT_BYTES);
+    this(cache, refresh, true, CACHE_FILE_SIZE_LIMIT_BYTES);
+  }
+
+  protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh,
+      boolean cacheOnClient) {
+    this(cache, refresh, cacheOnClient, CACHE_FILE_SIZE_LIMIT_BYTES);
   }
 
   @VisibleForTesting
   ResourceServlet(Cache<Path, Resource> cache, boolean refresh,
-      int cacheFileSizeLimitBytes) {
+      boolean cacheOnClient, int cacheFileSizeLimitBytes) {
     this.cache = checkNotNull(cache, "cache");
     this.refresh = refresh;
+    this.cacheOnClient = cacheOnClient;
     this.cacheFileSizeLimitBytes = cacheFileSizeLimitBytes;
   }
 
@@ -118,8 +125,9 @@
    *
    * @param pathInfo result of {@link HttpServletRequest#getPathInfo()}.
    * @return path where static content can be found.
+   * @throws IOException if an error occurred resolving the resource.
    */
-  protected abstract Path getResourcePath(String pathInfo);
+  protected abstract Path getResourcePath(String pathInfo) throws IOException;
 
   protected FileTime getLastModifiedTime(Path p) throws IOException {
     return Files.getLastModifiedTime(p);
@@ -172,7 +180,7 @@
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(SC_NOT_FOUND);
       return;
-    } else if (r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+    } else if (cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
       rsp.setStatus(SC_NOT_MODIFIED);
       return;
     }
@@ -185,6 +193,12 @@
         tosend = gz;
       }
     }
+
+    if (cacheOnClient) {
+      rsp.setHeader(ETAG, r.etag);
+    } else {
+      CacheHeaders.setNotCacheable(rsp);
+    }
     if (!CacheHeaders.hasCacheHeader(rsp)) {
       if (e != null && r.etag.equals(e)) {
         CacheHeaders.setCacheable(req, rsp, 360, DAYS, false);
@@ -192,7 +206,6 @@
         CacheHeaders.setCacheable(req, rsp, 15, MINUTES, refresh);
       }
     }
-    rsp.setHeader(ETAG, r.etag);
     rsp.setContentType(r.contentType);
     rsp.setContentLength(tosend.length);
     try (OutputStream out = rsp.getOutputStream()) {
@@ -209,7 +222,7 @@
         return null;
       }
       return cache.get(p, newLoader(p));
-    } catch (ExecutionException e) {
+    } catch (ExecutionException | IOException e) {
       log.warn(String.format("Cannot load static resource %s", name), e);
       return null;
     }
@@ -296,7 +309,7 @@
     };
   }
 
-  static class Resource {
+  public static class Resource {
     static final Resource NOT_FOUND =
         new Resource(FileTime.fromMillis(0), "", new byte[] {});
 
@@ -325,7 +338,7 @@
     }
   }
 
-  static class Weigher
+  public static class Weigher
       implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
index d75a523..64dd862 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
@@ -19,7 +19,7 @@
 import java.nio.file.Path;
 
 /** Serve a single static file, regardless of path. */
-class SingleFileServlet extends ResourceServlet{
+class SingleFileServlet extends ResourceServlet {
   private static final long serialVersionUID = 1L;
 
   private final Path path;
@@ -29,6 +29,12 @@
     this.path = path;
   }
 
+  SingleFileServlet(Cache<Path, Resource> cache, Path path, boolean refresh,
+      boolean cacheOnClient) {
+    super(cache, refresh, cacheOnClient);
+    this.path = path;
+  }
+
   @Override
   protected Path getResourcePath(String pathInfo) {
     return path;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
index cf99d3c..6365306 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
@@ -51,15 +51,6 @@
 
   @Override
   protected Path getResourcePath(String pathInfo) {
-    Path p = staticBase.resolve(pathInfo);
-    try {
-      p = p.toRealPath().normalize();
-      if (!p.startsWith(staticBase)) {
-        return null;
-      }
-      return p;
-    } catch (IOException e) {
-      return null;
-    }
+    return staticBase.resolve(pathInfo);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
index 79681df..7916ed0 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
 import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.httpd.GerritOptions;
+import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
@@ -49,57 +54,48 @@
   private static final Logger log =
       LoggerFactory.getLogger(StaticModule.class);
 
+  public static final String CACHE = "static_content";
+
+  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
+      ImmutableList.of(
+        "/",
+        "/c/*",
+        "/q/*",
+        "/x/*",
+        "/admin/*",
+        "/dashboard/*",
+        "/settings/*",
+        // TODO(dborowitz): These fragments conflict with the REST API
+        // namespace, so they will need to use a different path.
+        "/groups/*",
+        "/projects/*");
+
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
   private static final String GWT_UI_SERVLET = "GwtUiServlet";
+  private static final String POLYGERRIT_INDEX_SERVLET =
+      "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
-  static final String CACHE = "static_content";
+  private final GerritOptions options;
+  private Paths paths;
 
-  private final FileSystem warFs;
-  private final Path buckOut;
-  private final Path unpackedWar;
-  private final boolean development;
+  @Inject
+  public StaticModule(GerritOptions options) {
+    this.options = options;
+  }
 
-  public StaticModule() {
-    File launcherLoadedFrom = getLauncherLoadedFrom();
-    if (launcherLoadedFrom != null
-        && launcherLoadedFrom.getName().endsWith(".jar")) {
-      // Special case: unpacked war archive deployed in container.
-      // The path is something like:
-      // <container>/<gerrit>/WEB-INF/lib/launcher.jar
-      // Switch to exploded war case with <container>/webapp>/<gerrit>
-      // root directory
-      warFs = null;
-      unpackedWar = java.nio.file.Paths.get(launcherLoadedFrom
-          .getParentFile()
-          .getParentFile()
-          .getParentFile()
-          .toURI());
-      buckOut = null;
-      development = false;
-      return;
+  private Paths getPaths() {
+    if (paths == null) {
+      paths = new Paths();
     }
-
-    warFs = getDistributionArchive();
-    if (warFs == null) {
-      buckOut = getDeveloperBuckOut();
-      unpackedWar = makeWarTempDir();
-      development = true;
-    } else {
-      buckOut = null;
-      unpackedWar = null;
-      development = false;
-    }
+    return paths;
   }
 
   @Override
   protected void configureServlets() {
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
-    serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
-    serve("/favicon.ico").with(named(FAVICON_SERVLET));
-    serveGwtUi();
     install(new CacheModule() {
       @Override
       protected void configure() {
@@ -108,13 +104,12 @@
             .weigher(ResourceServlet.Weigher.class);
       }
     });
-  }
-
-  private void serveGwtUi() {
-    serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
-        .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
-    if (development) {
-      filter("/").through(new RecompileGwtUiFilter(buckOut, unpackedWar));
+    if (options.enablePolyGerrit()) {
+      install(new CoreStaticModule());
+      install(new PolyGerritUiModule());
+    } else if (options.enableDefaultUi()) {
+      install(new CoreStaticModule());
+      install(new GwtUiModule());
     }
   }
 
@@ -122,10 +117,11 @@
   @Singleton
   @Named(DOC_SERVLET)
   HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-    if (warFs != null) {
-      return new WarDocServlet(cache, warFs);
-    } else if (unpackedWar != null && !development) {
-      return new DirectoryDocServlet(cache, unpackedWar);
+    Paths p = getPaths();
+    if (p.warFs != null) {
+      return new WarDocServlet(cache, p.warFs);
+    } else if (p.unpackedWar != null && !p.isDev()) {
+      return new DirectoryDocServlet(cache, p.unpackedWar);
     } else {
       return new HttpServlet() {
         private static final long serialVersionUID = 1L;
@@ -139,126 +135,262 @@
     }
   }
 
-  @Provides
-  @Singleton
-  @Named(GWT_UI_SERVLET)
-  HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache)
-      throws IOException {
-    if (warFs != null) {
-      return new WarGwtUiServlet(cache, warFs);
-    } else {
-      return new DirectoryGwtUiServlet(cache, unpackedWar, development);
+  private class CoreStaticModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
+      serve("/favicon.ico").with(named(FAVICON_SERVLET));
     }
-  }
 
-  @Provides
-  @Singleton
-  @Named(ROBOTS_TXT_SERVLET)
-  HttpServlet getRobotsTxtServlet(@GerritServerConfig Config cfg,
-      SitePaths sitePaths, @Named(CACHE) Cache<Path, Resource> cache) {
-    Path configPath = sitePaths.resolve(
-        cfg.getString("httpd", null, "robotsFile"));
-    if (configPath != null) {
-      if (exists(configPath) && isReadable(configPath)) {
-        return new SingleFileServlet(cache, configPath, true);
-      } else {
+    @Provides
+    @Singleton
+    @Named(ROBOTS_TXT_SERVLET)
+    HttpServlet getRobotsTxtServlet(@GerritServerConfig Config cfg,
+        SitePaths sitePaths, @Named(CACHE) Cache<Path, Resource> cache) {
+      Path configPath = sitePaths.resolve(
+          cfg.getString("httpd", null, "robotsFile"));
+      if (configPath != null) {
+        if (exists(configPath) && isReadable(configPath)) {
+          return new SingleFileServlet(cache, configPath, true);
+        }
         log.warn("Cannot read httpd.robotsFile, using default");
       }
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/robots.txt"), false);
+      }
+      return new SingleFileServlet(
+          cache, webappSourcePath("robots.txt"), true);
     }
-    if (warFs != null) {
-      return new SingleFileServlet(cache, warFs.getPath("/robots.txt"), false);
-    } else {
-      return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
-    }
-  }
 
-  @Provides
-  @Singleton
-  @Named(FAVICON_SERVLET)
-  HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-    if (warFs != null) {
-      return new SingleFileServlet(cache, warFs.getPath("/favicon.ico"), false);
-    } else {
+    @Provides
+    @Singleton
+    @Named(FAVICON_SERVLET)
+    HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/favicon.ico"), false);
+      }
       return new SingleFileServlet(
           cache, webappSourcePath("favicon.ico"), true);
     }
+
+    private Path webappSourcePath(String name) {
+      Paths p = getPaths();
+      if (p.unpackedWar != null) {
+        return p.unpackedWar.resolve(name);
+      }
+      return p.buckOut.resolveSibling("gerrit-war").resolve("src")
+          .resolve("main").resolve("webapp").resolve(name);
+    }
   }
 
-  private Path webappSourcePath(String name) {
-    if (unpackedWar != null) {
-      return unpackedWar.resolve(name);
+  private class GwtUiModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
+          .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
+      Paths p = getPaths();
+      if (p.isDev()) {
+        filter("/").through(new RecompileGwtUiFilter(p.buckOut, p.unpackedWar));
+      }
     }
-    return buckOut.resolveSibling("gerrit-war").resolve("src").resolve("main")
-        .resolve("webapp").resolve(name);
+
+    @Provides
+    @Singleton
+    @Named(GWT_UI_SERVLET)
+    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache)
+        throws IOException {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new WarGwtUiServlet(cache, p.warFs);
+      }
+      return new DirectoryGwtUiServlet(cache, p.unpackedWar, p.isDev());
+    }
+  }
+
+  private class PolyGerritUiModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      Path buckOut = getPaths().buckOut;
+      if (buckOut != null) {
+        serve("/bower_components/*").with(BowerComponentsServlet.class);
+        serve("/fonts/*").with(FontsServlet.class);
+      } else {
+        // In the war case, bower_components and fonts are either inlined
+        // by vulcanize, or live under /polygerrit_ui in the war file,
+        // so we don't need a separate servlet.
+      }
+
+      Key<HttpServlet> indexKey = named(POLYGERRIT_INDEX_SERVLET);
+      for (String p : POLYGERRIT_INDEX_PATHS) {
+        filter(p).through(XsrfCookieFilter.class);
+        serve(p).with(indexKey);
+      }
+      serve("/*").with(PolyGerritUiServlet.class);
+    }
+
+    @Provides
+    @Singleton
+    @Named(POLYGERRIT_INDEX_SERVLET)
+    HttpServlet getPolyGerritUiIndexServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      return new SingleFileServlet(cache,
+          polyGerritBasePath().resolve("index.html"),
+          getPaths().isDev(),
+          false);
+    }
+
+    @Provides
+    @Singleton
+    PolyGerritUiServlet getPolyGerritUiServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      return new PolyGerritUiServlet(cache, polyGerritBasePath());
+    }
+
+    @Provides
+    @Singleton
+    BowerComponentsServlet getBowerComponentsServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
+      return new BowerComponentsServlet(cache, getPaths().buckOut);
+    }
+
+    @Provides
+    @Singleton
+    FontsServlet getFontsServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
+      return new FontsServlet(cache, getPaths().buckOut);
+    }
+
+    private Path polyGerritBasePath() {
+      Paths p = getPaths();
+      if (options.forcePolyGerritDev()) {
+        checkArgument(p.buckOut != null,
+            "no buck-out directory found for PolyGerrit developer mode");
+      }
+
+      if (p.isDev()) {
+        return p.buckOut.getParent().resolve("polygerrit-ui").resolve("app");
+      }
+
+      return p.warFs != null
+          ? p.warFs.getPath("/polygerrit_ui")
+          : p.unpackedWar.resolve("polygerrit_ui");
+    }
+  }
+
+  private class Paths {
+    private final FileSystem warFs;
+    private final Path buckOut;
+    private final Path unpackedWar;
+    private final boolean development;
+
+    private Paths() {
+      try {
+        File launcherLoadedFrom = getLauncherLoadedFrom();
+        if (launcherLoadedFrom != null
+            && launcherLoadedFrom.getName().endsWith(".jar")) {
+          // Special case: unpacked war archive deployed in container.
+          // The path is something like:
+          // <container>/<gerrit>/WEB-INF/lib/launcher.jar
+          // Switch to exploded war case with <container>/webapp>/<gerrit>
+          // root directory
+          warFs = null;
+          unpackedWar = java.nio.file.Paths.get(launcherLoadedFrom
+              .getParentFile()
+              .getParentFile()
+              .getParentFile()
+              .toURI());
+          buckOut = null;
+          development = false;
+          return;
+        }
+        warFs = getDistributionArchive(launcherLoadedFrom);
+        if (warFs == null) {
+          buckOut = getDeveloperBuckOut();
+          unpackedWar = makeWarTempDir();
+          development = true;
+        } else if (options.forcePolyGerritDev()) {
+          buckOut = getDeveloperBuckOut();
+          unpackedWar = null;
+          development = true;
+        } else {
+          buckOut = null;
+          unpackedWar = null;
+          development = false;
+        }
+      } catch (IOException e) {
+        throw new ProvisionException(
+            "Error initializing static content paths", e);
+      }
+    }
+
+    private FileSystem getDistributionArchive(File war) throws IOException {
+      if (war == null) {
+        return null;
+      }
+      return GerritLauncher.getZipFileSystem(war.toPath());
+    }
+
+    private File getLauncherLoadedFrom() {
+      File war;
+      try {
+        war = GerritLauncher.getDistributionArchive();
+      } catch (IOException e) {
+        if ((e instanceof FileNotFoundException)
+            && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
+          return null;
+        }
+        ProvisionException pe =
+            new ProvisionException("Error reading gerrit.war");
+        pe.initCause(e);
+        throw pe;
+      }
+      return war;
+    }
+
+    private boolean isDev() {
+      return development;
+    }
+
+    private Path getDeveloperBuckOut() {
+      try {
+        return GerritLauncher.getDeveloperBuckOut();
+      } catch (FileNotFoundException e) {
+        return null;
+      }
+    }
+
+    private Path makeWarTempDir() {
+      // Obtain our local temporary directory, but it comes back as a file
+      // so we have to switch it to be a directory post creation.
+      //
+      try {
+        File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
+        if (!dstwar.delete() || !dstwar.mkdir()) {
+          throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
+        }
+
+        // Jetty normally refuses to serve out of a symlinked directory, as
+        // a security feature. Try to resolve out any symlinks in the path.
+        //
+        try {
+          return dstwar.getCanonicalFile().toPath();
+        } catch (IOException e) {
+          return dstwar.getAbsoluteFile().toPath();
+        }
+      } catch (IOException e) {
+        ProvisionException pe =
+            new ProvisionException("Cannot create war tempdir");
+        pe.initCause(e);
+        throw pe;
+      }
+    }
   }
 
   private static Key<HttpServlet> named(String name) {
     return Key.get(HttpServlet.class, Names.named(name));
   }
-
-  private static FileSystem getDistributionArchive() {
-    try {
-      return GerritLauncher.getDistributionArchiveFileSystem();
-    } catch (IOException e) {
-      if ((e instanceof FileNotFoundException)
-          && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
-        return null;
-      } else {
-        ProvisionException pe =
-            new ProvisionException("Error reading gerrit.war");
-        pe.initCause(e);
-        throw pe;
-      }
-    }
-  }
-
-  private static File getLauncherLoadedFrom() {
-    try {
-      return GerritLauncher.getDistributionArchive();
-    } catch (IOException e) {
-      if ((e instanceof FileNotFoundException)
-          && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
-        return null;
-      } else {
-        ProvisionException pe =
-            new ProvisionException("Error reading gerrit.war");
-        pe.initCause(e);
-        throw pe;
-      }
-    }
-  }
-
-  private static Path getDeveloperBuckOut() {
-    try {
-      return GerritLauncher.getDeveloperBuckOut();
-    } catch (FileNotFoundException e) {
-      return null;
-    }
-  }
-
-  private static Path makeWarTempDir() {
-    // Obtain our local temporary directory, but it comes back as a file
-    // so we have to switch it to be a directory post creation.
-    //
-    try {
-      File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
-      if (!dstwar.delete() || !dstwar.mkdir()) {
-        throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
-      }
-
-      // Jetty normally refuses to serve out of a symlinked directory, as
-      // a security feature. Try to resolve out any symlinks in the path.
-      //
-      try {
-        return dstwar.getCanonicalFile().toPath();
-      } catch (IOException e) {
-        return dstwar.getAbsoluteFile().toPath();
-      }
-    } catch (IOException e) {
-      ProvisionException pe =
-          new ProvisionException("Cannot create war tempdir");
-      pe.initCause(e);
-      throw pe;
-    }
-  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
index ef5a1df..182ee7e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
@@ -15,5 +15,5 @@
 package com.google.gerrit.httpd.resources;
 
 public interface ResourceKey {
-  public int weigh();
+  int weigh();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
index d057269..9d052fe 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
@@ -61,9 +61,8 @@
       if (ifModifiedSince > 0 && ifModifiedSince == lastModified) {
         res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
         return;
-      } else {
-        res.setDateHeader("Last-Modified", lastModified);
       }
+      res.setDateHeader("Last-Modified", lastModified);
     }
     res.setContentType(contentType);
     if (characterEncoding != null) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
new file mode 100644
index 0000000..a9bd85d
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.access.AccessCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccessRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  AccessRestApiServlet(RestApiServlet.Globals globals,
+      Provider<AccessCollection> access) {
+    super(globals, access);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
new file mode 100644
index 0000000..7f8b152
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.account.AccountsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccountsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  AccountsRestApiServlet(RestApiServlet.Globals globals,
+      Provider<AccountsCollection> accounts) {
+    super(globals, accounts);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
new file mode 100644
index 0000000..f6f89a6
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.change.ChangesCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ChangesRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ChangesRestApiServlet(RestApiServlet.Globals globals,
+      Provider<ChangesCollection> changes) {
+    super(globals, changes);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
new file mode 100644
index 0000000..48dcfd9
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.config.ConfigCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ConfigRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ConfigRestApiServlet(RestApiServlet.Globals globals,
+      Provider<ConfigCollection> configCollection) {
+    super(globals, configCollection);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
new file mode 100644
index 0000000..4503bc5
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GroupsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  GroupsRestApiServlet(RestApiServlet.Globals globals,
+      Provider<GroupsCollection> groups) {
+    super(globals, groups);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 46843fc..f80cc49 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -24,7 +24,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
@@ -40,6 +39,7 @@
 
 import java.io.IOException;
 import java.io.StringWriter;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
@@ -66,7 +66,7 @@
     CmdLineParser clp = parserFactory.create(param);
     try {
       clp.parseOptionMap(in);
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | NumberFormatException e) {
       if (!clp.wasHelpRequestedByOption()) {
         replyError(req, res, SC_BAD_REQUEST, e.getMessage(), e);
         return false;
@@ -107,7 +107,7 @@
   }
 
   private static Set<String> query(HttpServletRequest req) {
-    Set<String> params = Sets.newHashSet();
+    Set<String> params = new HashSet<>();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       for (String kvPair : Splitter.on('&').split(req.getQueryString())) {
         params.add(Iterables.getFirst(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
new file mode 100644
index 0000000..87245ab
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.server.project.ProjectsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectsRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  ProjectsRestApiServlet(RestApiServlet.Globals globals,
+      Provider<ProjectsCollection> projects) {
+    super(globals, projects);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
new file mode 100644
index 0000000..5de0e0c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.httpd.restapi.RestApiServlet.ViewData;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RestApiMetrics {
+  private static final String[] PKGS = {
+    "com.google.gerrit.server.",
+    "com.google.gerrit.",
+  };
+
+  final Counter1<String> count;
+  final Counter2<String, Integer> errorCount;
+  final Timer1<String> serverLatency;
+  final Histogram1<String> responseBytes;
+
+  @Inject
+  RestApiMetrics(MetricMaker metrics) {
+    Field<String> view = Field.ofString("view", "view implementation class");
+    count = metrics.newCounter(
+        "http/server/rest_api/count",
+        new Description("REST API calls by view")
+          .setRate(),
+        view);
+
+    errorCount = metrics.newCounter(
+        "http/server/rest_api/error_count",
+        new Description("REST API errors by view")
+          .setRate(),
+        view,
+        Field.ofInteger("error_code", "HTTP status code"));
+
+    serverLatency = metrics.newTimer(
+        "http/server/rest_api/server_latency",
+        new Description("REST API call latency by view")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        view);
+
+    responseBytes = metrics.newHistogram(
+        "http/server/rest_api/response_bytes",
+        new Description("Size of response on network (may be gzip compressed)")
+          .setCumulative()
+          .setUnit(Units.BYTES),
+        view);
+  }
+
+  String view(ViewData viewData) {
+    String impl = viewData.view.getClass().getName().replace('$', '.');
+    for (String p : PKGS) {
+      if (impl.startsWith(p)) {
+        impl = impl.substring(p.length());
+        break;
+      }
+    }
+    if (!Strings.isNullOrEmpty(viewData.pluginName)
+        && !"gerrit".equals(viewData.pluginName)) {
+      impl = viewData.pluginName + '-' + impl;
+    }
+    return impl;
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 91a16b8..943d824 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -18,6 +18,7 @@
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
 import static javax.servlet.http.HttpServletResponse.SC_CREATED;
@@ -31,6 +32,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -39,15 +41,15 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 import com.google.common.io.BaseEncoding;
+import com.google.common.io.CountingOutputStream;
 import com.google.common.math.IntMath;
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -111,7 +113,6 @@
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.Writer;
@@ -121,11 +122,15 @@
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.regex.Pattern;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPOutputStream;
 
 import javax.servlet.ServletException;
@@ -142,6 +147,10 @@
   private static final String JSON_TYPE = "application/json";
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
+  // HTTP 422 Unprocessable Entity.
+  // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
+  private static final int SC_UNPROCESSABLE_ENTITY = 422;
+
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
 
   /**
@@ -164,16 +173,19 @@
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
     final AuditService auditService;
+    final RestApiMetrics metrics;
 
     @Inject
     Globals(Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
-        AuditService auditService) {
+        AuditService auditService,
+        RestApiMetrics metrics) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
       this.auditService = auditService;
+      this.metrics = metrics;
     }
   }
 
@@ -197,10 +209,12 @@
   @Override
   protected final void service(HttpServletRequest req, HttpServletResponse res)
       throws ServletException, IOException {
+    final long startNanos = System.nanoTime();
     long auditStartTs = TimeUtil.nowMs();
     res.setHeader("Content-Disposition", "attachment");
     res.setHeader("X-Content-Type-Options", "nosniff");
     int status = SC_OK;
+    long responseBytes = -1;
     Object result = null;
     Multimap<String, String> params = LinkedHashMultimap.create();
     Object inputRequestBody = null;
@@ -273,35 +287,34 @@
             throw new MethodNotAllowedException();
           }
           break;
-        } else {
-          IdString id = path.remove(0);
-          try {
-            rsrc = c.parse(rsrc, id);
-            checkPreconditions(req);
-            viewData = new ViewData(null, null);
-          } catch (ResourceNotFoundException e) {
-            if (c instanceof AcceptsCreate
-                && path.isEmpty()
-                && ("POST".equals(req.getMethod())
-                    || "PUT".equals(req.getMethod()))) {
-              @SuppressWarnings("unchecked")
-              AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
-              viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
-              status = SC_CREATED;
-            } else if (c instanceof AcceptsDelete
-                && path.isEmpty()
-                && "DELETE".equals(req.getMethod())) {
-              @SuppressWarnings("unchecked")
-              AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
-              viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
-              status = SC_NO_CONTENT;
-            } else {
-              throw e;
-            }
+        }
+        IdString id = path.remove(0);
+        try {
+          rsrc = c.parse(rsrc, id);
+          checkPreconditions(req);
+          viewData = new ViewData(null, null);
+        } catch (ResourceNotFoundException e) {
+          if (c instanceof AcceptsCreate
+              && path.isEmpty()
+              && ("POST".equals(req.getMethod())
+                  || "PUT".equals(req.getMethod()))) {
+            @SuppressWarnings("unchecked")
+            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
+            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
+            status = SC_CREATED;
+          } else if (c instanceof AcceptsDelete
+              && path.isEmpty()
+              && "DELETE".equals(req.getMethod())) {
+            @SuppressWarnings("unchecked")
+            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
+            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
+            status = SC_NO_CONTENT;
+          } else {
+            throw e;
           }
-          if (viewData.view == null) {
-            viewData = view(rsrc, c, req.getMethod(), path);
-          }
+        }
+        if (viewData.view == null) {
+          viewData = view(rsrc, c, req.getMethod(), path);
         }
         checkRequiresCapability(viewData);
       }
@@ -339,6 +352,11 @@
         CacheHeaders.setNotCacheable(res);
         res.sendRedirect(((Response.Redirect) result).location());
         return;
+      } else if (result instanceof Response.Accepted) {
+        CacheHeaders.setNotCacheable(res);
+        res.setStatus(SC_ACCEPTED);
+        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted)result).location());
+        return;
       } else {
         CacheHeaders.setNotCacheable(res);
       }
@@ -347,47 +365,62 @@
       if (result != Response.none()) {
         result = Response.unwrap(result);
         if (result instanceof BinaryResult) {
-          replyBinaryResult(req, res, (BinaryResult) result);
+          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
         } else {
-          replyJson(req, res, config, result);
+          responseBytes = replyJson(req, res, config, result);
         }
       }
     } catch (MalformedJsonException e) {
-      replyError(req, res, status = SC_BAD_REQUEST,
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
           "Invalid " + JSON_TYPE + " in request", e);
     } catch (JsonParseException e) {
-      replyError(req, res, status = SC_BAD_REQUEST,
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
           "Invalid " + JSON_TYPE + " in request", e);
     } catch (BadRequestException e) {
-      replyError(req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
+          messageOr(e, "Bad Request"), e.caching(), e);
     } catch (AuthException e) {
-      replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_FORBIDDEN,
+          messageOr(e, "Forbidden"), e.caching(), e);
     } catch (AmbiguousViewException e) {
-      replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
+          messageOr(e, "Ambiguous"), e);
     } catch (ResourceNotFoundException e) {
-      replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
+          messageOr(e, "Not Found"), e.caching(), e);
     } catch (MethodNotAllowedException e) {
-      replyError(req, res, status = SC_METHOD_NOT_ALLOWED,
+      responseBytes = replyError(req, res, status = SC_METHOD_NOT_ALLOWED,
           messageOr(e, "Method Not Allowed"), e.caching(), e);
     } catch (ResourceConflictException e) {
-      replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_CONFLICT,
+          messageOr(e, "Conflict"), e.caching(), e);
     } catch (PreconditionFailedException e) {
-      replyError(req, res, status = SC_PRECONDITION_FAILED,
+      responseBytes = replyError(req, res, status = SC_PRECONDITION_FAILED,
           messageOr(e, "Precondition Failed"), e.caching(), e);
     } catch (UnprocessableEntityException e) {
-      replyError(req, res, status = 422, messageOr(e, "Unprocessable Entity"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_UNPROCESSABLE_ENTITY,
+          messageOr(e, "Unprocessable Entity"), e.caching(), e);
     } catch (NotImplementedException e) {
-      replyError(req, res, status = SC_NOT_IMPLEMENTED,
+      responseBytes = replyError(req, res, status = SC_NOT_IMPLEMENTED,
           messageOr(e, "Not Implemented"), e);
     } catch (Exception e) {
       status = SC_INTERNAL_SERVER_ERROR;
-      handleException(e, req, res);
+      responseBytes = handleException(e, req, res);
     } finally {
+      String metric = viewData != null && viewData.view != null
+          ? globals.metrics.view(viewData)
+          : "_unknown";
+      globals.metrics.count.increment(metric);
+      if (status >= SC_BAD_REQUEST) {
+        globals.metrics.errorCount.increment(metric, status);
+      }
+      if (responseBytes != -1) {
+        globals.metrics.responseBytes.record(metric, responseBytes);
+      }
+      globals.metrics.serverLatency.record(
+          metric,
+          System.nanoTime() - startNanos,
+          TimeUnit.NANOSECONDS);
       globals.auditService.dispatch(new ExtendedHttpAuditEvent(globals.webSession.get()
           .getSessionId(), globals.currentUser.get(), req,
           auditStartTs, params, inputRequestBody, status,
@@ -591,22 +624,7 @@
     for (Field f : obj.getClass().getDeclaredFields()) {
       if (f.getType() == RawInput.class) {
         f.setAccessible(true);
-        f.set(obj, new RawInput() {
-          @Override
-          public String getContentType() {
-            return req.getContentType();
-          }
-
-          @Override
-          public long getContentLength() {
-            return req.getContentLength();
-          }
-
-          @Override
-          public InputStream getInputStream() throws IOException {
-            return req.getInputStream();
-          }
-        });
+        f.set(obj, RawInputUtil.create(req));
         return obj;
       }
     }
@@ -650,7 +668,7 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
-  public static void replyJson(@Nullable HttpServletRequest req,
+  public static long replyJson(@Nullable HttpServletRequest req,
       HttpServletResponse res,
       Multimap<String, String> config,
       Object result)
@@ -666,7 +684,7 @@
     }
     w.write('\n');
     w.flush();
-    replyBinaryResult(req, res, asBinaryResult(buf)
+    return replyBinaryResult(req, res, asBinaryResult(buf)
       .setContentType(JSON_TYPE)
       .setCharacterEncoding(UTF_8));
   }
@@ -698,13 +716,13 @@
 
   private static void enablePartialGetFields(GsonBuilder gb,
       Multimap<String, String> config) {
-    final Set<String> want = Sets.newHashSet();
+    final Set<String> want = new HashSet<>();
     for (String p : config.get("fields")) {
       Iterables.addAll(want, OptionUtil.splitOptionValue(p));
     }
     if (!want.isEmpty()) {
       gb.addSerializationExclusionStrategy(new ExclusionStrategy() {
-        private final Map<String, String> names = Maps.newHashMap();
+        private final Map<String, String> names = new HashMap<>();
 
         @Override
         public boolean shouldSkipField(FieldAttributes field) {
@@ -735,7 +753,7 @@
   }
 
   @SuppressWarnings("resource")
-  static void replyBinaryResult(
+  static long replyBinaryResult(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
       BinaryResult bin) throws IOException {
@@ -766,10 +784,13 @@
       }
 
       if (req == null || !"HEAD".equals(req.getMethod())) {
-        try (OutputStream dst = res.getOutputStream()) {
+        try (CountingOutputStream dst =
+            new CountingOutputStream(res.getOutputStream())) {
           bin.writeTo(dst);
+          return dst.getCount();
         }
       }
+      return 0;
     } finally {
       appResult.close();
     }
@@ -779,7 +800,7 @@
       final BinaryResult src) throws IOException {
     TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
     buf.write(JSON_MAGIC);
-    try(Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
+    try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
         JsonWriter json = new JsonWriter(w)) {
       json.setLenient(true);
       json.setHtmlSafe(true);
@@ -891,16 +912,15 @@
     RestView<RestResource> core = views.get("gerrit", name);
     if (core != null) {
       return new ViewData(null, core);
-    } else {
-      core = views.get("gerrit", "GET." + p.get(0));
-      if (core instanceof AcceptsPost && "POST".equals(method)) {
-        @SuppressWarnings("unchecked")
-        AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-        return new ViewData(null, ap.post(rsrc));
-      }
+    }
+    core = views.get("gerrit", "GET." + p.get(0));
+    if (core instanceof AcceptsPost && "POST".equals(method)) {
+      @SuppressWarnings("unchecked")
+      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
+      return new ViewData(null, ap.post(rsrc));
     }
 
-    Map<String, RestView<RestResource>> r = Maps.newTreeMap();
+    Map<String, RestView<RestResource>> r = new TreeMap<>();
     for (String plugin : views.plugins()) {
       RestView<RestResource> action = views.get(plugin, name);
       if (action != null) {
@@ -933,7 +953,7 @@
     if (Strings.isNullOrEmpty(path)) {
       return Collections.emptyList();
     }
-    List<IdString> out = Lists.newArrayList();
+    List<IdString> out = new ArrayList<>();
     for (String p : Splitter.on('/').split(path)) {
       out.add(IdString.fromUrl(p));
     }
@@ -976,7 +996,7 @@
         viewData.pluginName, viewData.view.getClass());
   }
 
-  private static void handleException(Throwable err, HttpServletRequest req,
+  private static long handleException(Throwable err, HttpServletRequest req,
       HttpServletResponse res) throws IOException {
     String uri = req.getRequestURI();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
@@ -986,16 +1006,17 @@
 
     if (!res.isCommitted()) {
       res.reset();
-      replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
+      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
     }
+    return 0;
   }
 
-  public static void replyError(HttpServletRequest req, HttpServletResponse res,
+  public static long replyError(HttpServletRequest req, HttpServletResponse res,
       int statusCode, String msg, @Nullable Throwable err) throws IOException {
-    replyError(req, res, statusCode, msg, CacheControl.NONE, err);
+    return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
   }
 
-  public static void replyError(HttpServletRequest req,
+  public static long replyError(HttpServletRequest req,
       HttpServletResponse res, int statusCode, String msg,
       CacheControl c, @Nullable Throwable err) throws IOException {
     if (err != null) {
@@ -1003,25 +1024,23 @@
     }
     configureCaching(req, res, null, null, c);
     res.setStatus(statusCode);
-    replyText(req, res, msg);
+    return replyText(req, res, msg);
   }
 
-  static void replyText(@Nullable HttpServletRequest req,
+  static long replyText(@Nullable HttpServletRequest req,
       HttpServletResponse res, String text) throws IOException {
     if ((req == null || isGetOrHead(req)) && isMaybeHTML(text)) {
-      replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
-    } else {
-      if (!text.endsWith("\n")) {
-        text += "\n";
-      }
-      replyBinaryResult(req, res,
-          BinaryResult.create(text).setContentType("text/plain"));
+      return replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
+    if (!text.endsWith("\n")) {
+      text += "\n";
+    }
+    return replyBinaryResult(req, res,
+        BinaryResult.create(text).setContentType("text/plain"));
   }
 
-  private static final Pattern IS_HTML = Pattern.compile("[<&]");
   private static boolean isMaybeHTML(String text) {
-    return IS_HTML.matcher(text).find();
+    return CharMatcher.anyOf("<&").matchesAnyOf(text);
   }
 
   private static boolean acceptsJson(HttpServletRequest req) {
@@ -1098,7 +1117,7 @@
     }
   }
 
-  private static class ViewData {
+  static class ViewData {
     String pluginName;
     RestView<RestResource> view;
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
index 547bf45..9cf6504 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.gerrit.common.errors.CorruptEntityException;
 import com.google.gerrit.common.errors.InvalidQueryException;
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -28,6 +27,8 @@
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Provider;
 
+import java.io.IOException;
+
 /** Support for services which require a {@link ReviewDb} instance. */
 public class BaseServiceImplementation {
   private final Provider<ReviewDb> schema;
@@ -48,6 +49,10 @@
     return currentUser.get();
   }
 
+  protected ReviewDb getDb() {
+    return schema.get();
+  }
+
   /**
    * Executes {@code action.run} with an active ReviewDb connection.
    * <p>
@@ -83,6 +88,8 @@
       handleOrmException(callback, ex);
     } catch (OrmException e) {
       handleOrmException(callback, e);
+    } catch (IOException e) {
+      callback.onFailure(e);
     } catch (Failure e) {
       if (e.getCause() instanceof NoSuchProjectException
           || e.getCause() instanceof NoSuchChangeException) {
@@ -98,8 +105,6 @@
       final AsyncCallback<T> callback, Exception e) {
     if (e.getCause() instanceof Failure) {
       callback.onFailure(e.getCause().getCause());
-    } else if (e.getCause() instanceof CorruptEntityException) {
-      callback.onFailure(e.getCause());
     } else if (e.getCause() instanceof NoSuchEntityException) {
       callback.onFailure(e.getCause());
     } else {
@@ -117,7 +122,7 @@
   }
 
   /** Arbitrary action to run with a database connection. */
-  public static interface Action<T> {
+  public interface Action<T> {
     /**
      * Perform this action, returning the onSuccess value.
      *
@@ -131,6 +136,6 @@
      * @throws InvalidQueryException
      */
     T run(ReviewDb db) throws OrmException, Failure, NoSuchProjectException,
-        NoSuchGroupException, InvalidQueryException;
+        NoSuchGroupException, InvalidQueryException, IOException;
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
index 3b064e2..319907b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
@@ -249,9 +249,8 @@
     public MethodHandle getMethod() {
       if (currentMethod.get() == null) {
         return super.getMethod();
-      } else {
-        return currentMethod.get();
       }
+      return currentMethod.get();
     }
 
     @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
index 1b2b990..9364764 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.gerrit.common.errors.CorruptEntityException;
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -74,9 +73,6 @@
       if (e.getCause() instanceof BaseServiceImplementation.Failure) {
         callback.onFailure(e.getCause().getCause());
 
-      } else if (e.getCause() instanceof CorruptEntityException) {
-        callback.onFailure(e.getCause());
-
       } else if (e.getCause() instanceof NoSuchEntityException) {
         callback.onFailure(e.getCause());
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java
deleted file mode 100644
index 69db233..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.SuggestService;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import java.util.Collections;
-import java.util.List;
-
-class SuggestServiceImpl extends BaseServiceImplementation implements
-    SuggestService {
-  private final ProjectControl.Factory projectControlFactory;
-  private final GroupBackend groupBackend;
-
-  @Inject
-  SuggestServiceImpl(final Provider<ReviewDb> schema,
-      final Provider<CurrentUser> currentUser,
-      final ProjectControl.Factory projectControlFactory,
-      final GroupBackend groupBackend) {
-    super(schema, currentUser);
-    this.projectControlFactory = projectControlFactory;
-    this.groupBackend = groupBackend;
-  }
-
-  @Override
-  public void suggestAccountGroupForProject(final Project.NameKey project,
-      final String query, final int limit,
-      final AsyncCallback<List<GroupReference>> callback) {
-    run(callback, new Action<List<GroupReference>>() {
-      @Override
-      public List<GroupReference> run(final ReviewDb db) {
-        ProjectControl projectControl = null;
-        if (project != null) {
-          try {
-            projectControl = projectControlFactory.controlFor(project);
-          } catch (NoSuchProjectException e) {
-            return Collections.emptyList();
-          }
-        }
-        return suggestAccountGroup(projectControl, query, limit);
-      }
-    });
-  }
-
-  private List<GroupReference> suggestAccountGroup(
-      @Nullable final ProjectControl projectControl, final String query, final int limit) {
-    return Lists.newArrayList(Iterables.limit(
-        groupBackend.suggest(query, projectControl),
-        limit <= 0 ? 10 : Math.min(limit, 10)));
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
index 08e1582..4398c78 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.httpd.rpc;
 
 import com.google.gerrit.httpd.rpc.account.AccountModule;
-import com.google.gerrit.httpd.rpc.changedetail.ChangeModule;
-import com.google.gerrit.httpd.rpc.patch.PatchModule;
 import com.google.gerrit.httpd.rpc.project.ProjectModule;
 
 /** Registers servlets to answer RPCs from client UI. */
@@ -27,12 +25,9 @@
 
   @Override
   protected void configureServlets() {
-    rpc(SuggestServiceImpl.class);
     rpc(SystemInfoServiceImpl.class);
 
     install(new AccountModule());
-    install(new ChangeModule());
-    install(new PatchModule());
     install(new ProjectModule());
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/access/AccessRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/access/AccessRestApiServlet.java
deleted file mode 100644
index fda6416..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/access/AccessRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.access;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.access.AccessCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class AccessRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  AccessRestApiServlet(RestApiServlet.Globals globals,
-      Provider<AccessCollection> access) {
-    super(globals, access);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
index e0d63c8..8fcf9ea 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.data.AccountSecurity;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.errors.NoSuchEntityException;
@@ -33,6 +32,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.extensions.events.AgreementSignup;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
@@ -40,6 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
@@ -55,9 +56,9 @@
   private final DeleteExternalIds.Factory deleteExternalIdsFactory;
   private final ExternalIdDetailFactory.Factory externalIdDetailFactory;
 
-  private final ChangeHooks hooks;
   private final GroupCache groupCache;
   private final AuditService auditService;
+  private final AgreementSignup agreementSignup;
 
   @Inject
   AccountSecurityImpl(final Provider<ReviewDb> schema,
@@ -67,8 +68,9 @@
       final AccountByEmailCache abec, final AccountCache uac,
       final DeleteExternalIds.Factory deleteExternalIdsFactory,
       final ExternalIdDetailFactory.Factory externalIdDetailFactory,
-      final ChangeHooks hooks, final GroupCache groupCache,
-      final AuditService auditService) {
+      final GroupCache groupCache,
+      final AuditService auditService,
+      AgreementSignup agreementSignup) {
     super(schema, currentUser);
     realm = r;
     user = u;
@@ -78,8 +80,8 @@
     this.auditService = auditService;
     this.deleteExternalIdsFactory = deleteExternalIdsFactory;
     this.externalIdDetailFactory = externalIdDetailFactory;
-    this.hooks = hooks;
     this.groupCache = groupCache;
+    this.agreementSignup = agreementSignup;
   }
 
   @Override
@@ -98,7 +100,8 @@
       final AsyncCallback<Account> callback) {
     run(callback, new Action<Account>() {
       @Override
-      public Account run(ReviewDb db) throws OrmException, Failure {
+      public Account run(ReviewDb db)
+          throws OrmException, Failure, IOException {
         IdentifiedUser self = user.get();
         final Account me = db.accounts().get(self.getAccountId());
         final String oldEmail = me.getPreferredEmail();
@@ -133,7 +136,8 @@
       final AsyncCallback<VoidResult> callback) {
     run(callback, new Action<VoidResult>() {
       @Override
-      public VoidResult run(final ReviewDb db) throws OrmException, Failure {
+      public VoidResult run(final ReviewDb db)
+          throws OrmException, Failure, IOException {
         ContributorAgreement ca = projectCache.getAllProjects().getConfig()
             .getContributorAgreement(agreementName);
         if (ca == null) {
@@ -153,7 +157,7 @@
         }
 
         Account account = user.get().getAccount();
-        hooks.doClaSignupHook(account, ca);
+        agreementSignup.fire(account, ca.getName());
 
         final AccountGroupMember.Key key =
             new AccountGroupMember.Key(account.getId(), group.getId());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
index 63ec075..8fba47d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
@@ -14,193 +14,25 @@
 
 package com.google.gerrit.httpd.rpc.account;
 
-import com.google.gerrit.common.data.AccountProjectWatchInfo;
 import com.google.gerrit.common.data.AccountService;
 import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.common.errors.InvalidQueryException;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.SetDiffPreferences;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Set;
-
 class AccountServiceImpl extends BaseServiceImplementation implements
     AccountService {
-  private final Provider<IdentifiedUser> currentUser;
-  private final ProjectControl.Factory projectControlFactory;
   private final AgreementInfoFactory.Factory agreementInfoFactory;
-  private final Provider<ChangeQueryBuilder> queryBuilder;
-  private final SetDiffPreferences setDiff;
 
   @Inject
   AccountServiceImpl(final Provider<ReviewDb> schema,
       final Provider<IdentifiedUser> identifiedUser,
-      final ProjectControl.Factory projectControlFactory,
-      final AgreementInfoFactory.Factory agreementInfoFactory,
-      final Provider<ChangeQueryBuilder> queryBuilder,
-      SetDiffPreferences setDiff) {
+      final AgreementInfoFactory.Factory agreementInfoFactory) {
     super(schema, identifiedUser);
-    this.currentUser = identifiedUser;
-    this.projectControlFactory = projectControlFactory;
     this.agreementInfoFactory = agreementInfoFactory;
-    this.queryBuilder = queryBuilder;
-    this.setDiff = setDiff;
-  }
-
-  @Override
-  public void myAccount(final AsyncCallback<Account> callback) {
-    run(callback, new Action<Account>() {
-      @Override
-      public Account run(ReviewDb db) throws OrmException {
-        return db.accounts().get(currentUser.get().getAccountId());
-      }
-    });
-  }
-
-  @Override
-  public void changeDiffPreferences(final DiffPreferencesInfo diffPref,
-      AsyncCallback<VoidResult> callback) {
-    run(callback, new Action<VoidResult>(){
-      @Override
-      public VoidResult run(ReviewDb db) throws OrmException {
-        if (!getUser().isIdentifiedUser()) {
-          throw new IllegalArgumentException("Not authenticated");
-        }
-        IdentifiedUser me = getUser().asIdentifiedUser();
-        try {
-          setDiff.apply(new AccountResource(me), diffPref);
-        } catch (AuthException | BadRequestException | ConfigInvalidException
-            | IOException e) {
-          throw new OrmException("Cannot save diff preferences", e);
-        }
-        return VoidResult.INSTANCE;
-      }
-    });
-  }
-
-  @Override
-  public void myProjectWatch(
-      final AsyncCallback<List<AccountProjectWatchInfo>> callback) {
-    run(callback, new Action<List<AccountProjectWatchInfo>>() {
-      @Override
-      public List<AccountProjectWatchInfo> run(ReviewDb db) throws OrmException {
-        List<AccountProjectWatchInfo> r = new ArrayList<>();
-
-        for (final AccountProjectWatch w : db.accountProjectWatches()
-            .byAccount(getAccountId()).toList()) {
-          final ProjectControl ctl;
-          try {
-            ctl = projectControlFactory.validateFor(w.getProjectNameKey());
-          } catch (NoSuchProjectException e) {
-            db.accountProjectWatches().delete(Collections.singleton(w));
-            continue;
-          }
-          r.add(new AccountProjectWatchInfo(w, ctl.getProject()));
-        }
-        Collections.sort(r, new Comparator<AccountProjectWatchInfo>() {
-          @Override
-          public int compare(final AccountProjectWatchInfo a,
-              final AccountProjectWatchInfo b) {
-            return a.getProject().getName().compareTo(b.getProject().getName());
-          }
-        });
-        return r;
-      }
-    });
-  }
-
-  @Override
-  public void addProjectWatch(final String projectName, final String filter,
-      final AsyncCallback<AccountProjectWatchInfo> callback) {
-    run(callback, new Action<AccountProjectWatchInfo>() {
-      @Override
-      public AccountProjectWatchInfo run(ReviewDb db) throws OrmException,
-          NoSuchProjectException, InvalidQueryException {
-        final Project.NameKey nameKey = new Project.NameKey(projectName);
-        final ProjectControl ctl = projectControlFactory.validateFor(nameKey);
-
-        if (filter != null) {
-          try {
-            queryBuilder.get().parse(filter);
-          } catch (QueryParseException badFilter) {
-            throw new InvalidQueryException(badFilter.getMessage(), filter);
-          }
-        }
-
-        AccountProjectWatch watch =
-            new AccountProjectWatch(new AccountProjectWatch.Key(
-                ctl.getUser().getAccountId(),
-                nameKey, filter));
-        try {
-          db.accountProjectWatches().insert(Collections.singleton(watch));
-        } catch (OrmDuplicateKeyException alreadyHave) {
-          watch = db.accountProjectWatches().get(watch.getKey());
-        }
-        return new AccountProjectWatchInfo(watch, ctl.getProject());
-      }
-    });
-  }
-
-  @Override
-  public void updateProjectWatch(final AccountProjectWatch watch,
-      final AsyncCallback<VoidResult> callback) {
-    if (!getAccountId().equals(watch.getAccountId())) {
-      callback.onFailure(new NoSuchEntityException());
-      return;
-    }
-
-    run(callback, new Action<VoidResult>() {
-      @Override
-      public VoidResult run(ReviewDb db) throws OrmException {
-        db.accountProjectWatches().update(Collections.singleton(watch));
-        return VoidResult.INSTANCE;
-      }
-    });
-  }
-
-  @Override
-  public void deleteProjectWatches(final Set<AccountProjectWatch.Key> keys,
-      final AsyncCallback<VoidResult> callback) {
-    run(callback, new Action<VoidResult>() {
-      @Override
-      public VoidResult run(final ReviewDb db) throws OrmException, Failure {
-        final Account.Id me = getAccountId();
-        for (final AccountProjectWatch.Key keyId : keys) {
-          if (!me.equals(keyId.getParentKey())) {
-            throw new Failure(new NoSuchEntityException());
-          }
-        }
-
-        db.accountProjectWatches().deleteKeys(keys);
-        return VoidResult.INSTANCE;
-      }
-    });
   }
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountsRestApiServlet.java
deleted file mode 100644
index 351d563..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountsRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.account;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.account.AccountsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class AccountsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  AccountsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<AccountsCollection> accounts) {
-    super(globals, accounts);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
index 0fff8ce..91afd97 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.httpd.rpc.account;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AgreementInfo;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.PermissionRule;
@@ -29,7 +27,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -54,14 +54,14 @@
 
   @Override
   public AgreementInfo call() throws Exception {
-    List<String> accepted = Lists.newArrayList();
-    Map<String, ContributorAgreement> agreements = Maps.newHashMap();
+    List<String> accepted = new ArrayList<>();
+    Map<String, ContributorAgreement> agreements = new HashMap<>();
     Collection<ContributorAgreement> cas =
         projectCache.getAllProjects().getConfig().getContributorAgreements();
     for (ContributorAgreement ca : cas) {
       agreements.put(ca.getName(), ca.forUi());
 
-      List<AccountGroup.UUID> groupIds = Lists.newArrayList();
+      List<AccountGroup.UUID> groupIds = new ArrayList<>();
       for (PermissionRule rule : ca.getAccepted()) {
         if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
           if (rule.getGroup().getUUID() == null) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
index 1d45c7d..34b7a4b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
@@ -24,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -60,7 +61,7 @@
   }
 
   @Override
-  public Set<AccountExternalId.Key> call() throws OrmException {
+  public Set<AccountExternalId.Key> call() throws OrmException, IOException {
     final Map<AccountExternalId.Key, AccountExternalId> have = have();
 
     List<AccountExternalId> toDelete = new ArrayList<>();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/change/ChangesRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/change/ChangesRestApiServlet.java
deleted file mode 100644
index fe810b9..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/change/ChangesRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.change;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ChangesRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ChangesRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ChangesCollection> changes) {
-    super(globals, changes);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeDetailServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeDetailServiceImpl.java
deleted file mode 100644
index 37ca524..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeDetailServiceImpl.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.changedetail;
-
-import com.google.gerrit.common.data.ChangeDetailService;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-
-class ChangeDetailServiceImpl implements ChangeDetailService {
-  private final PatchSetDetailFactory.Factory patchSetDetail;
-
-  @Inject
-  ChangeDetailServiceImpl(
-      final PatchSetDetailFactory.Factory patchSetDetail) {
-    this.patchSetDetail = patchSetDetail;
-  }
-
-  @Override
-  public void patchSetDetail(PatchSet.Id id,
-      AsyncCallback<PatchSetDetail> callback) {
-    patchSetDetail2(null, id, null, callback);
-  }
-
-  @Override
-  public void patchSetDetail2(PatchSet.Id baseId, PatchSet.Id id,
-      DiffPreferencesInfo diffPrefs, AsyncCallback<PatchSetDetail> callback) {
-    patchSetDetail.create(baseId, id, diffPrefs).to(callback);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeModule.java
deleted file mode 100644
index d8adfe3..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/ChangeModule.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.changedetail;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.httpd.rpc.RpcServletModule;
-import com.google.gerrit.httpd.rpc.UiRpcModule;
-
-public class ChangeModule extends RpcServletModule {
-  public ChangeModule() {
-    super(UiRpcModule.PREFIX);
-  }
-
-  @Override
-  protected void configureServlets() {
-    install(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(PatchSetDetailFactory.Factory.class);
-      }
-    });
-    rpc(ChangeDetailServiceImpl.class);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java
deleted file mode 100644
index d2dc384..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java
+++ /dev/null
@@ -1,228 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.changedetail;
-
-import com.google.common.base.Optional;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.PatchSetDetail;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Creates a {@link PatchSetDetail} from a {@link PatchSet}. */
-class PatchSetDetailFactory extends Handler<PatchSetDetail> {
-
-  private static final Logger log =
-    LoggerFactory.getLogger(PatchSetDetailFactory.class);
-
-  interface Factory {
-    PatchSetDetailFactory create(
-        @Assisted("psIdBase") @Nullable PatchSet.Id psIdBase,
-        @Assisted("psIdNew") PatchSet.Id psIdNew,
-        @Nullable DiffPreferencesInfo diffPrefs);
-  }
-
-  private final PatchSetInfoFactory infoFactory;
-  private final ReviewDb db;
-  private final PatchListCache patchListCache;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final PatchLineCommentsUtil plcUtil;
-  private final ChangeEditUtil editUtil;
-
-  private Project.NameKey project;
-  private final PatchSet.Id psIdBase;
-  private final PatchSet.Id psIdNew;
-  private final DiffPreferencesInfo diffPrefs;
-  private ObjectId oldId;
-  private ObjectId newId;
-
-  private PatchSetDetail detail;
-  ChangeControl control;
-  PatchSet patchSet;
-
-  @Inject
-  PatchSetDetailFactory(final PatchSetInfoFactory psif, final ReviewDb db,
-      final PatchListCache patchListCache,
-      final Provider<CurrentUser> userProvider,
-      final ChangeControl.GenericFactory changeControlFactory,
-      final PatchLineCommentsUtil plcUtil,
-      ChangeEditUtil editUtil,
-      @Assisted("psIdBase") @Nullable final PatchSet.Id psIdBase,
-      @Assisted("psIdNew") final PatchSet.Id psIdNew,
-      @Assisted @Nullable final DiffPreferencesInfo diffPrefs) {
-    this.infoFactory = psif;
-    this.db = db;
-    this.patchListCache = patchListCache;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-    this.plcUtil = plcUtil;
-    this.editUtil = editUtil;
-
-    this.psIdBase = psIdBase;
-    this.psIdNew = psIdNew;
-    this.diffPrefs = diffPrefs;
-  }
-
-  @Override
-  public PatchSetDetail call() throws OrmException, NoSuchEntityException,
-      PatchSetInfoNotAvailableException, NoSuchChangeException, AuthException,
-      IOException {
-    Optional<ChangeEdit> edit = null;
-    if (control == null || patchSet == null) {
-      control = changeControlFactory.validateFor(psIdNew.getParentKey(),
-          userProvider.get());
-      if (psIdNew.get() == 0) {
-        Change change = db.changes().get(psIdNew.getParentKey());
-        edit = editUtil.byChange(change);
-        if (edit.isPresent()) {
-          patchSet = edit.get().getBasePatchSet();
-        }
-      } else {
-        patchSet = db.patchSets().get(psIdNew);
-      }
-      if (patchSet == null) {
-        throw new NoSuchEntityException();
-      }
-    }
-    project = control.getProject().getNameKey();
-    final PatchList list;
-
-    try {
-      if (psIdBase != null) {
-        oldId = toObjectId(psIdBase);
-        if (edit != null && edit.isPresent()) {
-          newId = edit.get().getEditCommit().toObjectId();
-        } else {
-          newId = toObjectId(psIdNew);
-        }
-
-        list = listFor(keyFor(diffPrefs.ignoreWhitespace));
-      } else { // OK, means use base to compare
-        list = patchListCache.get(control.getChange(), patchSet);
-      }
-    } catch (PatchListNotAvailableException e) {
-      throw new NoSuchEntityException();
-    }
-
-    final List<Patch> patches = list.toPatchList(patchSet.getId());
-    final Map<Patch.Key, Patch> byKey = new HashMap<>();
-    for (final Patch p : patches) {
-      byKey.put(p.getKey(), p);
-    }
-
-    ChangeNotes notes = control.getNotes();
-    if (edit == null) {
-      for (PatchLineComment c : plcUtil.publishedByPatchSet(db, notes, psIdNew)) {
-        final Patch p = byKey.get(c.getKey().getParentKey());
-        if (p != null) {
-          p.setCommentCount(p.getCommentCount() + 1);
-        }
-      }
-    }
-
-    detail = new PatchSetDetail();
-    detail.setPatchSet(patchSet);
-    detail.setProject(project);
-
-    detail.setInfo(infoFactory.get(db, patchSet.getId()));
-    detail.setPatches(patches);
-
-    final CurrentUser user = control.getUser();
-    if (user.isIdentifiedUser() && edit == null) {
-      // If we are signed in, compute the number of draft comments by the
-      // current user on each of these patch files. This way they can more
-      // quickly locate where they have pending drafts, and review them.
-      //
-      final Account.Id me = user.getAccountId();
-      for (PatchLineComment c
-          : plcUtil.draftByPatchSetAuthor(db, psIdNew, me, notes)) {
-        final Patch p = byKey.get(c.getKey().getParentKey());
-        if (p != null) {
-          p.setDraftCount(p.getDraftCount() + 1);
-        }
-      }
-
-      for (AccountPatchReview r : db.accountPatchReviews().byReviewer(me, psIdNew)) {
-        final Patch p = byKey.get(r.getKey().getPatchKey());
-        if (p != null) {
-          p.setReviewedByCurrentUser(true);
-        }
-      }
-    }
-
-    return detail;
-  }
-
-  private ObjectId toObjectId(final PatchSet.Id psId) throws OrmException,
-      NoSuchEntityException {
-    final PatchSet ps = db.patchSets().get(psId);
-    if (ps == null) {
-      throw new NoSuchEntityException();
-    }
-
-    try {
-      return ObjectId.fromString(ps.getRevision().get());
-    } catch (IllegalArgumentException e) {
-      log.error("Patch set " + psId + " has invalid revision");
-      throw new NoSuchEntityException();
-    }
-  }
-
-  private PatchListKey keyFor(Whitespace whitespace) {
-    return new PatchListKey(oldId, newId, whitespace);
-  }
-
-  private PatchList listFor(PatchListKey key)
-      throws PatchListNotAvailableException {
-    return patchListCache.get(key, project);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/config/ConfigRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/config/ConfigRestApiServlet.java
deleted file mode 100644
index f951ad3..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/config/ConfigRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.config;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.config.ConfigCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ConfigRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ConfigRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ConfigCollection> configCollection) {
-    super(globals, configCollection);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
index 002b520..973adbe 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
@@ -24,6 +24,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.IOException;
 import java.util.List;
 
@@ -38,6 +41,9 @@
 
 @Singleton
 public class QueryDocumentationFilter implements Filter {
+  private final Logger log =
+      LoggerFactory.getLogger(QueryDocumentationFilter.class);
+
   private final QueryDocumentationExecutor searcher;
 
   @Inject
@@ -65,6 +71,7 @@
         Multimap<String, String> config = LinkedHashMultimap.create();
         RestApiServlet.replyJson(req, rsp, config, result);
       } catch (DocQueryException e) {
+        log.error("Doc search failed:", e);
         rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       }
     } else {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/group/GroupsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/group/GroupsRestApiServlet.java
deleted file mode 100644
index 04dc747..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/group/GroupsRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.group;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.group.GroupsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GroupsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  GroupsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<GroupsCollection> groups) {
-    super(globals, groups);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchDetailServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchDetailServiceImpl.java
deleted file mode 100644
index f9180f7..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchDetailServiceImpl.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.patch;
-
-import com.google.gerrit.common.data.PatchDetailService;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.patch.PatchScriptFactory;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-class PatchDetailServiceImpl extends BaseServiceImplementation implements
-    PatchDetailService {
-  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  PatchDetailServiceImpl(final Provider<ReviewDb> schema,
-      final Provider<CurrentUser> currentUser,
-      final PatchScriptFactory.Factory patchScriptFactoryFactory,
-      final ChangeControl.GenericFactory changeControlFactory) {
-    super(schema, currentUser);
-
-    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-    this.changeControlFactory = changeControlFactory;
-  }
-
-  @Override
-  public void patchScript(final Patch.Key patchKey, final PatchSet.Id psa,
-      final PatchSet.Id psb, final DiffPreferencesInfo dp,
-      final AsyncCallback<PatchScript> callback) {
-    if (psb == null) {
-      callback.onFailure(new NoSuchEntityException());
-      return;
-    }
-
-    new Handler<PatchScript>() {
-      @Override
-      public PatchScript call() throws Exception {
-        ChangeControl control = changeControlFactory.validateFor(
-            patchKey.getParentKey().getParentKey(),
-            getUser());
-        return patchScriptFactoryFactory.create(
-            control, patchKey.getFileName(), psa, psb, dp).call();
-      }
-    }.to(callback);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchModule.java
deleted file mode 100644
index 83bfcdc..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/patch/PatchModule.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.patch;
-
-import com.google.gerrit.httpd.rpc.RpcServletModule;
-import com.google.gerrit.httpd.rpc.UiRpcModule;
-
-public class PatchModule extends RpcServletModule {
-  public PatchModule() {
-    super(UiRpcModule.PREFIX);
-  }
-
-  @Override
-  protected void configureServlets() {
-    rpc(PatchDetailServiceImpl.class);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index e877d1e..3f471bf 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -51,8 +50,7 @@
         @Nullable @Assisted String message);
   }
 
-  private final ChangeHooks hooks;
-  private final IdentifiedUser user;
+  private final GitReferenceUpdated gitRefUpdated;
   private final ProjectAccessFactory.Factory projectAccessFactory;
   private final ProjectCache projectCache;
 
@@ -61,9 +59,9 @@
       ProjectControl.Factory projectControlFactory,
       ProjectCache projectCache, GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsNameProvider allProjects,
+      AllProjectsName allProjects,
       Provider<SetParent> setParent,
-      ChangeHooks hooks, IdentifiedUser user,
+      GitReferenceUpdated gitRefUpdated,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
@@ -74,19 +72,17 @@
         parentProjectName, message, true);
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
-    this.hooks = hooks;
-    this.user = user;
+    this.gitRefUpdated = gitRefUpdated;
   }
 
   @Override
-  protected ProjectAccess updateProjectConfig(ProjectControl ctl,
+  protected ProjectAccess updateProjectConfig(CurrentUser user,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException {
     RevCommit commit = config.commit(md);
 
-    hooks.doRefUpdatedHook(
-      new Branch.NameKey(config.getProject().getNameKey(), RefNames.REFS_CONFIG),
-      base, commit.getId(), user.getAccount());
+    gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
+        base, commit.getId(), user.asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index aee238d..ed2a4f9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -100,8 +100,7 @@
     // state, force a cache flush now.
     //
     ProjectConfig config;
-    MetaDataUpdate md = metaDataUpdateFactory.create(projectName);
-    try {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
 
       if (config.updateGroupNames(groupBackend)) {
@@ -115,8 +114,6 @@
         projectCache.evict(config.getProject());
         pc = open();
       }
-    } finally {
-      md.close();
     }
 
     final RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index ba4f012..111dfc9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -18,25 +18,28 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.common.errors.UpdateParentFailedException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.project.SetParent;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
@@ -55,7 +58,7 @@
   private final ProjectControl.Factory projectControlFactory;
   protected final GroupBackend groupBackend;
   private final MetaDataUpdate.User metaDataUpdateFactory;
-  private final AllProjectsNameProvider allProjects;
+  private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
 
   protected final Project.NameKey projectName;
@@ -67,7 +70,7 @@
 
   protected ProjectAccessHandler(ProjectControl.Factory projectControlFactory,
       GroupBackend groupBackend, MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsNameProvider allProjects, Provider<SetParent> setParent,
+      AllProjectsName allProjects, Provider<SetParent> setParent,
       Project.NameKey projectName, ObjectId base,
       List<AccessSection> sectionList, Project.NameKey parentProjectName,
       String message, boolean checkIfOwner) {
@@ -88,17 +91,16 @@
   @Override
   public final T call() throws NoSuchProjectException, IOException,
       ConfigInvalidException, InvalidNameException, NoSuchGroupException,
-      OrmException, UpdateParentFailedException {
+      OrmException, UpdateParentFailedException, PermissionDeniedException {
     final ProjectControl projectControl =
         projectControlFactory.controlFor(projectName);
 
-    final MetaDataUpdate md;
-    try {
-      md = metaDataUpdateFactory.create(projectName);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new NoSuchProjectException(projectName);
+    Capable r = projectControl.canPushToAtLeastOneRef();
+    if (r != Capable.OK) {
+      throw new PermissionDeniedException(r.getMessage());
     }
-    try {
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       ProjectConfig config = ProjectConfig.read(md, base);
       Set<String> toDelete = scanSectionNames(config);
 
@@ -116,7 +118,7 @@
             continue;
           }
 
-          RefControl.validateRefPattern(name);
+          RefPattern.validate(name);
 
           replace(config, toDelete, section);
         }
@@ -134,12 +136,12 @@
       }
 
       boolean parentProjectUpdate = false;
-      if (!config.getProject().getNameKey().equals(allProjects.get()) &&
-          !config.getProject().getParent(allProjects.get()).equals(parentProjectName)) {
+      if (!config.getProject().getNameKey().equals(allProjects) &&
+          !config.getProject().getParent(allProjects).equals(parentProjectName)) {
         parentProjectUpdate = true;
         try {
           setParent.get().validateParentUpdate(projectControl,
-              MoreObjects.firstNonNull(parentProjectName, allProjects.get()).get(),
+              MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
               checkIfOwner);
         } catch (AuthException e) {
           throw new UpdateParentFailedException(
@@ -161,14 +163,14 @@
         md.setMessage("Modify access rules\n");
       }
 
-      return updateProjectConfig(projectControl, config, md,
+      return updateProjectConfig(projectControl.getUser(), config, md,
           parentProjectUpdate);
-    } finally {
-      md.close();
+    } catch (RepositoryNotFoundException notFound) {
+      throw new NoSuchProjectException(projectName);
     }
   }
 
-  protected abstract T updateProjectConfig(ProjectControl ctl,
+  protected abstract T updateProjectConfig(CurrentUser user,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException,
       OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectsRestApiServlet.java
deleted file mode 100644
index ea3b8b0..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectsRestApiServlet.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.project;
-
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class ProjectsRestApiServlet extends RestApiServlet {
-  private static final long serialVersionUID = 1L;
-
-  @Inject
-  ProjectsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ProjectsCollection> projects) {
-    super(globals, projects);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 5b3b064..9260e01 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.AccessSection;
@@ -23,19 +22,19 @@
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
@@ -69,7 +68,7 @@
   }
 
   private final ReviewDb db;
-  private final IdentifiedUser user;
+  private final Sequences seq;
   private final Provider<PostReviewers> reviewersProvider;
   private final ProjectCache projectCache;
   private final ChangesCollection changes;
@@ -80,14 +79,14 @@
   ReviewProjectAccess(final ProjectControl.Factory projectControlFactory,
       GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory, ReviewDb db,
-      IdentifiedUser user,
       Provider<PostReviewers> reviewersProvider,
       ProjectCache projectCache,
-      AllProjectsNameProvider allProjects,
+      AllProjectsName allProjects,
       ChangesCollection changes,
       ChangeInserter.Factory changeInserterFactory,
       BatchUpdate.Factory updateFactory,
       Provider<SetParent> setParent,
+      Sequences seq,
 
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
@@ -98,7 +97,7 @@
         allProjects, setParent, projectName, base, sectionList,
         parentProjectName, message, false);
     this.db = db;
-    this.user = user;
+    this.seq = seq;
     this.reviewersProvider = reviewersProvider;
     this.projectCache = projectCache;
     this.changes = changes;
@@ -107,11 +106,11 @@
   }
 
   @Override
-  protected Change.Id updateProjectConfig(ProjectControl ctl,
+  protected Change.Id updateProjectConfig(CurrentUser user,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, OrmException {
     md.setInsertChangeId(true);
-    Change.Id changeId = new Change.Id(db.nextChangeId());
+    Change.Id changeId = new Change.Id(seq.nextChangeId());
     RevCommit commit =
         config.commitToNewRef(md, new PatchSet.Id(changeId,
             Change.INITIAL_PATCH_SET_ID).toRefName());
@@ -119,23 +118,14 @@
       return null;
     }
 
-    Change change = new Change(
-        getChangeId(commit),
-        changeId,
-        user.getAccountId(),
-        new Branch.NameKey(
-            config.getProject().getNameKey(),
-            RefNames.REFS_CONFIG),
-        TimeUtil.nowTs());
     try (RevWalk rw = new RevWalk(md.getRepository());
         ObjectInserter objInserter = md.getRepository().newObjectInserter();
         BatchUpdate bu = updateFactory.create(
-          db, change.getProject(), ctl.getUser(),
-          change.getCreatedOn())) {
+          db, config.getProject().getNameKey(), user,
+          TimeUtil.nowTs())) {
       bu.setRepository(md.getRepository(), rw, objInserter);
       bu.insertChange(
-          changeInserterFactory.create(
-                ctl.controlForRef(change.getDest().get()), change, commit)
+          changeInserterFactory.create(changeId, commit, RefNames.REFS_CONFIG)
               .setValidatePolicy(CommitValidators.Policy.NONE)
               .setUpdateRef(false)); // Created by commitToNewRef.
       bu.execute();
@@ -156,14 +146,6 @@
     return changeId;
   }
 
-  private static Change.Key getChangeId(RevCommit commit) {
-    List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
-    Change.Key changeKey = !idList.isEmpty()
-        ? new Change.Key(idList.get(idList.size() - 1).trim())
-        : new Change.Key("I" + commit.name());
-    return changeKey;
-  }
-
   private void addProjectOwnersAsReviewers(ChangeResource rsrc) {
     final String projectOwners =
         groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
@@ -171,7 +153,8 @@
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = projectOwners;
       reviewersProvider.get().apply(rsrc, input);
-    } catch (IOException | OrmException | RestApiException e) {
+    } catch (IOException | OrmException |
+        RestApiException | UpdateException e) {
       // one of the owner groups is not visible to the user and this it why it
       // can't be added as reviewer
     }
@@ -187,7 +170,8 @@
         AddReviewerInput input = new AddReviewerInput();
         input.reviewer = r.getGroup().getUUID().get();
         reviewersProvider.get().apply(rsrc, input);
-      } catch (IOException | OrmException | RestApiException e) {
+      } catch (IOException | OrmException |
+          RestApiException | UpdateException e) {
         // ignore
       }
     }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java
new file mode 100644
index 0000000..065fa5d
--- /dev/null
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class LfsPluginServletTest {
+
+  @Test
+  public void noLfsEndPoint_noMatch() {
+    Pattern p = Pattern.compile(LfsPluginServlet.URL_REGEX);
+    doesNotMatch(p, "/foo");
+    doesNotMatch(p, "/a/foo");
+    doesNotMatch(p, "/p/foo");
+    doesNotMatch(p, "/a/p/foo");
+
+    doesNotMatch(p, "/info/lfs/objects/batch");
+    doesNotMatch(p, "/info/lfs/objects/batch/foo");
+  }
+
+  @Test
+  public void matchingLfsEndpoint_projectNameCaptured() {
+    Pattern p = Pattern.compile(LfsPluginServlet.URL_REGEX);
+    matches(p, "/foo/bar/info/lfs/objects/batch", "foo/bar");
+    matches(p, "/a/foo/bar/info/lfs/objects/batch", "foo/bar");
+    matches(p, "/p/foo/bar/info/lfs/objects/batch", "foo/bar");
+    matches(p, "/a/p/foo/bar/info/lfs/objects/batch", "foo/bar");
+  }
+
+  private void doesNotMatch(Pattern p, String input) {
+    Matcher m = p.matcher(input);
+    assertThat(m.matches()).isFalse();
+  }
+
+  private void matches(Pattern p, String input, String expectedProjectName) {
+    Matcher m = p.matcher(input);
+    assertThat(m.matches()).isTrue();
+    assertThat(m.group(1)).isEqualTo(expectedProjectName);
+  }
+}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 3eb3088..e8c835d 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -64,8 +64,20 @@
     }
 
     private Servlet(FileSystem fs, Cache<Path, Resource> cache,
+        boolean refresh, boolean cacheOnClient) {
+      super(cache, refresh, cacheOnClient);
+      this.fs = fs;
+    }
+
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
         boolean refresh, int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheFileSizeLimitBytes);
+      super(cache, refresh, true, cacheFileSizeLimitBytes);
+      this.fs = fs;
+    }
+
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
+        boolean refresh, boolean cacheOnClient, int cacheFileSizeLimitBytes) {
+      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
       this.fs = fs;
     }
 
@@ -156,6 +168,37 @@
   }
 
   @Test
+  public void smallFileWithoutClientCache() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false, false);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertNotCacheable(res);
+    assertCacheHits(cache, 2, 2);
+  }
+
+  @Test
   public void smallFileWithoutRefresh() throws Exception {
     Cache<Path, Resource> cache = newCache(1);
     Servlet servlet = new Servlet(fs, cache, false);
diff --git a/gerrit-launcher/BUCK b/gerrit-launcher/BUCK
index 687e02f..5be25fa 100644
--- a/gerrit-launcher/BUCK
+++ b/gerrit-launcher/BUCK
@@ -4,6 +4,7 @@
   name = 'launcher',
   srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
   visibility = [
+    '//gerrit-acceptance-framework/...',
     '//gerrit-acceptance-tests/...',
     '//gerrit-httpd:',
     '//gerrit-main:main_lib',
diff --git a/gerrit-launcher/BUILD b/gerrit-launcher/BUILD
new file mode 100644
index 0000000..ced3447
--- /dev/null
+++ b/gerrit-launcher/BUILD
@@ -0,0 +1,7 @@
+# NOTE: GerritLauncher must be a single, self-contained class. Do not add any
+# additional srcs or deps to this rule.
+java_library(
+  name = 'launcher',
+  srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index fb54bcf..7954146 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -32,13 +32,16 @@
 import java.net.URLClassLoader;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.CodeSource;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
@@ -71,11 +74,12 @@
       System.err.println();
       System.err.println("The most commonly used commands are:");
       System.err.println("  init            Initialize a Gerrit installation");
-      System.err.println("  rebuild-notedb  Rebuild the review notes database");
       System.err.println("  reindex         Rebuild the secondary index");
       System.err.println("  daemon          Run the Gerrit network daemons");
       System.err.println("  gsql            Run the interactive query console");
       System.err.println("  version         Display the build version number");
+      System.err.println("  passwd          Set or change password in secure.config");
+
       System.err.println();
       System.err.println("  ls              List files available for cat");
       System.err.println("  cat FILE        Display a file from the archive");
@@ -169,9 +173,8 @@
     }
     if (res instanceof Number) {
       return ((Number) res).intValue();
-    } else {
-      return 0;
     }
+    return 0;
   }
 
   private static String programClassName(String cn) {
@@ -300,9 +303,10 @@
   }
 
   private static volatile File myArchive;
-  private static volatile FileSystem myArchiveFs;
   private static volatile File myHome;
 
+  private static final Map<Path, FileSystem> zipFileSystems = new HashMap<>();
+
   /**
    * Locate the JAR/WAR file we were launched from.
    *
@@ -319,19 +323,28 @@
           return result;
         }
         result = locateMyArchive();
-        myArchiveFs = FileSystems.newFileSystem(
-            URI.create("jar:" + result.toPath().toUri()),
-            Collections.<String, String> emptyMap());
         myArchive = result;
       }
     }
     return result;
   }
 
-  public static FileSystem getDistributionArchiveFileSystem()
-      throws FileNotFoundException, IOException {
-    getDistributionArchive();
-    return myArchiveFs;
+  public static synchronized FileSystem getZipFileSystem(Path zip)
+      throws IOException {
+    // FileSystems canonicalizes the path, so we should too.
+    zip = zip.toRealPath();
+    FileSystem zipFs = zipFileSystems.get(zip);
+    if (zipFs == null) {
+      zipFs = newZipFileSystem(zip);
+      zipFileSystems.put(zip, zipFs);
+    }
+    return zipFs;
+  }
+
+  public static FileSystem newZipFileSystem(Path zip) throws IOException {
+    return FileSystems.newFileSystem(
+        URI.create("jar:" + zip.toUri()),
+        Collections.<String, String> emptyMap());
   }
 
   private static File locateMyArchive() throws FileNotFoundException {
@@ -562,58 +575,68 @@
   }
 
   /**
+   * Locate the path of the {@code eclipse-out} directory in a source tree.
+   *
+   * @throws FileNotFoundException if the directory cannot be found.
+   */
+  public static Path getDeveloperEclipseOut() throws FileNotFoundException {
+    return resolveInSourceRoot("eclipse-out");
+  }
+
+  /**
    * Locate the path of the {@code buck-out} directory in a source tree.
    *
    * @throws FileNotFoundException if the directory cannot be found.
    */
   public static Path getDeveloperBuckOut() throws FileNotFoundException {
-    // Find ourselves in the CLASSPATH, we should be a loose class file.
+    return resolveInSourceRoot("buck-out");
+  }
+
+  private static Path resolveInSourceRoot(String name)
+      throws FileNotFoundException {
+    // Find ourselves in the classpath, as a loose class file or jar.
     Class<GerritLauncher> self = GerritLauncher.class;
     URL u = self.getResource(self.getSimpleName() + ".class");
     if (u == null) {
       throw new FileNotFoundException("Cannot find class " + self.getName());
-    } else if (!"file".equals(u.getProtocol())) {
-      throw new FileNotFoundException("Cannot find extract path from " + u);
-    }
-
-    // Pop up to the top level classes folder that contains us.
-    Path dir = Paths.get(u.getPath());
-    String myName = self.getName();
-    for (;;) {
-      int dot = myName.lastIndexOf('.');
-      if (dot < 0) {
-        dir = dir.getParent();
-        break;
+    } else if ("jar".equals(u.getProtocol())) {
+      String p = u.getPath();
+      try {
+        u = new URL(p.substring(0, p.indexOf('!')));
+      } catch (MalformedURLException e) {
+        FileNotFoundException fnfe =
+            new FileNotFoundException("Not a valid jar file: " + u);
+        fnfe.initCause(e);
+        throw fnfe;
       }
-      myName = myName.substring(0, dot);
-      dir = dir.getParent();
+    }
+    if (!"file".equals(u.getProtocol())) {
+      throw new FileNotFoundException("Cannot extract path from " + u);
     }
 
-    dir = popdir(u, dir, "classes");
-    dir = popdir(u, dir, "eclipse");
-    if (last(dir).equals("buck-out")) {
-      return dir;
+    // Pop up to the top-level source folder by looking for .buckconfig.
+    Path dir = Paths.get(u.getPath());
+    while (!Files.isRegularFile(dir.resolve(".buckconfig"))) {
+      Path parent = dir.getParent();
+      if (parent == null) {
+        throw new FileNotFoundException("Cannot find source root from " + u);
+      }
+      dir = parent;
     }
-    throw new FileNotFoundException("Cannot find buck-out from " + u);
-  }
 
-  private static String last(Path dir) {
-    return dir.getName(dir.getNameCount() - 1).toString();
-  }
-
-  private static Path popdir(URL u, Path dir, String name)
-      throws FileNotFoundException {
-    if (last(dir).equals(name)) {
-      return dir.getParent();
+    Path ret = dir.resolve(name);
+    if (!Files.exists(ret)) {
+      throw new FileNotFoundException(
+          name + " not found in source root " + dir);
     }
-    throw new FileNotFoundException("Cannot find buck-out from " + u);
+    return ret;
   }
 
   private static ClassLoader useDevClasspath()
       throws MalformedURLException, FileNotFoundException {
-    Path out = getDeveloperBuckOut();
+    Path out = getDeveloperEclipseOut();
     List<URL> dirs = new ArrayList<>();
-    dirs.add(out.resolve("eclipse").resolve("classes").toUri().toURL());
+    dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
     for (URL u : ((URLClassLoader) cl).getURLs()) {
       if (includeJar(u)) {
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
index 8ba7479..771a021 100644
--- a/gerrit-lucene/BUCK
+++ b/gerrit-lucene/BUCK
@@ -11,7 +11,7 @@
     '//gerrit-server:server',
     '//lib:gwtorm',
     '//lib:guava',
-    '//lib/lucene:core-and-backward-codecs',
+    '//lib/lucene:lucene-core-and-backward-codecs',
   ],
   visibility = ['PUBLIC'],
 )
@@ -31,11 +31,11 @@
     '//lib:gwtorm',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/log:api',
-    '//lib/lucene:analyzers-common',
-    '//lib/lucene:core-and-backward-codecs',
-    '//lib/lucene:misc',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+    '//lib/lucene:lucene-misc',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
new file mode 100644
index 0000000..2f1cba7
--- /dev/null
+++ b/gerrit-lucene/BUILD
@@ -0,0 +1,41 @@
+QUERY_BUILDER = [
+  'src/main/java/com/google/gerrit/lucene/QueryBuilder.java',
+]
+
+java_library(
+  name = 'query_builder',
+  srcs = QUERY_BUILDER,
+  deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:gwtorm',
+    '//lib:guava',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lucene',
+  srcs = glob(['src/main/java/**/*.java'], exclude = QUERY_BUILDER),
+  deps = [
+    ':query_builder',
+    '//gerrit-antlr:query_exception',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/log:api',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+    '//lib/lucene:lucene-misc',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
new file mode 100644
index 0000000..eb0dfaa
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -0,0 +1,439 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.AbstractFuture;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.Schema.Values;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.Field.Store;
+import org.apache.lucene.document.IntField;
+import org.apache.lucene.document.LongField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.index.TrackingIndexWriter;
+import org.apache.lucene.search.ControlledRealTimeReopenThread;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.ReferenceManager;
+import org.apache.lucene.search.ReferenceManager.RefreshListener;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.store.AlreadyClosedException;
+import org.apache.lucene.store.Directory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/** Basic Lucene index implementation. */
+public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
+  private static final Logger log =
+      LoggerFactory.getLogger(AbstractLuceneIndex.class);
+
+  static String sortFieldName(FieldDef<?, ?> f) {
+    return f.getName() + "_SORT";
+  }
+
+  public static void setReady(SitePaths sitePaths, String name, int version,
+      boolean ready) throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      cfg.setReady(name, version, ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private final Schema<V> schema;
+  private final SitePaths sitePaths;
+  private final Directory dir;
+  private final String name;
+  private final ListeningExecutorService writerThread;
+  private final TrackingIndexWriter writer;
+  private final ReferenceManager<IndexSearcher> searcherManager;
+  private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
+  private final Set<NrtFuture> notDoneNrtFutures;
+  private ScheduledThreadPoolExecutor autoCommitExecutor;
+
+  AbstractLuceneIndex(
+      Schema<V> schema,
+      SitePaths sitePaths,
+      Directory dir,
+      String name,
+      String subIndex,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory) throws IOException {
+    this.schema = schema;
+    this.sitePaths = sitePaths;
+    this.dir = dir;
+    this.name = name;
+    final String index = Joiner.on('_').skipNulls().join(name, subIndex);
+    IndexWriter delegateWriter;
+    long commitPeriod = writerConfig.getCommitWithinMs();
+
+    if (commitPeriod < 0) {
+      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
+    } else if (commitPeriod == 0) {
+      delegateWriter =
+          new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
+    } else {
+      final AutoCommitWriter autoCommitWriter =
+          new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
+      delegateWriter = autoCommitWriter;
+
+      autoCommitExecutor = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder()
+          .setNameFormat(index + " Commit-%d")
+          .setDaemon(true)
+          .build());
+      autoCommitExecutor.scheduleAtFixedRate(new Runnable() {
+            @Override
+            public void run() {
+              try {
+                if (autoCommitWriter.hasUncommittedChanges()) {
+                  autoCommitWriter.manualFlush();
+                  autoCommitWriter.commit();
+                }
+              } catch (IOException e) {
+                log.error("Error committing " + index + " Lucene index", e);
+              } catch (OutOfMemoryError e) {
+                log.error("Error committing " + index + " Lucene index", e);
+                try {
+                  autoCommitWriter.close();
+                } catch (IOException e2) {
+                  log.error("SEVERE: Error closing " + index
+                      + " Lucene index  after OOM; index may be corrupted.", e);
+                }
+              }
+            }
+          }, commitPeriod, commitPeriod, MILLISECONDS);
+    }
+    writer = new TrackingIndexWriter(delegateWriter);
+    searcherManager = new WrappableSearcherManager(
+        writer.getIndexWriter(), true, searcherFactory);
+
+    notDoneNrtFutures = Sets.newConcurrentHashSet();
+
+    writerThread = MoreExecutors.listeningDecorator(
+        Executors.newFixedThreadPool(1,
+            new ThreadFactoryBuilder()
+              .setNameFormat(index + " Write-%d")
+              .setDaemon(true)
+              .build()));
+
+    reopenThread = new ControlledRealTimeReopenThread<>(
+        writer, searcherManager,
+        0.500 /* maximum stale age (seconds) */,
+        0.010 /* minimum stale age (seconds) */);
+    reopenThread.setName(index + " NRT");
+    reopenThread.setPriority(Math.min(
+        Thread.currentThread().getPriority() + 2,
+        Thread.MAX_PRIORITY));
+    reopenThread.setDaemon(true);
+
+    // This must be added after the reopen thread is created. The reopen thread
+    // adds its own listener which copies its internally last-refreshed
+    // generation to the searching generation. removeIfDone() depends on the
+    // searching generation being up to date when calling
+    // reopenThread.waitForGeneration(gen, 0), therefore the reopen thread's
+    // internal listener needs to be called first.
+    // TODO(dborowitz): This may have been fixed by
+    // http://issues.apache.org/jira/browse/LUCENE-5461
+    searcherManager.addListener(new RefreshListener() {
+      @Override
+      public void beforeRefresh() throws IOException {
+      }
+
+      @Override
+      public void afterRefresh(boolean didRefresh) throws IOException {
+        for (NrtFuture f : notDoneNrtFutures) {
+          f.removeIfDone();
+        }
+      }
+    });
+
+    reopenThread.start();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    setReady(sitePaths, name, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void close() {
+    if (autoCommitExecutor != null) {
+      autoCommitExecutor.shutdown();
+    }
+
+    writerThread.shutdown();
+    try {
+      if (!writerThread.awaitTermination(5, TimeUnit.SECONDS)) {
+        log.warn("shutting down " + name + " index with pending Lucene writes");
+      }
+    } catch (InterruptedException e) {
+      log.warn("interrupted waiting for pending Lucene writes of " + name +
+          " index", e);
+    }
+    reopenThread.close();
+
+    // Closing the reopen thread sets its generation to Long.MAX_VALUE, but we
+    // still need to refresh the searcher manager to let pending NrtFutures
+    // know.
+    //
+    // Any futures created after this method (which may happen due to undefined
+    // shutdown ordering behavior) will finish immediately, even though they may
+    // not have flushed.
+    try {
+      searcherManager.maybeRefreshBlocking();
+    } catch (IOException e) {
+      log.warn("error finishing pending Lucene writes", e);
+    }
+
+    try {
+      writer.getIndexWriter().close();
+    } catch (AlreadyClosedException e) {
+      // Ignore.
+    } catch (IOException e) {
+      log.warn("error closing Lucene writer", e);
+    }
+    try {
+      dir.close();
+    } catch (IOException e) {
+      log.warn("error closing Lucene directory", e);
+    }
+  }
+
+  ListenableFuture<?> insert(final Document doc) {
+    return submit(new Callable<Long>() {
+      @Override
+      public Long call() throws IOException, InterruptedException {
+        return writer.addDocument(doc);
+      }
+    });
+  }
+
+  ListenableFuture<?> replace(final Term term, final Document doc) {
+    return submit(new Callable<Long>() {
+      @Override
+      public Long call() throws IOException, InterruptedException {
+        return writer.updateDocument(term, doc);
+      }
+    });
+  }
+
+  ListenableFuture<?> delete(final Term term) {
+    return submit(new Callable<Long>() {
+      @Override
+      public Long call() throws IOException, InterruptedException {
+        return writer.deleteDocuments(term);
+      }
+    });
+  }
+
+  private ListenableFuture<?> submit(Callable<Long> task) {
+    ListenableFuture<Long> future =
+        Futures.nonCancellationPropagating(writerThread.submit(task));
+    return Futures.transformAsync(future, new AsyncFunction<Long, Void>() {
+      @Override
+      public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
+        // Tell the reopen thread a future is waiting on this
+        // generation so it uses the min stale time when refreshing.
+        reopenThread.waitForGeneration(gen, 0);
+        return new NrtFuture(gen);
+      }
+    });
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    writer.deleteAll();
+  }
+
+  public TrackingIndexWriter getWriter() {
+    return writer;
+  }
+
+  IndexSearcher acquire() throws IOException {
+    return searcherManager.acquire();
+  }
+
+  void release(IndexSearcher searcher) throws IOException {
+    searcherManager.release(searcher);
+  }
+
+  Document toDocument(V obj, FillArgs fillArgs) {
+    Document result = new Document();
+    for (Values<V> vs : schema.buildFields(obj, fillArgs)) {
+      if (vs.getValues() != null) {
+        add(result, vs);
+      }
+    }
+    return result;
+  }
+
+  void add(Document doc, Values<V> values) {
+    String name = values.getField().getName();
+    FieldType<?> type = values.getField().getType();
+    Store store = store(values.getField());
+
+    if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+      for (Object value : values.getValues()) {
+        doc.add(new IntField(name, (Integer) value, store));
+      }
+    } else if (type == FieldType.LONG) {
+      for (Object value : values.getValues()) {
+        doc.add(new LongField(name, (Long) value, store));
+      }
+    } else if (type == FieldType.TIMESTAMP) {
+      for (Object value : values.getValues()) {
+        doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
+      }
+    } else if (type == FieldType.EXACT
+        || type == FieldType.PREFIX) {
+      for (Object value : values.getValues()) {
+        doc.add(new StringField(name, (String) value, store));
+      }
+    } else if (type == FieldType.FULL_TEXT) {
+      for (Object value : values.getValues()) {
+        doc.add(new TextField(name, (String) value, store));
+      }
+    } else if (type == FieldType.STORED_ONLY) {
+      for (Object value : values.getValues()) {
+        doc.add(new StoredField(name, (byte[]) value));
+      }
+    } else {
+      throw FieldType.badFieldType(type);
+    }
+  }
+
+  private static Field.Store store(FieldDef<?, ?> f) {
+    return f.isStored() ? Field.Store.YES : Field.Store.NO;
+  }
+
+  private final class NrtFuture extends AbstractFuture<Void> {
+    private final long gen;
+
+    NrtFuture(long gen) {
+      this.gen = gen;
+    }
+
+    @Override
+    public Void get() throws InterruptedException, ExecutionException {
+      if (!isDone()) {
+        reopenThread.waitForGeneration(gen);
+        set(null);
+      }
+      return super.get();
+    }
+
+    @Override
+    public Void get(long timeout, TimeUnit unit) throws InterruptedException,
+        TimeoutException, ExecutionException {
+      if (!isDone()) {
+        if (!reopenThread.waitForGeneration(gen, (int) unit.toMillis(timeout))) {
+          throw new TimeoutException();
+        }
+        set(null);
+      }
+      return super.get(timeout, unit);
+    }
+
+    @Override
+    public boolean isDone() {
+      if (super.isDone()) {
+        return true;
+      } else if (isGenAvailableNowForCurrentSearcher()) {
+        set(null);
+        return true;
+      } else if (!reopenThread.isAlive()) {
+        setException(new IllegalStateException("NRT thread is dead"));
+        return true;
+      }
+      return false;
+    }
+
+    @Override
+    public void addListener(Runnable listener, Executor executor) {
+      if (isGenAvailableNowForCurrentSearcher() && !isCancelled()) {
+        set(null);
+      } else if (!isDone()) {
+        notDoneNrtFutures.add(this);
+      }
+      super.addListener(listener, executor);
+    }
+
+    @Override
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      boolean result = super.cancel(mayInterruptIfRunning);
+      if (result) {
+        notDoneNrtFutures.remove(this);
+      }
+      return result;
+    }
+
+    void removeIfDone() {
+      if (isGenAvailableNowForCurrentSearcher()) {
+        notDoneNrtFutures.remove(this);
+        if (!isCancelled()) {
+          set(null);
+        }
+      }
+    }
+
+    private boolean isGenAvailableNowForCurrentSearcher() {
+      try {
+        return reopenThread.waitForGeneration(gen, 0);
+      } catch (InterruptedException e) {
+        log.warn("Interrupted waiting for searcher generation", e);
+        return false;
+      }
+    }
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
index 4e47bca..4a53fc6 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
@@ -108,7 +108,7 @@
   }
 
   void manualFlush() throws IOException {
-    flush(true, true);
+    flush();
     if (autoCommit) {
       commit();
     }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
new file mode 100644
index 0000000..9bec978
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
+import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.Schema.Values;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+
+public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  ChangeSubIndex(
+      Schema<ChangeData> schema,
+      SitePaths sitePaths,
+      Path path,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory) throws IOException {
+    this(schema, sitePaths, FSDirectory.open(path),
+        path.getFileName().toString(), writerConfig, searcherFactory);
+  }
+
+  ChangeSubIndex(
+      Schema<ChangeData> schema,
+      SitePaths sitePaths,
+      Directory dir,
+      String subIndex,
+      GerritIndexWriterConfig writerConfig,
+      SearcherFactory searcherFactory) throws IOException {
+    super(schema, sitePaths, dir, NAME, subIndex, writerConfig,
+        searcherFactory);
+  }
+
+  @Override
+  public void replace(ChangeData obj) throws IOException {
+    throw new UnsupportedOperationException(
+        "don't use ChangeSubIndex directly");
+  }
+
+  @Override
+  public void delete(Change.Id key) throws IOException {
+    throw new UnsupportedOperationException(
+        "don't use ChangeSubIndex directly");
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p,
+      QueryOptions opts) throws QueryParseException {
+    throw new UnsupportedOperationException(
+        "don't use ChangeSubIndex directly");
+  }
+
+  @Override
+  void add(Document doc, Values<ChangeData> values) {
+    // Add separate DocValues fields for those fields needed for sorting.
+    FieldDef<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.LEGACY_ID) {
+      int v = (Integer) getOnlyElement(values.getValues());
+      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
+    } else if (f == ChangeField.UPDATED) {
+      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
+      doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
+    }
+    super.add(doc, values);
+  }
+
+  @Override
+  public void stop() {
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
new file mode 100644
index 0000000..f43e385
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+import java.io.IOException;
+
+class GerritIndexStatus {
+  private static final String SECTION = "index";
+  private static final String KEY_READY = "ready";
+
+  private final FileBasedConfig cfg;
+
+  GerritIndexStatus(SitePaths sitePaths)
+      throws ConfigInvalidException, IOException {
+    cfg = new FileBasedConfig(
+        sitePaths.index_dir.resolve("gerrit_index.config").toFile(),
+        FS.detect());
+    cfg.load();
+    convertLegacyConfig();
+  }
+
+  void setReady(String indexName, int version, boolean ready) {
+    cfg.setBoolean(SECTION, indexDirName(indexName, version), KEY_READY, ready);
+  }
+
+  boolean getReady(String indexName, int version) {
+    return cfg.getBoolean(SECTION, indexDirName(indexName, version), KEY_READY,
+        false);
+  }
+
+  void save() throws IOException {
+    cfg.save();
+  }
+
+  private void convertLegacyConfig() throws IOException {
+    boolean dirty = false;
+    // Convert legacy [index "25"] to modern [index "changes_0025"].
+    for (String subsection : cfg.getSubsections(SECTION)) {
+      Integer v = Ints.tryParse(subsection);
+      if (v != null) {
+        String ready = cfg.getString(SECTION, subsection, KEY_READY);
+        if (ready != null) {
+          dirty = false;
+          cfg.unset(SECTION, subsection, KEY_READY);
+          cfg.setString(SECTION,
+              indexDirName(ChangeSchemaDefinitions.NAME, v), KEY_READY, ready);
+        }
+      }
+    }
+    if (dirty) {
+      cfg.save();
+    }
+  }
+
+  private static String indexDirName(String indexName, int version) {
+    return String.format("%s_%04d", indexName, version);
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
new file mode 100644
index 0000000..36145c6
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.config.ConfigUtil;
+
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.analysis.util.CharArraySet;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Map;
+
+/**
+ * Combination of Lucene {@link IndexWriterConfig} with additional
+ * Gerrit-specific options.
+ */
+class GerritIndexWriterConfig {
+  private static final Map<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("_", " ", ".", " ");
+
+  private final IndexWriterConfig luceneConfig;
+  private long commitWithinMs;
+  private final CustomMappingAnalyzer analyzer;
+
+  GerritIndexWriterConfig(Config cfg, String name) {
+    analyzer =
+        new CustomMappingAnalyzer(new StandardAnalyzer(
+            CharArraySet.EMPTY_SET), CUSTOM_CHAR_MAPPING);
+    luceneConfig = new IndexWriterConfig(analyzer)
+        .setOpenMode(OpenMode.CREATE_OR_APPEND)
+        .setCommitOnClose(true);
+    double m = 1 << 20;
+    luceneConfig.setRAMBufferSizeMB(cfg.getLong(
+        "index", name, "ramBufferSize",
+        (long) (IndexWriterConfig.DEFAULT_RAM_BUFFER_SIZE_MB * m)) / m);
+    luceneConfig.setMaxBufferedDocs(cfg.getInt(
+        "index", name, "maxBufferedDocs",
+        IndexWriterConfig.DEFAULT_MAX_BUFFERED_DOCS));
+    try {
+      commitWithinMs =
+          ConfigUtil.getTimeUnit(cfg, "index", name, "commitWithin",
+              MILLISECONDS.convert(5, MINUTES), MILLISECONDS);
+    } catch (IllegalArgumentException e) {
+      commitWithinMs = cfg.getLong("index", name, "commitWithin", 0);
+    }
+  }
+
+  CustomMappingAnalyzer getAnalyzer() {
+    return analyzer;
+  }
+
+  IndexWriterConfig getLuceneConfig() {
+    return luceneConfig;
+  }
+
+  long getCommitWithinMs() {
+    return commitWithinMs;
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
new file mode 100644
index 0000000..59980e7
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -0,0 +1,221 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+
+public class LuceneAccountIndex
+    extends AbstractLuceneIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  private static final Logger log =
+      LoggerFactory.getLogger(LuceneAccountIndex.class);
+
+  private static final String ACCOUNTS = "accounts";
+
+  private static final String ID_SORT_FIELD = sortFieldName(ID);
+
+  private static Term idTerm(AccountState as) {
+    return idTerm(as.getAccount().getId());
+  }
+
+  private static Term idTerm(Account.Id id) {
+    return QueryBuilder.intTerm(ID.getName(), id.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<AccountState> queryBuilder;
+  private final AccountCache accountCache;
+
+  private static Directory dir(Schema<AccountState> schema, Config cfg,
+      SitePaths sitePaths) throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir =
+        LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneAccountIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      AccountCache accountCache,
+      @Assisted Schema<AccountState> schema) throws IOException {
+    super(schema, sitePaths, dir(schema, cfg, sitePaths), ACCOUNTS, null,
+        new GerritIndexWriterConfig(cfg, ACCOUNTS), new SearcherFactory());
+    this.accountCache = accountCache;
+
+    indexWriterConfig =
+        new GerritIndexWriterConfig(cfg, ACCOUNTS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    try {
+      // No parts of FillArgs are currently required, just use null.
+      replace(idTerm(as), toDocument(as, null)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Account.Id key) throws IOException {
+    try {
+      delete(idTerm(key)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p,
+      QueryOptions opts) throws QueryParseException {
+    return new QuerySource(
+        opts,
+        queryBuilder.toQuery(p),
+        new Sort(
+            new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
+  }
+
+  private class QuerySource implements DataSource<AccountState> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    private QuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      // TODO(dborowitz): In contrast to the comment in
+      // LuceneChangeIndex.QuerySource#getCardinality, at this point I actually
+      // think we might just want to remove getCardinality.
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountState> read() throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          Document doc = searcher.doc(sd.doc, fields(opts));
+          result.add(toAccountState(doc));
+        }
+        final List<AccountState> r = Collections.unmodifiableList(result);
+        return new ResultSet<AccountState>() {
+          @Override
+          public Iterator<AccountState> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<AccountState> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+    }
+  }
+
+  private Set<String> fields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(ID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(ID.getName()));
+  }
+
+  private AccountState toAccountState(Document doc) {
+    Account.Id id =
+        new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    // Use the AccountCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any. The most expensive part to
+    // compute anyway is the effective group IDs, and we don't have a good way
+    // to reindex when those change.
+    return accountCache.get(id);
+  }
+
+  @Override
+  public void stop() {
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 6af320f..530566c 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -15,48 +15,48 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
+import static com.google.gerrit.lucene.LuceneVersionManager.CHANGES_PREFIX;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.IndexRewriter.CLOSED_STATUSES;
-import static com.google.gerrit.server.index.IndexRewriter.OPEN_STATUSES;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.MINUTES;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 
+import com.google.common.base.Function;
 import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.Nullable;
+import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.ChangeField.ChangeProtoField;
-import com.google.gerrit.server.index.ChangeField.PatchSetApprovalProtoField;
-import com.google.gerrit.server.index.ChangeField.PatchSetProtoField;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.Schema.Values;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeField.ChangeProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetApprovalProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
-import com.google.gerrit.server.query.change.QueryOptions;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
@@ -65,25 +65,10 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
-import org.apache.lucene.analysis.standard.StandardAnalyzer;
-import org.apache.lucene.analysis.util.CharArraySet;
 import org.apache.lucene.document.Document;
-import org.apache.lucene.document.Field;
-import org.apache.lucene.document.Field.Store;
-import org.apache.lucene.document.IntField;
-import org.apache.lucene.document.LongField;
-import org.apache.lucene.document.NumericDocValuesField;
-import org.apache.lucene.document.StoredField;
-import org.apache.lucene.document.StringField;
-import org.apache.lucene.document.TextField;
-import org.apache.lucene.index.DirectoryReader;
-import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.IndexWriter;
-import org.apache.lucene.index.IndexWriterConfig;
-import org.apache.lucene.index.IndexWriterConfig.OpenMode;
 import org.apache.lucene.index.IndexableField;
 import org.apache.lucene.index.Term;
-import org.apache.lucene.search.BooleanQuery;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
@@ -94,27 +79,23 @@
 import org.apache.lucene.search.TopDocs;
 import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.RAMDirectory;
-import org.apache.lucene.uninverting.UninvertingReader;
 import org.apache.lucene.util.BytesRef;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Secondary index implementation using Apache Lucene.
@@ -131,102 +112,42 @@
   public static final String CHANGES_OPEN = "open";
   public static final String CHANGES_CLOSED = "closed";
 
+  static final String UPDATED_SORT_FIELD =
+      sortFieldName(ChangeField.UPDATED);
+  static final String ID_SORT_FIELD =
+      sortFieldName(ChangeField.LEGACY_ID);
+
   private static final String ADDED_FIELD = ChangeField.ADDED.getName();
   private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
   private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String ID_FIELD = ChangeField.LEGACY_ID2.getName();
   private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
   private static final String REVIEWEDBY_FIELD =
       ChangeField.REVIEWEDBY.getName();
-  private static final String UPDATED_SORT_FIELD =
-      sortFieldName(ChangeField.UPDATED);
+  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String HASHTAG_FIELD =
+      ChangeField.HASHTAG_CASE_AWARE.getName();
+  private static final String STAR_FIELD = ChangeField.STAR.getName();
+  @Deprecated
+  private static final String STARREDBY_FIELD = ChangeField.STARREDBY.getName();
 
-  private static final ImmutableSet<String> FIELDS = ImmutableSet.of(
-      ADDED_FIELD, APPROVAL_FIELD, CHANGE_FIELD, DELETED_FIELD, ID_FIELD,
-      MERGEABLE_FIELD, PATCH_SET_FIELD, REVIEWEDBY_FIELD);
-
-  private static final Map<String, String> CUSTOM_CHAR_MAPPING = ImmutableMap.of(
-      "_", " ", ".", " ");
-
-  public static void setReady(SitePaths sitePaths, int version, boolean ready)
-      throws IOException {
-    try {
-      FileBasedConfig cfg =
-          LuceneVersionManager.loadGerritIndexConfig(sitePaths);
-      LuceneVersionManager.setReady(cfg, version, ready);
-      cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
+  static Term idTerm(ChangeData cd) {
+    return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
   }
 
-  private static String sortFieldName(FieldDef<?, ?> f) {
-    return f.getName() + "_SORT";
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.intTerm(LEGACY_ID.getName(), id.get());
   }
 
-  static interface Factory {
-    LuceneChangeIndex create(Schema<ChangeData> schema, String base);
-  }
-
-  static class GerritIndexWriterConfig {
-    private final IndexWriterConfig luceneConfig;
-    private long commitWithinMs;
-
-    private GerritIndexWriterConfig(Config cfg, String name) {
-      CustomMappingAnalyzer analyzer =
-          new CustomMappingAnalyzer(new StandardAnalyzer(
-              CharArraySet.EMPTY_SET), CUSTOM_CHAR_MAPPING);
-      luceneConfig = new IndexWriterConfig(analyzer)
-          .setOpenMode(OpenMode.CREATE_OR_APPEND)
-          .setCommitOnClose(true);
-      double m = 1 << 20;
-      luceneConfig.setRAMBufferSizeMB(cfg.getLong(
-          "index", name, "ramBufferSize",
-          (long) (IndexWriterConfig.DEFAULT_RAM_BUFFER_SIZE_MB * m)) / m);
-      luceneConfig.setMaxBufferedDocs(cfg.getInt(
-          "index", name, "maxBufferedDocs",
-          IndexWriterConfig.DEFAULT_MAX_BUFFERED_DOCS));
-      try {
-        commitWithinMs =
-            ConfigUtil.getTimeUnit(cfg, "index", name, "commitWithin",
-                MILLISECONDS.convert(5, MINUTES), MILLISECONDS);
-      } catch (IllegalArgumentException e) {
-        commitWithinMs = cfg.getLong("index", name, "commitWithin", 0);
-      }
-    }
-
-    IndexWriterConfig getLuceneConfig() {
-      return luceneConfig;
-    }
-
-    long getCommitWithinMs() {
-      return commitWithinMs;
-    }
-  }
-
-  private final SitePaths sitePaths;
   private final FillArgs fillArgs;
   private final ListeningExecutorService executor;
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
-  private final QueryBuilder queryBuilder;
-  private final SubIndex openIndex;
-  private final SubIndex closedIndex;
-  private final String idSortField;
-
-  /**
-   * Whether to use DocValues for range/sorted numeric fields.
-   * <p>
-   * Lucene 5 removed support for sorting based on normal numeric fields, so we
-   * use the newer API for more strongly typed numeric fields in newer schema
-   * versions. These fields also are not stored, so we need to store auxiliary
-   * stored-only field for them as well.
-   */
-  // TODO(dborowitz): Delete when we delete support for pre-Lucene-5.0 schemas.
-  private final boolean useDocValuesForSorting;
+  private final QueryBuilder<ChangeData> queryBuilder;
+  private final ChangeSubIndex openIndex;
+  private final ChangeSubIndex closedIndex;
 
   @AssistedInject
   LuceneChangeIndex(
@@ -236,82 +157,48 @@
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       FillArgs fillArgs,
-      @Assisted Schema<ChangeData> schema,
-      @Assisted @Nullable String base) throws IOException {
-    this.sitePaths = sitePaths;
+      @Assisted Schema<ChangeData> schema) throws IOException {
     this.fillArgs = fillArgs;
     this.executor = executor;
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
-    this.useDocValuesForSorting = schema.getVersion() >= 15;
-    this.idSortField = sortFieldName(LegacyChangeIdPredicate.idField(schema));
-
-    CustomMappingAnalyzer analyzer =
-        new CustomMappingAnalyzer(new StandardAnalyzer(CharArraySet.EMPTY_SET),
-            CUSTOM_CHAR_MAPPING);
-    queryBuilder = new QueryBuilder(analyzer);
-
-    BooleanQuery.setMaxClauseCount(cfg.getInt("index", "maxTerms",
-        BooleanQuery.getMaxClauseCount()));
 
     GerritIndexWriterConfig openConfig =
         new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig =
         new GerritIndexWriterConfig(cfg, "changes_closed");
 
-    SearcherFactory searcherFactory = newSearcherFactory();
-    if (cfg.getBoolean("index", "lucene", "testInmemory", false)) {
-      openIndex = new SubIndex(new RAMDirectory(), "ramOpen", openConfig,
-          searcherFactory);
-      closedIndex = new SubIndex(new RAMDirectory(), "ramClosed", closedConfig,
-          searcherFactory);
+    queryBuilder = new QueryBuilder<>(schema, openConfig.getAnalyzer());
+
+    SearcherFactory searcherFactory = new SearcherFactory();
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      openIndex = new ChangeSubIndex(schema, sitePaths, new RAMDirectory(),
+          "ramOpen", openConfig, searcherFactory);
+      closedIndex = new ChangeSubIndex(schema, sitePaths, new RAMDirectory(),
+          "ramClosed", closedConfig, searcherFactory);
     } else {
-      Path dir = base != null ? Paths.get(base)
-          : LuceneVersionManager.getDir(sitePaths, schema);
-      openIndex = new SubIndex(dir.resolve(CHANGES_OPEN), openConfig,
-          searcherFactory);
-      closedIndex = new SubIndex(dir.resolve(CHANGES_CLOSED), closedConfig,
-          searcherFactory);
+      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES_PREFIX, schema);
+      openIndex = new ChangeSubIndex(schema, sitePaths,
+          dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
+      closedIndex = new ChangeSubIndex(schema, sitePaths,
+          dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
     }
   }
 
-  private SearcherFactory newSearcherFactory() {
-    if (useDocValuesForSorting) {
-      return new SearcherFactory();
-    }
-    @SuppressWarnings("deprecation")
-    final Map<String, UninvertingReader.Type> mapping = ImmutableMap.of(
-        ChangeField.LEGACY_ID.getName(), UninvertingReader.Type.INTEGER,
-        ChangeField.UPDATED.getName(), UninvertingReader.Type.LONG);
-    return new SearcherFactory() {
-      @Override
-      public IndexSearcher newSearcher(IndexReader reader, IndexReader previousReader)
-          throws IOException {
-        checkState(reader instanceof DirectoryReader,
-            "expected DirectoryReader, found %s", reader.getClass().getName());
-        return new IndexSearcher(
-            UninvertingReader.wrap((DirectoryReader) reader, mapping));
-      }
-    };
+  @Override
+  public void stop() {
+    MoreExecutors.shutdownAndAwaitTermination(
+        executor, Long.MAX_VALUE, TimeUnit.SECONDS);
   }
 
   @Override
   public void close() {
-    List<ListenableFuture<?>> closeFutures = Lists.newArrayListWithCapacity(2);
-    closeFutures.add(executor.submit(new Runnable() {
-      @Override
-      public void run() {
-        openIndex.close();
-      }
-    }));
-    closeFutures.add(executor.submit(new Runnable() {
-      @Override
-      public void run() {
-        closedIndex.close();
-      }
-    }));
-    Futures.getUnchecked(Futures.allAsList(closeFutures));
+    try {
+      openIndex.close();
+    } finally {
+      closedIndex.close();
+    }
   }
 
   @Override
@@ -321,8 +208,10 @@
 
   @Override
   public void replace(ChangeData cd) throws IOException {
-    Term id = QueryBuilder.idTerm(schema, cd);
-    Document doc = toDocument(cd);
+    Term id = LuceneChangeIndex.idTerm(cd);
+    // toDocument is essentially static and doesn't depend on the specific
+    // sub-index, so just pick one.
+    Document doc = openIndex.toDocument(cd, fillArgs);
     try {
       if (cd.change().getStatus().isOpen()) {
         Futures.allAsList(
@@ -340,7 +229,7 @@
 
   @Override
   public void delete(Change.Id id) throws IOException {
-    Term idTerm = QueryBuilder.idTerm(schema, id);
+    Term idTerm = LuceneChangeIndex.idTerm(id);
     try {
       Futures.allAsList(
           openIndex.delete(idTerm),
@@ -359,8 +248,8 @@
   @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
-    Set<Change.Status> statuses = IndexRewriter.getPossibleStatus(p);
-    List<SubIndex> indexes = Lists.newArrayListWithCapacity(2);
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<ChangeSubIndex> indexes = new ArrayList<>(2);
     if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
       indexes.add(openIndex);
     }
@@ -372,42 +261,32 @@
 
   @Override
   public void markReady(boolean ready) throws IOException {
-    setReady(sitePaths, schema.getVersion(), ready);
+    // Arbitrary done on open index, as ready bit is set
+    // per index and not sub index
+    openIndex.markReady(ready);
   }
 
-  @SuppressWarnings("deprecation")
   private Sort getSort() {
-    if (useDocValuesForSorting) {
-      return new Sort(
-          new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
-          new SortField(idSortField, SortField.Type.LONG, true));
-    } else {
-      return new Sort(
-          new SortField(
-            ChangeField.UPDATED.getName(), SortField.Type.LONG, true),
-          new SortField(
-            ChangeField.LEGACY_ID.getName(), SortField.Type.INT, true));
-    }
+    return new Sort(
+        new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
+        new SortField(ID_SORT_FIELD, SortField.Type.LONG, true));
   }
 
-  public SubIndex getOpenChangesIndex() {
-    return openIndex;
-  }
-
-  public SubIndex getClosedChangesIndex() {
+  public ChangeSubIndex getClosedChangesIndex() {
     return closedIndex;
   }
 
   private class QuerySource implements ChangeDataSource {
-    private final List<SubIndex> indexes;
+    private final List<ChangeSubIndex> indexes;
     private final Predicate<ChangeData> predicate;
     private final Query query;
     private final QueryOptions opts;
     private final Sort sort;
 
 
-    private QuerySource(List<SubIndex> indexes, Predicate<ChangeData> predicate,
-        QueryOptions opts, Sort sort) throws QueryParseException {
+    private QuerySource(List<ChangeSubIndex> indexes,
+        Predicate<ChangeData> predicate, QueryOptions opts, Sort sort)
+        throws QueryParseException {
       this.indexes = indexes;
       this.predicate = predicate;
       this.query = checkNotNull(queryBuilder.toQuery(predicate),
@@ -435,23 +314,25 @@
     public ResultSet<ChangeData> read() throws OrmException {
       if (Thread.interrupted()) {
         Thread.currentThread().interrupt();
-        throw new OrmException("interupted");
+        throw new OrmException("interrupted");
       }
+
+      final Set<String> fields = fields(opts);
       return new ChangeDataResults(
           executor.submit(new Callable<List<Document>>() {
             @Override
-            public List<Document> call() throws OrmException {
-              return doRead();
+            public List<Document> call() throws IOException {
+              return doRead(fields);
             }
 
             @Override
             public String toString() {
               return predicate.toString();
             }
-          }));
+          }), fields);
     }
 
-    private List<Document> doRead() throws OrmException {
+    private List<Document> doRead(Set<String> fields) throws IOException {
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
       try {
         int realLimit = opts.start() + opts.limit();
@@ -465,16 +346,12 @@
         }
         TopDocs docs = TopDocs.merge(sort, realLimit, hits);
 
-        List<Document> result =
-            Lists.newArrayListWithCapacity(docs.scoreDocs.length);
+        List<Document> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searchers[sd.shardIndex].doc(sd.doc, FIELDS);
-          result.add(doc);
+          result.add(searchers[sd.shardIndex].doc(sd.doc, fields));
         }
         return result;
-      } catch (IOException e) {
-        throw new OrmException(e);
       } finally {
         for (int i = 0; i < indexes.size(); i++) {
           if (searchers[i] != null) {
@@ -491,9 +368,11 @@
 
   private class ChangeDataResults implements ResultSet<ChangeData> {
     private final Future<List<Document>> future;
+    private final Set<String> fields;
 
-    ChangeDataResults(Future<List<Document>> future) {
+    ChangeDataResults(Future<List<Document>> future, Set<String> fields) {
       this.future = future;
+      this.fields = fields;
     }
 
     @Override
@@ -506,8 +385,9 @@
       try {
         List<Document> docs = future.get();
         List<ChangeData> result = new ArrayList<>(docs.size());
+        String idFieldName = LEGACY_ID.getName();
         for (Document doc : docs) {
-          result.add(toChangeData(doc));
+          result.add(toChangeData(fields(doc, fields), fields, idFieldName));
         }
         return result;
       } catch (InterruptedException e) {
@@ -524,19 +404,100 @@
       future.cancel(false /* do not interrupt Lucene */);
     }
   }
-  private ChangeData toChangeData(Document doc) {
-    BytesRef cb = doc.getBinaryValue(CHANGE_FIELD);
-    if (cb == null) {
-      int id = doc.getField(ID_FIELD).numericValue().intValue();
-      return changeDataFactory.create(db.get(), new Change.Id(id));
+
+  private Set<String> fields(QueryOptions opts) {
+    // Ensure we request enough fields to construct a ChangeData.
+    Set<String> fs = opts.fields();
+    if (fs.contains(CHANGE.getName())) {
+      // A Change is always sufficient.
+      return fs;
     }
 
-    // Change proto.
-    Change change = ChangeProtoField.CODEC.decode(
-        cb.bytes, cb.offset, cb.length);
-    ChangeData cd = changeDataFactory.create(db.get(), change);
+    if (!schema.hasField(PROJECT)) {
+      // Schema is not new enough to have project field. Ensure we have ID
+      // field, and call createOnlyWhenNoteDbDisabled from toChangeData below.
+      if (fs.contains(LEGACY_ID.getName())) {
+        return fs;
+      }
+      return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName()));
+    }
 
-    // Patch sets.
+    // New enough schema to have project field, so ensure that is requested.
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+      return fs;
+    }
+    return Sets.union(fs,
+        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+  }
+
+  private static Multimap<String, IndexableField> fields(Document doc,
+      Set<String> fields) {
+    Multimap<String, IndexableField> stored =
+        ArrayListMultimap.create(fields.size(), 4);
+    for (IndexableField f : doc) {
+      String name = f.name();
+      if (fields.contains(name)) {
+        stored.put(name, f);
+      }
+    }
+    return stored;
+  }
+
+  private ChangeData toChangeData(Multimap<String, IndexableField> doc,
+      Set<String> fields, String idFieldName) {
+    ChangeData cd;
+    // Either change or the ID field was guaranteed to be included in the call
+    // to fields() above.
+    IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
+    if (cb != null) {
+      BytesRef proto = cb.binaryValue();
+      cd = changeDataFactory.create(db.get(),
+          ChangeProtoField.CODEC.decode(proto.bytes, proto.offset, proto.length));
+    } else {
+      IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
+      Change.Id id = new Change.Id(f.numericValue().intValue());
+      IndexableField project = Iterables.getFirst(doc.get(PROJECT.getName()), null);
+      if (project == null) {
+        // Old schema without project field: we can safely assume NoteDb is
+        // disabled.
+        cd = changeDataFactory.createOnlyWhenNoteDbDisabled(db.get(), id);
+      } else {
+        cd = changeDataFactory.create(
+            db.get(), new Project.NameKey(project.stringValue()), id);
+      }
+    }
+
+    if (fields.contains(PATCH_SET_FIELD)) {
+      decodePatchSets(doc, cd);
+    }
+    if (fields.contains(APPROVAL_FIELD)) {
+      decodeApprovals(doc, cd);
+    }
+    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
+      decodeChangedLines(doc, cd);
+    }
+    if (fields.contains(MERGEABLE_FIELD)) {
+      decodeMergeable(doc, cd);
+    }
+    if (fields.contains(REVIEWEDBY_FIELD)) {
+      decodeReviewedBy(doc, cd);
+    }
+    if (fields.contains(HASHTAG_FIELD)) {
+      decodeHashtags(doc, cd);
+    }
+    if (fields.contains(STARREDBY_FIELD)) {
+      decodeStarredBy(doc, cd);
+    }
+    if (fields.contains(STAR_FIELD)) {
+      decodeStar(doc, cd);
+    }
+    if (fields.contains(REVIEWER_FIELD)) {
+      decodeReviewers(doc, cd);
+    }
+    return cd;
+  }
+
+  private void decodePatchSets(Multimap<String, IndexableField> doc, ChangeData cd) {
     List<PatchSet> patchSets =
         decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoField.CODEC);
     if (!patchSets.isEmpty()) {
@@ -544,117 +505,115 @@
       // this cannot be valid since a change needs at least one patch set.
       cd.setPatchSets(patchSets);
     }
+  }
 
-    // Approvals.
+  private void decodeApprovals(Multimap<String, IndexableField> doc, ChangeData cd) {
     cd.setCurrentApprovals(
         decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoField.CODEC));
+  }
 
-    // Changed lines.
-    IndexableField added = doc.getField(ADDED_FIELD);
-    IndexableField deleted = doc.getField(DELETED_FIELD);
+  private void decodeChangedLines(Multimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
+    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
     if (added != null && deleted != null) {
       cd.setChangedLines(
           added.numericValue().intValue(),
           deleted.numericValue().intValue());
+    } else {
+      // No ChangedLines stored, likely due to failure during reindexing, for
+      // example due to LargeObjectException. But we know the field was
+      // requested, so update ChangeData to prevent callers from trying to
+      // lazily load it, as that would probably also fail.
+      cd.setNoChangedLines();
     }
+  }
 
-    // Mergeable.
-    String mergeable = doc.get(MERGEABLE_FIELD);
-    if ("1".equals(mergeable)) {
-      cd.setMergeable(true);
-    } else if ("0".equals(mergeable)) {
-      cd.setMergeable(false);
+  private void decodeMergeable(Multimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
+    if (f != null) {
+      String mergeable = f.stringValue();
+      if ("1".equals(mergeable)) {
+        cd.setMergeable(true);
+      } else if ("0".equals(mergeable)) {
+        cd.setMergeable(false);
+      }
     }
+  }
 
-    // Reviewed-by.
-    IndexableField[] reviewedBy = doc.getFields(REVIEWEDBY_FIELD);
-    if (reviewedBy.length > 0) {
+  private void decodeReviewedBy(Multimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
+    if (reviewedBy.size() > 0) {
       Set<Account.Id> accounts =
-          Sets.newHashSetWithExpectedSize(reviewedBy.length);
+          Sets.newHashSetWithExpectedSize(reviewedBy.size());
       for (IndexableField r : reviewedBy) {
         int id = r.numericValue().intValue();
-        if (reviewedBy.length == 1 && id == ChangeField.NOT_REVIEWED) {
+        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
           break;
         }
         accounts.add(new Account.Id(id));
       }
       cd.setReviewedBy(accounts);
     }
-
-    return cd;
   }
 
-  private static <T> List<T> decodeProtos(Document doc, String fieldName,
-      ProtobufCodec<T> codec) {
-    BytesRef[] bytesRefs = doc.getBinaryValues(fieldName);
-    if (bytesRefs.length == 0) {
+  private void decodeHashtags(Multimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
+    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
+    for (IndexableField r : hashtag) {
+      hashtags.add(r.binaryValue().utf8ToString());
+    }
+    cd.setHashtags(hashtags);
+  }
+
+  @Deprecated
+  private void decodeStarredBy(Multimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> starredBy = doc.get(STARREDBY_FIELD);
+    Set<Account.Id> accounts =
+        Sets.newHashSetWithExpectedSize(starredBy.size());
+    for (IndexableField r : starredBy) {
+      accounts.add(new Account.Id(r.numericValue().intValue()));
+    }
+    cd.setStarredBy(accounts);
+  }
+
+  private void decodeStar(Multimap<String, IndexableField> doc, ChangeData cd) {
+    Collection<IndexableField> star = doc.get(STAR_FIELD);
+    Multimap<Account.Id, String> stars = ArrayListMultimap.create();
+    for (IndexableField r : star) {
+      StarredChangesUtil.StarField starField =
+          StarredChangesUtil.StarField.parse(r.stringValue());
+      if (starField != null) {
+        stars.put(starField.accountId(), starField.label());
+      }
+    }
+    cd.setStars(stars);
+  }
+
+  private void decodeReviewers(Multimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewers(
+        ChangeField.parseReviewerFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_FIELD))
+                .transform(
+                    new Function<IndexableField, String>() {
+                      @Override
+                      public String apply(IndexableField in) {
+                        return in.stringValue();
+                      }
+                    })));
+  }
+
+  private static <T> List<T> decodeProtos(Multimap<String, IndexableField> doc,
+      String fieldName, ProtobufCodec<T> codec) {
+    Collection<IndexableField> fields = doc.get(fieldName);
+    if (fields.isEmpty()) {
       return Collections.emptyList();
     }
-    List<T> result = new ArrayList<>(bytesRefs.length);
-    for (BytesRef r : bytesRefs) {
+
+    List<T> result = new ArrayList<>(fields.size());
+    for (IndexableField f : fields) {
+      BytesRef r = f.binaryValue();
       result.add(codec.decode(r.bytes, r.offset, r.length));
     }
     return result;
   }
-
-  private Document toDocument(ChangeData cd) {
-    Document result = new Document();
-    for (Values<ChangeData> vs : schema.buildFields(cd, fillArgs)) {
-      if (vs.getValues() != null) {
-        add(result, vs);
-      }
-    }
-    return result;
-  }
-
-  @SuppressWarnings("deprecation")
-  private void add(Document doc, Values<ChangeData> values) {
-    String name = values.getField().getName();
-    FieldType<?> type = values.getField().getType();
-    Store store = store(values.getField());
-
-    if (useDocValuesForSorting) {
-      FieldDef<ChangeData, ?> f = values.getField();
-      if (f == ChangeField.LEGACY_ID || f == ChangeField.LEGACY_ID2) {
-        int v = (Integer) getOnlyElement(values.getValues());
-        doc.add(new NumericDocValuesField(sortFieldName(f), v));
-      } else if (f == ChangeField.UPDATED) {
-        long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
-        doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-      }
-    }
-
-    if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
-      for (Object value : values.getValues()) {
-        doc.add(new IntField(name, (Integer) value, store));
-      }
-    } else if (type == FieldType.LONG) {
-      for (Object value : values.getValues()) {
-        doc.add(new LongField(name, (Long) value, store));
-      }
-    } else if (type == FieldType.TIMESTAMP) {
-      for (Object value : values.getValues()) {
-        doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
-      }
-    } else if (type == FieldType.EXACT
-        || type == FieldType.PREFIX) {
-      for (Object value : values.getValues()) {
-        doc.add(new StringField(name, (String) value, store));
-      }
-    } else if (type == FieldType.FULL_TEXT) {
-      for (Object value : values.getValues()) {
-        doc.add(new TextField(name, (String) value, store));
-      }
-    } else if (type == FieldType.STORED_ONLY) {
-      for (Object value : values.getValues()) {
-        doc.add(new StoredField(name, (byte[]) value));
-      }
-    } else {
-      throw FieldType.badFieldType(type);
-    }
-  }
-
-  private static Field.Store store(FieldDef<?, ?> f) {
-    return f.isStored() ? Field.Store.YES : Field.Store.NO;
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 5af7cc5..f5d5146 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -14,43 +14,76 @@
 
 package com.google.gerrit.lucene;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.ChangeSchemas;
-import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.inject.Inject;
 import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
 
+import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
-public class LuceneIndexModule extends LifecycleModule {
-  private final Integer singleVersion;
-  private final int threads;
-  private final String base;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
 
-  public LuceneIndexModule() {
-    this(null, 0, null);
+public class LuceneIndexModule extends LifecycleModule {
+  private static final String SINGLE_VERSIONS =
+      "LuceneIndexModule/SingleVersions";
+
+  public static LuceneIndexModule singleVersionAllLatest(int threads) {
+    return new LuceneIndexModule(ImmutableMap.<String, Integer> of(), threads);
   }
 
-  public LuceneIndexModule(Integer singleVersion, int threads,
-      String base) {
-    this.singleVersion = singleVersion;
+  public static LuceneIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new LuceneIndexModule(versions, threads);
+  }
+
+  public static LuceneIndexModule latestVersionWithOnlineUpgrade() {
+    return new LuceneIndexModule(null, 0);
+  }
+
+  static boolean isInMemoryTest(Config cfg) {
+    return cfg.getBoolean("index", "lucene", "testInmemory", false);
+  }
+
+  private final int threads;
+  private final Map<String, Integer> singleVersions;
+
+  private LuceneIndexModule(Map<String, Integer> singleVersions, int threads) {
+    this.singleVersions = singleVersions;
     this.threads = threads;
-    this.base = base;
   }
 
   @Override
   protected void configure() {
-    factory(LuceneChangeIndex.Factory.class);
-    factory(OnlineReindexer.Factory.class);
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, LuceneChangeIndex.class)
+            .build(ChangeIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
     install(new IndexModule(threads));
-    if (singleVersion == null && base == null) {
+    if (singleVersions == null) {
       install(new MultiVersionModule());
     } else {
       install(new SingleVersionModule());
@@ -60,6 +93,8 @@
   @Provides
   @Singleton
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    BooleanQuery.setMaxClauseCount(cfg.getInt("index", "maxTerms",
+        BooleanQuery.getMaxClauseCount()));
     return IndexConfig.fromConfig(cfg);
   }
 
@@ -74,34 +109,56 @@
     @Override
     public void configure() {
       listener().to(SingleVersionListener.class);
-    }
-
-    @Provides
-    @Singleton
-    LuceneChangeIndex getIndex(LuceneChangeIndex.Factory factory) {
-      Schema<ChangeData> schema = singleVersion != null
-          ? ChangeSchemas.get(singleVersion)
-          : ChangeSchemas.getLatest();
-      return factory.create(schema, base);
+      bind(new TypeLiteral<Map<String, Integer>>() {})
+          .annotatedWith(Names.named(SINGLE_VERSIONS))
+          .toInstance(singleVersions);
     }
   }
 
   @Singleton
   static class SingleVersionListener implements LifecycleListener {
-    private final IndexCollection indexes;
-    private final LuceneChangeIndex index;
+    private final Set<String> disabled;
+    private final Collection<IndexDefinition<?, ?, ?>> defs;
+    private final Map<String, Integer> singleVersions;
 
     @Inject
-    SingleVersionListener(IndexCollection indexes,
-        LuceneChangeIndex index) {
-      this.indexes = indexes;
-      this.index = index;
+    SingleVersionListener(
+        @GerritServerConfig Config cfg,
+        Collection<IndexDefinition<?, ?, ?>> defs,
+        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
+      this.defs = defs;
+      this.singleVersions = singleVersions;
+
+      disabled = ImmutableSet.copyOf(
+          cfg.getStringList("index", null, "testDisable"));
     }
 
     @Override
     public void start() {
-      indexes.setSearchIndex(index);
-      indexes.addWriteIndex(index);
+      for (IndexDefinition<?, ?, ?> def : defs) {
+        start(def);
+      }
+    }
+
+    private <K, V, I extends Index<K, V>> void start(
+        IndexDefinition<K, V, I> def) {
+      if (disabled.contains(def.getName())) {
+        return;
+      }
+      Schema<V> schema;
+      Integer v = singleVersions.get(def.getName());
+      if (v == null) {
+        schema = def.getLatest();
+      } else {
+        schema = def.getSchemas().get(v);
+        if (schema == null) {
+          throw new ProvisionException(String.format(
+                "Unrecognized %s schema version: %s", def.getName(), v));
+        }
+      }
+      I index = def.getIndexFactory().create(schema);
+      def.getIndexCollection().setSearchIndex(index);
+      def.getIndexCollection().addWriteIndex(index);
     }
 
     @Override
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index 407f5a8..f05f879 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -22,18 +22,18 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.IndexDefinition.IndexFactory;
+import com.google.gerrit.server.index.OnlineReindexer;
 import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -43,6 +43,7 @@
 import java.nio.file.Path;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.TreeMap;
 
 @Singleton
@@ -50,15 +51,15 @@
   private static final Logger log = LoggerFactory
       .getLogger(LuceneVersionManager.class);
 
-  private static final String CHANGES_PREFIX = "changes_";
+  static final String CHANGES_PREFIX = "changes_";
 
-  private static class Version {
-    private final Schema<ChangeData> schema;
+  private static class Version<V> {
+    private final Schema<V> schema;
     private final int version;
     private final boolean exists;
     private final boolean ready;
 
-    private Version(Schema<ChangeData> schema, int version, boolean exists,
+    private Version(Schema<V> schema, int version, boolean exists,
         boolean ready) {
       checkArgument(schema == null || schema.getVersion() == version);
       this.schema = schema;
@@ -68,71 +69,65 @@
     }
   }
 
-  static Path getDir(SitePaths sitePaths, Schema<ChangeData> schema) {
+  static Path getDir(SitePaths sitePaths, String prefix, Schema<?> schema) {
     return sitePaths.index_dir.resolve(String.format("%s%04d",
-        CHANGES_PREFIX, schema.getVersion()));
-  }
-
-  static FileBasedConfig loadGerritIndexConfig(SitePaths sitePaths)
-      throws ConfigInvalidException, IOException {
-    FileBasedConfig cfg = new FileBasedConfig(
-        sitePaths.index_dir.resolve("gerrit_index.config").toFile(),
-        FS.detect());
-    cfg.load();
-    return cfg;
-  }
-
-  static void setReady(Config cfg, int version, boolean ready) {
-    cfg.setBoolean("index", Integer.toString(version), "ready", ready);
-  }
-
-  private static boolean getReady(Config cfg, int version) {
-    return cfg.getBoolean("index", Integer.toString(version), "ready", false);
+        prefix, schema.getVersion()));
   }
 
   private final SitePaths sitePaths;
-  private final LuceneChangeIndex.Factory indexFactory;
-  private final IndexCollection indexes;
-  private final OnlineReindexer.Factory reindexerFactory;
+  private final Map<String, IndexDefinition<?, ?, ?>> defs;
+  private final Map<String, OnlineReindexer<?, ?, ?>> reindexers;
   private final boolean onlineUpgrade;
-  private OnlineReindexer reindexer;
+  private final String runReindexMsg;
 
   @Inject
   LuceneVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
-      LuceneChangeIndex.Factory indexFactory,
-      IndexCollection indexes,
-      OnlineReindexer.Factory reindexerFactory) {
+      Collection<IndexDefinition<?, ?, ?>> defs) {
     this.sitePaths = sitePaths;
-    this.indexFactory = indexFactory;
-    this.indexes = indexes;
-    this.reindexerFactory = reindexerFactory;
-    this.onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
+    this.defs = Maps.newHashMapWithExpectedSize(defs.size());
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      this.defs.put(def.getName(), def);
+    }
+
+    reindexers = Maps.newHashMapWithExpectedSize(defs.size());
+    onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
+    runReindexMsg =
+        "No index versions ready; run java -jar " +
+        sitePaths.gerrit_war.toAbsolutePath() +
+        " reindex";
   }
 
   @Override
   public void start() {
-    FileBasedConfig cfg;
+    GerritIndexStatus cfg;
     try {
-      cfg = loadGerritIndexConfig(sitePaths);
+      cfg = new GerritIndexStatus(sitePaths);
     } catch (ConfigInvalidException | IOException e) {
       throw fail(e);
     }
 
     if (!Files.exists(sitePaths.index_dir)) {
-      throw new ProvisionException("No index versions ready; run Reindex");
+      throw new ProvisionException(runReindexMsg);
     } else if (!Files.exists(sitePaths.index_dir)) {
-      log.warn("Not a directory: %s", sitePaths.index_dir.toAbsolutePath());
-      throw new ProvisionException("No index versions ready; run Reindex");
+      log.warn("Not a directory: {}", sitePaths.index_dir.toAbsolutePath());
+      throw new ProvisionException(runReindexMsg);
     }
 
-    TreeMap<Integer, Version> versions = scanVersions(cfg);
+    for (IndexDefinition<?, ?, ?> def : defs.values()) {
+      initIndex(def, cfg);
+    }
+  }
+
+  private <K, V, I extends Index<K, V>> void initIndex(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
     // Search from the most recent ready version.
     // Write to the most recent ready version and the most recent version.
-    Version search = null;
-    List<Version> write = Lists.newArrayListWithCapacity(2);
-    for (Version v : versions.descendingMap().values()) {
+    Version<V> search = null;
+    List<Version<V>> write = Lists.newArrayListWithCapacity(2);
+    for (Version<V> v : versions.descendingMap().values()) {
       if (v.schema == null) {
         continue;
       }
@@ -148,39 +143,49 @@
       }
     }
     if (search == null) {
-      throw new ProvisionException("No index versions ready; run Reindex");
+      throw new ProvisionException(runReindexMsg);
     }
 
-    markNotReady(cfg, versions.values(), write);
-    LuceneChangeIndex searchIndex = indexFactory.create(search.schema, null);
+    IndexFactory<K, V, I> factory = def.getIndexFactory();
+    I searchIndex = factory.create(search.schema);
+    IndexCollection<K, V, I> indexes = def.getIndexCollection();
     indexes.setSearchIndex(searchIndex);
-    for (Version v : write) {
+    for (Version<V> v : write) {
       if (v.schema != null) {
         if (v.version != search.version) {
-          indexes.addWriteIndex(indexFactory.create(v.schema, null));
+          indexes.addWriteIndex(factory.create(v.schema));
         } else {
           indexes.addWriteIndex(searchIndex);
         }
       }
     }
 
+    markNotReady(cfg, def.getName(), versions.values(), write);
+
     int latest = write.get(0).version;
-    if (onlineUpgrade && latest != search.version) {
-      reindexer = reindexerFactory.create(latest);
-      reindexer.start();
+    OnlineReindexer<K, V, I> reindexer = new OnlineReindexer<>(def, latest);
+    synchronized (this) {
+      if (!reindexers.containsKey(def.getName())) {
+        reindexers.put(def.getName(), reindexer);
+        if (onlineUpgrade && latest != search.version) {
+          reindexer.start();
+        }
+      }
     }
   }
 
   /**
    * Start the online reindexer if the current index is not already the latest.
    *
+   * @param  force start re-index
    * @return true if started, otherwise false.
    * @throws ReindexerAlreadyRunningException
    */
-  public synchronized boolean startReindexer()
+  public synchronized boolean startReindexer(String name, boolean force)
       throws ReindexerAlreadyRunningException {
-    validateReindexerNotRunning();
-    if (!isCurrentIndexVersionLatest()) {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (force || !isCurrentIndexVersionLatest(name, reindexer)) {
       reindexer.start();
       return true;
     }
@@ -193,49 +198,57 @@
    * @return true if index was activate, otherwise false.
    * @throws ReindexerAlreadyRunningException
    */
-  public synchronized boolean activateLatestIndex()
+  public synchronized boolean activateLatestIndex(String name)
       throws ReindexerAlreadyRunningException {
-    validateReindexerNotRunning();
-    if (!isCurrentIndexVersionLatest()) {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (!isCurrentIndexVersionLatest(name, reindexer)) {
       reindexer.activateIndex();
       return true;
     }
     return false;
   }
 
-  private boolean isCurrentIndexVersionLatest() {
+  private boolean isCurrentIndexVersionLatest(
+      String name, OnlineReindexer<?, ?, ?> reindexer) {
+    int readVersion = defs.get(name).getIndexCollection().getSearchIndex()
+        .getSchema().getVersion();
     return reindexer == null
-        || reindexer.getVersion() == indexes.getSearchIndex().getSchema()
-            .getVersion();
+        || reindexer.getVersion() == readVersion;
   }
 
-  private void validateReindexerNotRunning()
+  private static void validateReindexerNotRunning(
+      OnlineReindexer<?, ?, ?> reindexer)
       throws ReindexerAlreadyRunningException {
     if (reindexer != null && reindexer.isRunning()) {
       throw new ReindexerAlreadyRunningException();
     }
   }
 
-  private TreeMap<Integer, Version> scanVersions(Config cfg) {
-    TreeMap<Integer, Version> versions = Maps.newTreeMap();
-    for (Schema<ChangeData> schema : ChangeSchemas.ALL.values()) {
-      Path p = getDir(sitePaths, schema);
+  private <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>>
+      scanVersions(IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
+    for (Schema<V> schema : def.getSchemas().values()) {
+      // This part is Lucene-specific.
+      Path p = getDir(sitePaths, def.getName(), schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
-        log.warn("Not a directory: %s", p.toAbsolutePath());
+        log.warn("Not a directory: {}", p.toAbsolutePath());
       }
       int v = schema.getVersion();
-      versions.put(v, new Version(schema, v, isDir, getReady(cfg, v)));
+      versions.put(v, new Version<>(
+          schema, v, isDir, cfg.getReady(def.getName(), v)));
     }
 
+    String prefix = def.getName() + "_";
     try (DirectoryStream<Path> paths =
         Files.newDirectoryStream(sitePaths.index_dir)) {
       for (Path p : paths) {
         String n = p.getFileName().toString();
-        if (!n.startsWith(CHANGES_PREFIX)) {
+        if (!n.startsWith(prefix)) {
           continue;
         }
-        String versionStr = n.substring(CHANGES_PREFIX.length());
+        String versionStr = n.substring(prefix.length());
         Integer v = Ints.tryParse(versionStr);
         if (v == null || versionStr.length() != 4) {
           log.warn("Unrecognized version in index directory: {}",
@@ -243,7 +256,8 @@
           continue;
         }
         if (!versions.containsKey(v)) {
-          versions.put(v, new Version(null, v, true, getReady(cfg, v)));
+          versions.put(v, new Version<V>(
+              null, v, true, cfg.getReady(def.getName(), v)));
         }
       }
     } catch (IOException e) {
@@ -252,12 +266,12 @@
     return versions;
   }
 
-  private void markNotReady(FileBasedConfig cfg, Iterable<Version> versions,
-      Collection<Version> inUse) {
+  private <V> void markNotReady(GerritIndexStatus cfg, String name,
+      Iterable<Version<V>> versions, Collection<Version<V>> inUse) {
     boolean dirty = false;
-    for (Version v : versions) {
+    for (Version<V> v : versions) {
       if (!inUse.contains(v) && v.exists) {
-        setReady(cfg, v.version, false);
+        cfg.setReady(name, v.version, false);
         dirty = true;
       }
     }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java
deleted file mode 100644
index 1dbc427..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/OnlineReindexer.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.gerrit.server.index.SiteIndexer;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public class OnlineReindexer {
-  private static final Logger log = LoggerFactory
-      .getLogger(OnlineReindexer.class);
-
-  public interface Factory {
-    OnlineReindexer create(int version);
-  }
-
-  private final IndexCollection indexes;
-  private final SiteIndexer batchIndexer;
-  private final ProjectCache projectCache;
-  private final int version;
-  private ChangeIndex index;
-  private final AtomicBoolean running = new AtomicBoolean();
-
-  @Inject
-  OnlineReindexer(
-      IndexCollection indexes,
-      SiteIndexer batchIndexer,
-      ProjectCache projectCache,
-      @Assisted int version) {
-    this.indexes = indexes;
-    this.batchIndexer = batchIndexer;
-    this.projectCache = projectCache;
-    this.version = version;
-  }
-
-  public void start() {
-    if (running.compareAndSet(false, true)) {
-      Thread t = new Thread() {
-        @Override
-        public void run() {
-          try {
-            reindex();
-          } finally {
-            running.set(false);
-          }
-        }
-      };
-      t.setName(String.format("Reindex v%d-v%d",
-          version(indexes.getSearchIndex()), version));
-      t.start();
-    }
-  }
-
-  public boolean isRunning() {
-    return running.get();
-  }
-
-  public int getVersion() {
-    return version;
-  }
-
-  private static int version(ChangeIndex i) {
-    return i.getSchema().getVersion();
-  }
-
-  private void reindex() {
-    index = checkNotNull(indexes.getWriteIndex(version),
-        "not an active write schema version: %s", version);
-    log.info("Starting online reindex from schema version {} to {}",
-        version(indexes.getSearchIndex()), version(index));
-    SiteIndexer.Result result =
-        batchIndexer.indexAll(index, projectCache.all());
-    if (!result.success()) {
-      log.error("Online reindex of schema version {} failed. Successfully"
-          + " indexed {} changes, failed to index {} changes",
-          version(index), result.doneCount(), result.failedCount());
-      return;
-    }
-    log.info("Reindex to version {} complete", version(index));
-    activateIndex();
-  }
-
-  void activateIndex() {
-    indexes.setSearchIndex(index);
-    log.info("Using schema version {}", version(index));
-    try {
-      index.markReady(true);
-    } catch (IOException e) {
-      log.warn("Error activating new schema version {}", version(index));
-    }
-
-    List<ChangeIndex> toRemove = Lists.newArrayListWithExpectedSize(1);
-    for (ChangeIndex i : indexes.getWriteIndexes()) {
-      if (version(i) != version(index)) {
-        toRemove.add(i);
-      }
-    }
-    for (ChangeIndex i : toRemove) {
-      try {
-        i.markReady(false);
-        indexes.removeWriteIndex(version(i));
-      } catch (IOException e) {
-        log.warn("Error deactivating old schema version {}", version(i));
-      }
-    }
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
index 3af0713..a993b49 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.lucene;
 
-import static com.google.gerrit.server.query.change.LegacyChangeIdPredicate.idField;
+import static com.google.common.base.Preconditions.checkArgument;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
 import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
 
 import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.IntegerRangePredicate;
@@ -32,7 +31,6 @@
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData;
 
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.index.Term;
@@ -49,23 +47,22 @@
 import java.util.Date;
 import java.util.List;
 
-public class QueryBuilder {
-
-  public static Term idTerm(Schema<ChangeData> schema, ChangeData cd) {
-    return intTerm(idField(schema).getName(), cd.getId().get());
+public class QueryBuilder<V> {
+  static Term intTerm(String name, int value) {
+    BytesRefBuilder builder = new BytesRefBuilder();
+    NumericUtils.intToPrefixCoded(value, 0, builder);
+    return new Term(name, builder.get());
   }
 
-  public static Term idTerm(Schema<ChangeData> schema, Change.Id id) {
-    return intTerm(idField(schema).getName(), id.get());
-  }
-
+  private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  public QueryBuilder(Analyzer analyzer) {
+  public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
+    this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
   }
 
-  public Query toQuery(Predicate<ChangeData> p) throws QueryParseException {
+  public Query toQuery(Predicate<V> p) throws QueryParseException {
     if (p instanceof AndPredicate) {
       return and(p);
     } else if (p instanceof OrPredicate) {
@@ -73,13 +70,13 @@
     } else if (p instanceof NotPredicate) {
       return not(p);
     } else if (p instanceof IndexPredicate) {
-      return fieldQuery((IndexPredicate<ChangeData>) p);
+      return fieldQuery((IndexPredicate<V>) p);
     } else {
       throw new QueryParseException("cannot create query for index: " + p);
     }
   }
 
-  private Query or(Predicate<ChangeData> p)
+  private Query or(Predicate<V> p)
       throws QueryParseException {
     try {
       BooleanQuery.Builder q = new BooleanQuery.Builder();
@@ -92,17 +89,17 @@
     }
   }
 
-  private Query and(Predicate<ChangeData> p)
+  private Query and(Predicate<V> p)
       throws QueryParseException {
     try {
       BooleanQuery.Builder b = new BooleanQuery.Builder();
       List<Query> not = Lists.newArrayListWithCapacity(p.getChildCount());
       for (int i = 0; i < p.getChildCount(); i++) {
-        Predicate<ChangeData> c = p.getChild(i);
+        Predicate<V> c = p.getChild(i);
         if (c instanceof NotPredicate) {
-          Predicate<ChangeData> n = c.getChild(0);
+          Predicate<V> n = c.getChild(0);
           if (n instanceof TimestampRangePredicate) {
-            b.add(notTimestamp((TimestampRangePredicate<ChangeData>) n), MUST);
+            b.add(notTimestamp((TimestampRangePredicate<V>) n), MUST);
           } else {
             not.add(toQuery(n));
           }
@@ -119,11 +116,11 @@
     }
   }
 
-  private Query not(Predicate<ChangeData> p)
+  private Query not(Predicate<V> p)
       throws QueryParseException {
-    Predicate<ChangeData> n = p.getChild(0);
+    Predicate<V> n = p.getChild(0);
     if (n instanceof TimestampRangePredicate) {
-      return notTimestamp((TimestampRangePredicate<ChangeData>) n);
+      return notTimestamp((TimestampRangePredicate<V>) n);
     }
 
     // Lucene does not support negation, start with all and subtract.
@@ -133,8 +130,11 @@
       .build();
   }
 
-  private Query fieldQuery(IndexPredicate<ChangeData> p)
+  private Query fieldQuery(IndexPredicate<V> p)
       throws QueryParseException {
+    checkArgument(schema.hasField(p.getField()),
+        "field not in schema v%s: %s", schema.getVersion(),
+        p.getField().getName());
     if (p.getType() == FieldType.INTEGER) {
       return intQuery(p);
     } else if (p.getType() == FieldType.INTEGER_RANGE) {
@@ -152,13 +152,7 @@
     }
   }
 
-  private static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    NumericUtils.intToPrefixCodedBytes(value, 0, builder);
-    return new Term(name, builder.get());
-  }
-
-  private Query intQuery(IndexPredicate<ChangeData> p)
+  private Query intQuery(IndexPredicate<V> p)
       throws QueryParseException {
     int value;
     try {
@@ -171,33 +165,32 @@
     return new TermQuery(intTerm(p.getField().getName(), value));
   }
 
-  private Query intRangeQuery(IndexPredicate<ChangeData> p)
+  private Query intRangeQuery(IndexPredicate<V> p)
       throws QueryParseException {
     if (p instanceof IntegerRangePredicate) {
-      IntegerRangePredicate<ChangeData> r =
-          (IntegerRangePredicate<ChangeData>) p;
+      IntegerRangePredicate<V> r =
+          (IntegerRangePredicate<V>) p;
       int minimum = r.getMinimumValue();
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
         return new TermQuery(intTerm(p.getField().getName(), minimum));
-      } else {
-        return NumericRangeQuery.newIntRange(
-            r.getField().getName(),
-            minimum,
-            maximum,
-            true,
-            true);
       }
+      return NumericRangeQuery.newIntRange(
+          r.getField().getName(),
+          minimum,
+          maximum,
+          true,
+          true);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
 
-  private Query timestampQuery(IndexPredicate<ChangeData> p)
+  private Query timestampQuery(IndexPredicate<V> p)
       throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
-      TimestampRangePredicate<ChangeData> r =
-          (TimestampRangePredicate<ChangeData>) p;
+      TimestampRangePredicate<V> r =
+          (TimestampRangePredicate<V>) p;
       return NumericRangeQuery.newLongRange(
           r.getField().getName(),
           r.getMinTimestamp().getTime(),
@@ -207,7 +200,7 @@
     throw new QueryParseException("not a timestamp: " + p);
   }
 
-  private Query notTimestamp(TimestampRangePredicate<ChangeData> r)
+  private Query notTimestamp(TimestampRangePredicate<V> r)
       throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
       return NumericRangeQuery.newLongRange(
@@ -219,15 +212,14 @@
     throw new QueryParseException("cannot negate: " + r);
   }
 
-  private Query exactQuery(IndexPredicate<ChangeData> p) {
+  private Query exactQuery(IndexPredicate<V> p) {
     if (p instanceof RegexPredicate<?>) {
       return regexQuery(p);
-    } else {
-      return new TermQuery(new Term(p.getField().getName(), p.getValue()));
     }
+    return new TermQuery(new Term(p.getField().getName(), p.getValue()));
   }
 
-  private Query regexQuery(IndexPredicate<ChangeData> p) {
+  private Query regexQuery(IndexPredicate<V> p) {
     String re = p.getValue();
     if (re.startsWith("^")) {
       re = re.substring(1);
@@ -238,11 +230,11 @@
     return new RegexpQuery(new Term(p.getField().getName(), re));
   }
 
-  private Query prefixQuery(IndexPredicate<ChangeData> p) {
+  private Query prefixQuery(IndexPredicate<V> p) {
     return new PrefixQuery(new Term(p.getField().getName(), p.getValue()));
   }
 
-  private Query fullTextQuery(IndexPredicate<ChangeData> p)
+  private Query fullTextQuery(IndexPredicate<V> p)
       throws QueryParseException {
     String value = p.getValue();
     if (value == null) {
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/SubIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/SubIndex.java
deleted file mode 100644
index 84a7bda..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/SubIndex.java
+++ /dev/null
@@ -1,345 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.AbstractFuture;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.lucene.LuceneChangeIndex.GerritIndexWriterConfig;
-
-import org.apache.lucene.document.Document;
-import org.apache.lucene.index.IndexWriter;
-import org.apache.lucene.index.Term;
-import org.apache.lucene.index.TrackingIndexWriter;
-import org.apache.lucene.search.ControlledRealTimeReopenThread;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.ReferenceManager;
-import org.apache.lucene.search.ReferenceManager.RefreshListener;
-import org.apache.lucene.search.SearcherFactory;
-import org.apache.lucene.store.AlreadyClosedException;
-import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-/** Piece of the change index that is implemented as a separate Lucene index. */
-public class SubIndex {
-  private static final Logger log = LoggerFactory.getLogger(SubIndex.class);
-
-  private final Directory dir;
-  private final String dirName;
-  private final TrackingIndexWriter writer;
-  private final ListeningExecutorService writerThread;
-  private final ReferenceManager<IndexSearcher> searcherManager;
-  private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
-  private final Set<NrtFuture> notDoneNrtFutures;
-  private ScheduledThreadPoolExecutor autoCommitExecutor;
-
-  SubIndex(Path path, GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory) throws IOException {
-    this(FSDirectory.open(path), path.getFileName().toString(), writerConfig,
-        searcherFactory);
-  }
-
-  SubIndex(Directory dir, final String dirName,
-      GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory) throws IOException {
-    this.dir = dir;
-    this.dirName = dirName;
-    IndexWriter delegateWriter;
-    long commitPeriod = writerConfig.getCommitWithinMs();
-
-    if (commitPeriod < 0) {
-      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
-    } else if (commitPeriod == 0) {
-      delegateWriter =
-          new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
-    } else {
-      final AutoCommitWriter autoCommitWriter =
-          new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
-      delegateWriter = autoCommitWriter;
-
-      autoCommitExecutor =
-          new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder()
-              .setNameFormat("Commit-%d " + dirName)
-              .setDaemon(true).build());
-      autoCommitExecutor.scheduleAtFixedRate(new Runnable() {
-            @Override
-            public void run() {
-              try {
-                if (autoCommitWriter.hasUncommittedChanges()) {
-                  autoCommitWriter.manualFlush();
-                  autoCommitWriter.commit();
-                }
-              } catch (IOException e) {
-                log.error("Error committing Lucene index " + dirName, e);
-              } catch (OutOfMemoryError e) {
-                log.error("Error committing Lucene index " + dirName, e);
-                try {
-                  autoCommitWriter.close();
-                } catch (IOException e2) {
-                  log.error("SEVERE: Error closing Lucene index " + dirName
-                      + " after OOM; index may be corrupted.", e);
-                }
-              }
-            }
-          }, commitPeriod, commitPeriod, MILLISECONDS);
-    }
-    writer = new TrackingIndexWriter(delegateWriter);
-    searcherManager = new WrappableSearcherManager(
-        writer.getIndexWriter(), true, searcherFactory);
-
-    notDoneNrtFutures = Sets.newConcurrentHashSet();
-
-    writerThread = MoreExecutors.listeningDecorator(
-        Executors.newFixedThreadPool(1,
-            new ThreadFactoryBuilder()
-              .setNameFormat("Write-%d " + dirName)
-              .setDaemon(true)
-              .build()));
-
-    reopenThread = new ControlledRealTimeReopenThread<>(
-        writer, searcherManager,
-        0.500 /* maximum stale age (seconds) */,
-        0.010 /* minimum stale age (seconds) */);
-    reopenThread.setName("NRT " + dirName);
-    reopenThread.setPriority(Math.min(
-        Thread.currentThread().getPriority() + 2,
-        Thread.MAX_PRIORITY));
-    reopenThread.setDaemon(true);
-
-    // This must be added after the reopen thread is created. The reopen thread
-    // adds its own listener which copies its internally last-refreshed
-    // generation to the searching generation. removeIfDone() depends on the
-    // searching generation being up to date when calling
-    // reopenThread.waitForGeneration(gen, 0), therefore the reopen thread's
-    // internal listener needs to be called first.
-    // TODO(dborowitz): This may have been fixed by
-    // http://issues.apache.org/jira/browse/LUCENE-5461
-    searcherManager.addListener(new RefreshListener() {
-      @Override
-      public void beforeRefresh() throws IOException {
-      }
-
-      @Override
-      public void afterRefresh(boolean didRefresh) throws IOException {
-        for (NrtFuture f : notDoneNrtFutures) {
-          f.removeIfDone();
-        }
-      }
-    });
-
-    reopenThread.start();
-  }
-
-  void close() {
-    if (autoCommitExecutor != null) {
-      autoCommitExecutor.shutdown();
-    }
-
-    writerThread.shutdown();
-    try {
-      if (!writerThread.awaitTermination(5, TimeUnit.SECONDS)) {
-        log.warn(
-            "shutting down {} index with pending Lucene writes", dirName);
-      }
-    } catch (InterruptedException e) {
-      log.warn("interrupted waiting for pending Lucene writes of " + dirName +
-          " index", e);
-    }
-    reopenThread.close();
-
-    // Closing the reopen thread sets its generation to Long.MAX_VALUE, but we
-    // still need to refresh the searcher manager to let pending NrtFutures
-    // know.
-    //
-    // Any futures created after this method (which may happen due to undefined
-    // shutdown ordering behavior) will finish immediately, even though they may
-    // not have flushed.
-    try {
-      searcherManager.maybeRefreshBlocking();
-    } catch (IOException e) {
-      log.warn("error finishing pending Lucene writes", e);
-    }
-
-    try {
-      writer.getIndexWriter().close();
-    } catch (AlreadyClosedException e) {
-      // Ignore.
-    } catch (IOException e) {
-      log.warn("error closing Lucene writer", e);
-    }
-    try {
-      dir.close();
-    } catch (IOException e) {
-      log.warn("error closing Lucene directory", e);
-    }
-  }
-
-  ListenableFuture<?> insert(final Document doc) {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.addDocument(doc);
-      }
-    });
-  }
-
-  ListenableFuture<?> replace(final Term term, final Document doc) {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.updateDocument(term, doc);
-      }
-    });
-  }
-
-  ListenableFuture<?> delete(final Term term)  {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.deleteDocuments(term);
-      }
-    });
-  }
-
-  private ListenableFuture<?> submit(Callable<Long> task) {
-    ListenableFuture<Long> future =
-        Futures.nonCancellationPropagating(writerThread.submit(task));
-    return Futures.transformAsync(future, new AsyncFunction<Long, Void>() {
-      @Override
-      public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
-        // Tell the reopen thread a future is waiting on this
-        // generation so it uses the min stale time when refreshing.
-        reopenThread.waitForGeneration(gen, 0);
-        return new NrtFuture(gen);
-      }
-    });
-  }
-
-  void deleteAll() throws IOException {
-    writer.deleteAll();
-  }
-
-  public TrackingIndexWriter getWriter() {
-    return writer;
-  }
-
-  IndexSearcher acquire() throws IOException {
-    return searcherManager.acquire();
-  }
-
-  void release(IndexSearcher searcher) throws IOException {
-    searcherManager.release(searcher);
-  }
-
-  private final class NrtFuture extends AbstractFuture<Void> {
-    private final long gen;
-
-    NrtFuture(long gen) {
-      this.gen = gen;
-    }
-
-    @Override
-    public Void get() throws InterruptedException, ExecutionException {
-      if (!isDone()) {
-        reopenThread.waitForGeneration(gen);
-        set(null);
-      }
-      return super.get();
-    }
-
-    @Override
-    public Void get(long timeout, TimeUnit unit) throws InterruptedException,
-        TimeoutException, ExecutionException {
-      if (!isDone()) {
-        if (!reopenThread.waitForGeneration(gen, (int) unit.toMillis(timeout))) {
-          throw new TimeoutException();
-        }
-        set(null);
-      }
-      return super.get(timeout, unit);
-    }
-
-    @Override
-    public boolean isDone() {
-      if (super.isDone()) {
-        return true;
-      } else if (isGenAvailableNowForCurrentSearcher()) {
-        set(null);
-        return true;
-      } else if (!reopenThread.isAlive()) {
-        setException(new IllegalStateException("NRT thread is dead"));
-        return true;
-      }
-      return false;
-    }
-
-    @Override
-    public void addListener(Runnable listener, Executor executor) {
-      if (isGenAvailableNowForCurrentSearcher() && !isCancelled()) {
-        set(null);
-      } else if (!isDone()) {
-        notDoneNrtFutures.add(this);
-      }
-      super.addListener(listener, executor);
-    }
-
-    @Override
-    public boolean cancel(boolean mayInterruptIfRunning) {
-      boolean result = super.cancel(mayInterruptIfRunning);
-      if (result) {
-        notDoneNrtFutures.remove(this);
-      }
-      return result;
-    }
-
-    void removeIfDone() {
-      if (isGenAvailableNowForCurrentSearcher()) {
-        notDoneNrtFutures.remove(this);
-        if (!isCancelled()) {
-          set(null);
-        }
-      }
-    }
-
-    private boolean isGenAvailableNowForCurrentSearcher() {
-      try {
-        return reopenThread.waitForGeneration(gen, 0);
-      } catch (InterruptedException e) {
-        log.warn("Interrupted waiting for searcher generation", e);
-        return false;
-      }
-    }
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index 7bcd0a6..458cfda 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -91,7 +91,7 @@
    *
    * @throws IOException if there is a low-level I/O error
    */
-  public WrappableSearcherManager(IndexWriter writer, boolean applyAllDeletes, SearcherFactory searcherFactory) throws IOException {
+  WrappableSearcherManager(IndexWriter writer, boolean applyAllDeletes, SearcherFactory searcherFactory) throws IOException {
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
@@ -108,7 +108,7 @@
    *
    * @throws IOException if there is a low-level I/O error
    */
-  public WrappableSearcherManager(Directory dir, SearcherFactory searcherFactory) throws IOException {
+  WrappableSearcherManager(Directory dir, SearcherFactory searcherFactory) throws IOException {
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
@@ -127,7 +127,7 @@
    *
    * @throws IOException if there is a low-level I/O error
    */
-  public WrappableSearcherManager(DirectoryReader reader, SearcherFactory searcherFactory) throws IOException {
+  WrappableSearcherManager(DirectoryReader reader, SearcherFactory searcherFactory) throws IOException {
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
@@ -143,13 +143,13 @@
   @Override
   protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
     final IndexReader r = referenceToRefresh.getIndexReader();
-    assert r instanceof DirectoryReader: "searcher's IndexReader should be a DirectoryReader, but got " + r;
+    assert r instanceof DirectoryReader :
+      "searcher's IndexReader should be a DirectoryReader, but got " + r;
     final IndexReader newReader = DirectoryReader.openIfChanged((DirectoryReader) r);
     if (newReader == null) {
       return null;
-    } else {
-      return getSearcher(searcherFactory, newReader);
     }
+    return getSearcher(searcherFactory, newReader);
   }
 
   @Override
@@ -171,7 +171,8 @@
     final IndexSearcher searcher = acquire();
     try {
       final IndexReader r = searcher.getIndexReader();
-      assert r instanceof DirectoryReader: "searcher's IndexReader should be a DirectoryReader, but got " + r;
+      assert r instanceof DirectoryReader :
+        "searcher's IndexReader should be a DirectoryReader, but got " + r;
       return ((DirectoryReader) r).isCurrent();
     } finally {
       release(searcher);
diff --git a/gerrit-main/src/main/java/Main.java b/gerrit-main/src/main/java/Main.java
index 54cf20e..a29f1c6 100644
--- a/gerrit-main/src/main/java/Main.java
+++ b/gerrit-main/src/main/java/Main.java
@@ -34,11 +34,10 @@
     if (1.7 <= parse(version)) {
       return true;
 
-    } else {
-      System.err.println("fatal: Gerrit Code Review requires Java 7 or later");
-      System.err.println("       (trying to run on Java " + version + ")");
-      return false;
     }
+    System.err.println("fatal: Gerrit Code Review requires Java 7 or later");
+    System.err.println("       (trying to run on Java " + version + ")");
+    return false;
   }
 
   private static double parse(String version) {
diff --git a/gerrit-oauth/BUILD b/gerrit-oauth/BUILD
new file mode 100644
index 0000000..b2cf17b
--- /dev/null
+++ b/gerrit-oauth/BUILD
@@ -0,0 +1,26 @@
+SRCS = glob(
+  ['src/main/java/**/*.java'],
+)
+RESOURCES = glob(['src/main/resources/**/*'])
+
+java_library(
+  name = 'oauth',
+  srcs = SRCS,
+  resources = RESOURCES,
+  deps = [
+    '//gerrit-common:annotations',
+    '//gerrit-extension-api:api',
+    '//gerrit-httpd:httpd',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib/commons:codec',
+    '//lib/guice:guice',
+    '//lib/guice:guice-servlet',
+    '//lib/log:api',
+    '//lib:servlet-api-3_1',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index d24c8a0..35f79c9 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -58,9 +59,10 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final AccountManager accountManager;
   private final CanonicalWebUrl urlProvider;
+  private final OAuthTokenCache tokenCache;
   private OAuthServiceProvider serviceProvider;
-  private OAuthToken token;
   private OAuthUserInfo user;
+  private Account.Id accountId;
   private String redirectToken;
   private boolean linkMode;
 
@@ -68,16 +70,18 @@
   OAuthSession(DynamicItem<WebSession> webSession,
       Provider<IdentifiedUser> identifiedUser,
       AccountManager accountManager,
-      CanonicalWebUrl urlProvider) {
+      CanonicalWebUrl urlProvider,
+      OAuthTokenCache tokenCache) {
     this.state = generateRandomState();
     this.identifiedUser = identifiedUser;
     this.webSession = webSession;
     this.accountManager = accountManager;
     this.urlProvider = urlProvider;
+    this.tokenCache = tokenCache;
   }
 
   boolean isLoggedIn() {
-    return token != null && user != null;
+    return user != null;
   }
 
   boolean isOAuthFinal(HttpServletRequest request) {
@@ -95,36 +99,34 @@
       }
 
       log.debug("Login-Retrieve-User " + this);
-      token = oauth.getAccessToken(new OAuthVerifier(request.getParameter("code")));
-
+      OAuthToken token = oauth.getAccessToken(
+          new OAuthVerifier(request.getParameter("code")));
       user = oauth.getUserInfo(token);
 
       if (isLoggedIn()) {
         log.debug("Login-SUCCESS " + this);
-        authenticateAndRedirect(request, response);
+        authenticateAndRedirect(request, response, token);
         return true;
-      } else {
-        response.sendError(SC_UNAUTHORIZED);
-        return false;
       }
-    } else {
-      log.debug("Login-PHASE1 " + this);
-      redirectToken = request.getRequestURI();
-      // We are here in content of filter.
-      // Due to this Jetty limitation:
-      // https://bz.apache.org/bugzilla/show_bug.cgi?id=28323
-      // we cannot use LoginUrlToken.getToken() method,
-      // because it relies on getPathInfo() and it is always null here.
-      redirectToken = redirectToken.substring(
-          request.getContextPath().length());
-      response.sendRedirect(oauth.getAuthorizationUrl() +
-          "&state=" + state);
+      response.sendError(SC_UNAUTHORIZED);
       return false;
     }
+    log.debug("Login-PHASE1 " + this);
+    redirectToken = request.getRequestURI();
+    // We are here in content of filter.
+    // Due to this Jetty limitation:
+    // https://bz.apache.org/bugzilla/show_bug.cgi?id=28323
+    // we cannot use LoginUrlToken.getToken() method,
+    // because it relies on getPathInfo() and it is always null here.
+    redirectToken = redirectToken.substring(
+        request.getContextPath().length());
+    response.sendRedirect(oauth.getAuthorizationUrl() +
+        "&state=" + state);
+    return false;
   }
 
   private void authenticateAndRedirect(HttpServletRequest req,
-      HttpServletResponse rsp) throws IOException {
+      HttpServletResponse rsp, OAuthToken token) throws IOException {
     AuthRequest areq = new AuthRequest(user.getExternalId());
     AuthResult arsp;
     try {
@@ -143,6 +145,9 @@
       areq.setEmailAddress(user.getEmailAddress());
       areq.setDisplayName(user.getDisplayName());
       arsp = accountManager.authenticate(areq);
+
+      accountId = arsp.getAccountId();
+      tokenCache.put(accountId, token);
     } catch (AccountException e) {
       log.error("Unable to authenticate user \"" + user + "\"", e);
       rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
@@ -211,7 +216,10 @@
   }
 
   void logout() {
-    token = null;
+    if (accountId != null) {
+      tokenCache.remove(accountId);
+      accountId = null;
+    }
     user = null;
     redirectToken = null;
     serviceProvider = null;
@@ -243,7 +251,8 @@
 
   @Override
   public String toString() {
-    return "OAuthSession [token=" + token + ", user=" + user + "]";
+    return "OAuthSession [token=" + tokenCache.get(accountId) + ", user="
+        + user + "]";
   }
 
   public void setServiceProvider(OAuthServiceProvider provider) {
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 333af15..4c6c0b0 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -101,13 +101,12 @@
       if (service == null && Strings.isNullOrEmpty(provider)) {
         selectProvider(httpRequest, httpResponse, null);
         return;
-      } else {
-        if (service == null) {
-          service = findService(provider);
-        }
-        oauthSession.setServiceProvider(service);
-        oauthSession.login(httpRequest, httpResponse, service);
       }
+      if (service == null) {
+        service = findService(provider);
+      }
+      oauthSession.setServiceProvider(service);
+      oauthSession.login(httpRequest, httpResponse, service);
     } else {
       chain.doFilter(httpRequest, response);
     }
diff --git a/gerrit-openid/BUCK b/gerrit-openid/BUCK
index 78abce8..5eace7b 100644
--- a/gerrit-openid/BUCK
+++ b/gerrit-openid/BUCK
@@ -3,6 +3,9 @@
   srcs = glob(['src/main/java/**/*.java']),
   resources = glob(['src/main/resources/**/*']),
   deps = [
+    '//lib/openid:consumer',
+  ],
+  provided_deps = [
     '//gerrit-common:annotations',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
@@ -12,13 +15,12 @@
     '//gerrit-server:server',
     '//lib:guava',
     '//lib:gwtorm',
+    '//lib:servlet-api-3_1',
     '//lib/commons:codec',
     '//lib/guice:guice',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/log:api',
-    '//lib/openid:consumer',
   ],
-  provided_deps = ['//lib:servlet-api-3_1'],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-openid/BUILD b/gerrit-openid/BUILD
new file mode 100644
index 0000000..b5ae049
--- /dev/null
+++ b/gerrit-openid/BUILD
@@ -0,0 +1,24 @@
+java_library(
+  name = 'openid',
+  srcs = glob(['src/main/java/**/*.java']),
+  resources = glob(['src/main/resources/**/*']),
+  deps = [ # We want all these deps to be provided_deps
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-gwtexpui:server',
+    '//gerrit-httpd:httpd',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:servlet-api-3_1',
+    '//lib/commons:codec',
+    '//lib/guice:guice',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/log:api',
+    '//lib/openid:consumer',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
index 37051da..2ee3b6b 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
@@ -17,7 +17,7 @@
 import java.util.Map;
 
 final class DiscoveryResult {
-  static enum Status {
+  enum Status {
     /** Provider was discovered and {@code providerUrl} is valid. */
     VALID,
 
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 8b05c72..3a40252 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
@@ -46,6 +45,7 @@
 import org.w3c.dom.Element;
 
 import java.io.IOException;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -102,7 +102,7 @@
       suggestProviders = ImmutableSet.of();
       ssoUrl = authConfig.getOpenIdSsoUrl();
     } else {
-      Set<String> providers = Sets.newHashSet();
+      Set<String> providers = new HashSet<>();
       for (Map.Entry<String, String> e : ALL_PROVIDERS.entrySet()) {
         if (impl.isAllowedOpenID(e.getValue())) {
           providers.add(e.getKey());
@@ -218,7 +218,7 @@
     url.append(r.providerUrl);
     if (r.providerArgs != null && !r.providerArgs.isEmpty()) {
       boolean first = true;
-      for(Map.Entry<String, String> arg : r.providerArgs.entrySet()) {
+      for (Map.Entry<String, String> arg : r.providerArgs.entrySet()) {
         if (first) {
           url.append('?');
           first = false;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index 33c6e34..67ac895 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -104,17 +104,15 @@
         log.debug("Login-SUCCESS " + this);
         authenticateAndRedirect(request, response);
         return true;
-      } else {
-        response.sendError(SC_UNAUTHORIZED);
-        return false;
       }
-    } else {
-      log.debug("Login-PHASE1 " + this);
-      redirectToken = LoginUrlToken.getToken(request);
-      response.sendRedirect(oauth.getAuthorizationUrl() +
-          "&state=" + state);
+      response.sendError(SC_UNAUTHORIZED);
       return false;
     }
+    log.debug("Login-PHASE1 " + this);
+    redirectToken = LoginUrlToken.getToken(request);
+    response.sendRedirect(oauth.getAuthorizationUrl() +
+        "&state=" + state);
+    return false;
   }
 
   private void authenticateAndRedirect(HttpServletRequest req,
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index ba144b6..36947a9 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -450,6 +450,7 @@
       case SIGN_IN:
       case REGISTER:
         return true;
+      case LINK_IDENTIY:
       default:
         return false;
     }
diff --git a/gerrit-patch-commonsnet/BUILD b/gerrit-patch-commonsnet/BUILD
new file mode 100644
index 0000000..c5e541d
--- /dev/null
+++ b/gerrit-patch-commonsnet/BUILD
@@ -0,0 +1,11 @@
+java_library(
+  name = 'commons-net',
+  srcs = glob(['src/main/java/org/apache/commons/net/**/*.java']),
+  deps = [
+    '//gerrit-util-ssl:ssl',
+    '//lib/commons:codec',
+    '//lib/commons:net',
+    '//lib/log:api',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index d24d179..63bb842 100644
--- a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -82,14 +82,8 @@
   private static SSLSocketFactory sslFactory(final boolean verify) {
     if (verify) {
       return (SSLSocketFactory) SSLSocketFactory.getDefault();
-    } else {
-      return (SSLSocketFactory) BlindSSLSocketFactory.getDefault();
     }
-  }
-
-  @Override
-  public String[] getReplyStrings() {
-    return _replyLines.toArray(new String[_replyLines.size()]);
+    return (SSLSocketFactory) BlindSSLSocketFactory.getDefault();
   }
 
   @Override
@@ -167,7 +161,7 @@
     }
 
     String cmd = encodeBase64(smtpUser.getBytes(UTF_8));
-    if(sendCommand(cmd) != 334) {
+    if (sendCommand(cmd) != 334) {
       return false;
     }
 
diff --git a/gerrit-patch-jgit/BUCK b/gerrit-patch-jgit/BUCK
index b54499f..09ccf9c 100644
--- a/gerrit-patch-jgit/BUCK
+++ b/gerrit-patch-jgit/BUCK
@@ -9,23 +9,46 @@
   gwt_xml = SRC + 'JGit.gwt.xml',
   deps = [
     '//lib:gwtjsonrpc',
-    '//lib/jgit:Edit',
+    ':Edit',
   ],
   provided_deps = ['//lib/gwt:user'],
   visibility = ['PUBLIC'],
 )
 
+gwt_module(
+  name = 'Edit',
+  srcs = [':jgit_edit_src'],
+  deps = [':edit_src'],
+  visibility = ['PUBLIC'],
+)
+
+prebuilt_jar(
+  name = 'edit_src',
+  binary_jar = ':jgit_edit_src',
+)
+
+genrule(
+  name = 'jgit_edit_src',
+  cmd = 'unzip -qd $TMP $(location //lib/jgit/org.eclipse.jgit:jgit_src) ' +
+    'org/eclipse/jgit/diff/Edit.java;' +
+    'cd $TMP;' +
+    'zip -Dq $OUT org/eclipse/jgit/diff/Edit.java',
+  out = 'edit.src.zip',
+)
+
 java_library(
   name = 'server',
   srcs = [
-    SRC + 'diff/EditDeserializer.java',
-    SRC + 'diff/ReplaceEdit.java',
-    SRC + 'internal/storage/file/WindowCacheStatAccessor.java',
-    SRC + 'lib/ObjectIdSerialization.java',
+    SRC + x for x in [
+      'diff/EditDeserializer.java',
+      'diff/ReplaceEdit.java',
+      'internal/storage/file/WindowCacheStatAccessor.java',
+      'lib/ObjectIdSerialization.java',
+    ]
   ],
   deps = [
     '//lib:gson',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
   ],
   visibility = ['PUBLIC'],
 )
@@ -35,7 +58,7 @@
   srcs = glob(['src/test/java/**/*.java']),
   deps = [
     ':server',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib:junit',
   ],
   source_under_test = [':server'],
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
new file mode 100644
index 0000000..13a2fe0
--- /dev/null
+++ b/gerrit-patch-jgit/BUILD
@@ -0,0 +1,66 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+
+SRC = 'src/main/java/org/eclipse/jgit/'
+
+gwt_module(
+  name = 'client',
+  srcs = [
+    SRC + 'diff/Edit_JsonSerializer.java',
+    SRC + 'diff/ReplaceEdit.java',
+  ],
+  gwt_xml = SRC + 'JGit.gwt.xml',
+  deps = [
+    ':Edit',
+    '//lib/gwt:user',
+    '//lib:gwtjsonrpc',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+gwt_module(
+  name = 'Edit',
+  srcs = [':jgit_edit_src'],
+  visibility = ['//visibility:public'],
+)
+
+genrule2(
+  name = 'jgit_edit_src',
+  cmd = ' && '.join([
+    'unzip -qd $$TMP $(location @jgit_src//file) ' +
+      'org/eclipse/jgit/diff/Edit.java',
+    'cd $$TMP',
+    'zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java',
+  ]),
+  tools = ['@jgit_src//file'],
+  out = 'edit.srcjar',
+)
+
+java_library(
+  name = 'server',
+  srcs = [
+    SRC + x for x in [
+      'diff/EditDeserializer.java',
+      'diff/ReplaceEdit.java',
+      'internal/storage/file/WindowCacheStatAccessor.java',
+      'lib/ObjectIdSerialization.java',
+    ]
+  ],
+  deps = [
+    '//lib:gson',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_test(
+  name = 'jgit_patch_tests',
+  test_class = 'org.eclipse.jgit.diff.EditDeserializerTest',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':server',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
index c57ec52..4be941c 100644
--- a/gerrit-pgm/BUCK
+++ b/gerrit-pgm/BUCK
@@ -3,23 +3,27 @@
 
 INIT_API_SRCS = glob([SRCS + 'init/api/*.java'])
 
-DEPS = [
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:linker_server',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-httpd:httpd',
-    '//gerrit-server:server',
-    '//gerrit-sshd:sshd',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
-    '//lib/log:api',
-    '//lib/log:jsonevent-layout',
-    '//lib/log:log4j'
+BASE_JETTY_DEPS = [
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-gwtexpui:linker_server',
+  '//gerrit-gwtexpui:server',
+  '//gerrit-httpd:httpd',
+  '//gerrit-server:server',
+  '//gerrit-sshd:sshd',
+  '//lib:guava',
+  '//lib/guice:guice',
+  '//lib/guice:guice-assistedinject',
+  '//lib/guice:guice-servlet',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/joda:joda-time',
+  '//lib/log:api',
+  '//lib/log:log4j',
+]
+
+DEPS = BASE_JETTY_DEPS + [
+  '//gerrit-reviewdb:server',
+  '//lib/log:jsonevent-layout',
 ]
 
 java_library(
@@ -83,24 +87,31 @@
   name = 'util-nodep',
   srcs = glob([SRCS + 'util/*.java']),
   provided_deps = DEPS + REST_UTIL_DEPS,
-  visibility = [
-    '//gerrit-acceptance-framework/...',
-  ],
+  visibility = ['//gerrit-acceptance-framework/...'],
 )
 
+JETTY_DEPS = [
+  '//lib/jetty:jmx',
+  '//lib/jetty:server',
+  '//lib/jetty:servlet',
+]
+
 java_library(
   name = 'http',
-  srcs = glob([SRCS + 'http/**/*.java']),
-  deps = DEPS + [
-    '//lib/jetty:jmx',
-    '//lib/jetty:server',
-    '//lib/jetty:servlet',
-  ],
-  provided_deps = [
+  deps = DEPS + JETTY_DEPS,
+  exported_deps = [':http-jetty'],
+  visibility = ['//gerrit-war:'],
+)
+
+java_library(
+  name = 'http-jetty',
+  srcs = glob([SRCS + 'http/jetty/*.java']),
+  provided_deps = JETTY_DEPS + BASE_JETTY_DEPS + [
     '//gerrit-launcher:launcher',
+    '//gerrit-reviewdb:client',
     '//lib:servlet-api-3_1',
   ],
-  visibility = ['//gerrit-war:'],
+  visibility = ['//gerrit-acceptance-framework/...'],
 )
 
 REST_PGM_DEPS = [
@@ -166,8 +177,8 @@
     '//lib:junit',
     '//lib/easymock:easymock',
     '//lib/guice:guice',
-    '//lib/jgit:jgit',
-    '//lib/jgit:junit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
   ],
   source_under_test = [':pgm'],
 )
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
new file mode 100644
index 0000000..59b371a
--- /dev/null
+++ b/gerrit-pgm/BUILD
@@ -0,0 +1,161 @@
+load('//tools/bzl:java.bzl', 'java_library2')
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRCS = 'src/main/java/com/google/gerrit/pgm/'
+RSRCS = 'src/main/resources/com/google/gerrit/pgm/'
+
+INIT_API_SRCS = glob([SRCS + 'init/api/*.java'])
+
+BASE_JETTY_DEPS = [
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-gwtexpui:linker_server',
+  '//gerrit-gwtexpui:server',
+  '//gerrit-httpd:httpd',
+  '//gerrit-server:server',
+  '//gerrit-sshd:sshd',
+  '//lib:guava',
+  '//lib/guice:guice',
+  '//lib/guice:guice-assistedinject',
+  '//lib/guice:guice-servlet',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/log:api',
+  '//lib/log:log4j',
+]
+
+DEPS = BASE_JETTY_DEPS + [
+  '//gerrit-reviewdb:server',
+  '//lib/log:jsonevent-layout',
+]
+
+java_library(
+  name = 'init-api',
+  srcs = INIT_API_SRCS,
+  deps = DEPS + ['//gerrit-common:annotations'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'init',
+  srcs = glob([SRCS + 'init/*.java']),
+  resources = glob([RSRCS + 'init/*']),
+  deps = DEPS + [
+    ':init-api',
+    ':util',
+    '//gerrit-common:annotations',
+    '//gerrit-launcher:launcher', # We want this dep to be provided_deps
+    '//gerrit-lucene:lucene',
+    '//lib:args4j',
+    '//lib:derby',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm',
+    '//lib:h2',
+    '//lib/commons:validator',
+    '//lib/mina:sshd',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+REST_UTIL_DEPS = [
+  '//gerrit-cache-h2:cache-h2',
+  '//gerrit-util-cli:cli',
+  '//lib:args4j',
+  '//lib:gwtorm',
+  '//lib/commons:dbcp',
+]
+
+java_library(
+  name = 'util',
+  exports = [':util-nodep'],
+  runtime_deps = DEPS + REST_UTIL_DEPS,
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'util-nodep',
+  srcs = glob([SRCS + 'util/*.java']),
+  deps = DEPS + REST_UTIL_DEPS, #  We want all these deps to be provided_deps
+  visibility = ['//visibility:public'],
+)
+
+JETTY_DEPS = [
+  '//lib/jetty:jmx',
+  '//lib/jetty:server',
+  '//lib/jetty:servlet',
+]
+
+java_library(
+  name = 'http',
+  runtime_deps = DEPS + JETTY_DEPS,
+  exports = [':http-jetty'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'http-jetty',
+  srcs = glob([SRCS + 'http/jetty/*.java']),
+  deps = JETTY_DEPS + BASE_JETTY_DEPS + [ # We want all these deps to be provided_deps
+    '//gerrit-launcher:launcher',
+    '//gerrit-reviewdb:client',
+    '//lib:servlet-api-3_1',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+REST_PGM_DEPS = [
+  ':http',
+  ':init',
+  ':init-api',
+  ':util',
+  '//gerrit-cache-h2:cache-h2',
+  '//gerrit-gpg:gpg',
+  '//gerrit-lucene:lucene',
+  '//gerrit-oauth:oauth',
+  '//gerrit-openid:openid',
+  '//lib:args4j',
+  '//lib:gwtorm',
+  '//lib:protobuf',
+  '//lib:servlet-api-3_1-without-neverlink',
+  '//lib/auto:auto-value',
+  '//lib/prolog:cafeteria',
+  '//lib/prolog:compiler',
+  '//lib/prolog:runtime',
+]
+
+java_library(
+  name = 'pgm',
+  resources = glob([RSRCS + '*']),
+  runtime_deps = DEPS + REST_PGM_DEPS + [
+    ':daemon',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+# no transitive deps, used for gerrit-acceptance-framework
+java_library(
+  name = 'daemon',
+  srcs = glob([SRCS + '*.java', SRCS + 'rules/*.java']),
+  resources = glob([RSRCS + '*']),
+  deps = DEPS + REST_PGM_DEPS + [ # We want all these deps to be provided_deps
+    '//gerrit-launcher:launcher',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'pgm_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':init',
+    ':init-api',
+    ':pgm',
+    '//gerrit-common:server',
+    '//gerrit-server:server',
+    '//lib:guava',
+    '//lib:junit',
+    '//lib/easymock:easymock',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+  ],
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index ee1b111..3af1397 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -19,7 +19,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.ChangeHookRunner;
+import com.google.gerrit.common.EventBroker;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.GerritOptions;
@@ -28,13 +28,17 @@
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
 import com.google.gerrit.httpd.RequestContextFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
+import com.google.gerrit.httpd.RequireSslFilter;
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.http.jetty.JettyEnv;
 import com.google.gerrit.pgm.http.jetty.JettyModule;
 import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter;
@@ -54,9 +58,10 @@
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RestCacheAdminModule;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
+import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.IndexModule;
@@ -68,6 +73,8 @@
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -81,6 +88,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -138,6 +146,9 @@
   @Option(name = "--headless", usage = "Don't start the UI frontend")
   private boolean headless;
 
+  @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
+  private boolean polyGerritDev;
+
   @Option(name = "--init", aliases = {"-i"},
       usage = "Init site before starting the daemon")
   private boolean doInit;
@@ -273,7 +284,7 @@
   @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
-      dbInjector = createDbInjector(MULTI_USER);
+      dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(
@@ -324,14 +335,24 @@
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(SchemaVersionCheck.module());
+    modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
+
+    // Index module shutdown must happen before work queue shutdown, otherwise
+    // work queue can get stuck waiting on index futures that will never return.
+    modules.add(createIndexModule());
+
     modules.add(new WorkQueue.Module());
-    modules.add(new ChangeHookRunner.Module());
+    modules.add(new StreamEventsApiListener.Module());
+    modules.add(new EventBroker.Module());
+    modules.add(test
+        ? new H2AccountPatchReviewStore.InMemoryModule()
+        : new JdbcAccountPatchReviewStore.Module(config));
     modules.add(new ReceiveCommitsExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new ChangeCacheImplModule(slave));
+    modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultCacheFactory.Module());
     if (emailModule != null) {
@@ -343,7 +364,6 @@
     modules.add(new PluginRestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
-    modules.add(createIndexModule());
     if (MoreObjects.firstNonNull(httpd, true)) {
       modules.add(new CanonicalWebUrlModule() {
         @Override
@@ -367,7 +387,8 @@
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
-        bind(GerritOptions.class).toInstance(new GerritOptions(headless, slave));
+        bind(GerritOptions.class).toInstance(
+            new GerritOptions(config, headless, slave, polyGerritDev));
         if (test) {
           bind(String.class).annotatedWith(SecureStoreClassName.class)
               .toInstance(DefaultSecureStore.class.getName());
@@ -388,7 +409,9 @@
     }
     switch (indexType) {
       case LUCENE:
-        return luceneModule != null ? luceneModule : new LuceneIndexModule();
+        return luceneModule != null
+            ? luceneModule
+            : LuceneIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -418,7 +441,8 @@
       modules.add(new SshHostKeyModule());
     }
     modules.add(new DefaultCommandModule(slave,
-        sysInjector.getInstance(DownloadConfig.class)));
+        sysInjector.getInstance(DownloadConfig.class),
+        sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
     if (!slave && indexType == IndexType.LUCENE) {
       modules.add(new IndexCommandsModule());
     }
@@ -446,9 +470,11 @@
     }
     modules.add(RequestContextFilter.module());
     modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
     modules.add(H2CacheBasedWebSession.module());
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
     modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     modules.add(new HttpPluginModule());
     if (sshd) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
@@ -465,6 +491,9 @@
     }
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
+    // StaticModule contains a "/*" wildcard, place it last.
+    modules.add(sysInjector.getInstance(StaticModule.class));
+
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
index e08b7ac..05a0d70 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
@@ -40,18 +40,24 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 /** Initialize a new Gerrit installation. */
 public class Init extends BaseInit {
-  @Option(name = "--batch", usage = "Batch mode; skip interactive prompting")
+  @Option(name = "--batch", aliases = {"-b"},
+      usage = "Batch mode; skip interactive prompting")
   private boolean batchMode;
 
+  @Option(name = "--delete-caches",
+      usage = "Delete all persistent caches without asking")
+  private boolean deleteCaches;
+
   @Option(name = "--no-auto-start", usage = "Don't automatically start daemon after init")
   private boolean noAutoStart;
 
   @Option(name = "--skip-plugins", usage = "Don't install plugins")
-  private boolean skipPlugins = false;
+  private boolean skipPlugins;
 
   @Option(name = "--list-plugins", usage = "List available plugins")
   private boolean listPlugins;
@@ -59,6 +65,10 @@
   @Option(name = "--install-plugin", usage = "Install given plugin without asking")
   private List<String> installPlugins;
 
+  @Option(name = "--install-all-plugins",
+      usage = "Install all plugins from war without asking")
+  private boolean installAllPlugins;
+
   @Option(name = "--secure-store-lib",
       usage = "Path to jar providing SecureStore implementation class")
   private String secureStoreLib;
@@ -67,6 +77,12 @@
       usage = "Setup site with default options suitable for developers")
   private boolean dev;
 
+  @Option(name = "--skip-all-downloads", usage = "Don't download libraries")
+  private boolean skipAllDownloads;
+
+  @Option(name = "--skip-download", usage = "Don't download given library")
+  private List<String> skippedDownloads;
+
   @Inject
   Browser browser;
 
@@ -86,8 +102,14 @@
 
     if (!skipPlugins) {
       final List<PluginData> plugins =
-          InitPlugins.listPluginsAndRemoveTempFiles(init.site, pluginsDistribution);
+          InitPlugins.listPluginsAndRemoveTempFiles(init.site,
+              pluginsDistribution);
       ConsoleUI ui = ConsoleUI.getInstance(false);
+      if (installAllPlugins && !nullOrEmpty(installPlugins)) {
+        ui.message(
+            "Cannot use --install-plugin together with --install-all-plugins.\n");
+        return true;
+      }
       verifyInstallPluginList(ui, plugins);
       if (listPlugins) {
         if (!plugins.isEmpty()) {
@@ -106,7 +128,7 @@
 
   @Override
   protected void afterInit(SiteRun run) throws Exception {
-    List<Module> modules = Lists.newArrayList();
+    List<Module> modules = new ArrayList<>();
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
@@ -127,6 +149,11 @@
   }
 
   @Override
+  protected boolean installAllPlugins() {
+    return installAllPlugins;
+  }
+
+  @Override
   protected ConsoleUI getConsoleUI() {
     return ConsoleUI.getInstance(batchMode);
   }
@@ -137,6 +164,12 @@
   }
 
   @Override
+  protected boolean getDeleteCaches() {
+    return deleteCaches;
+  }
+
+
+  @Override
   protected boolean skipPlugins() {
     return skipPlugins;
   }
@@ -147,6 +180,18 @@
   }
 
   @Override
+  protected boolean skipAllDownloads() {
+    return skipAllDownloads;
+  }
+
+  @Override
+  protected List<String> getSkippedDownloads() {
+    return skippedDownloads != null
+        ? skippedDownloads
+        : Collections.<String> emptyList();
+  }
+
+  @Override
   protected String getSecureStoreLib() {
     return secureStoreLib;
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
index d3643f3..b092784 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
@@ -134,7 +134,7 @@
       new Class[]  { String.class, pyObject },
       new Object[] { getDefaultBanner() +
         " running for Gerrit " + com.google.gerrit.common.Version.getVersion(),
-        null });
+        null, });
   }
 
   public void set(String key, Object content) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 224c75c..bd9ed8f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -116,7 +116,7 @@
   private class Worker extends Thread {
     @Override
     public void run() {
-      try (ReviewDb db = database.open()){
+      try (ReviewDb db = database.open()) {
         for (;;) {
           final AccountExternalId extId = next();
           if (extId == null) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
new file mode 100644
index 0000000..6e6be3a
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Migrates AccountPatchReviewDb from one to another */
+public class MigrateAccountPatchReviewDb extends SiteProgram {
+
+  @Option(name = "--sourceUrl", usage = "Url of source database")
+  private String sourceUrl;
+
+  @Option(name = "--chunkSize", usage = "chunk size of fetching from source and push to target on each time")
+  private static long chunkSize = 100000;
+
+  @Override
+  public int run() throws Exception {
+    SitePaths sitePaths = new SitePaths(getSitePath());
+    Config fakeCfg = new Config();
+    if (!Strings.isNullOrEmpty(sourceUrl)) {
+      fakeCfg.setString("accountPatchReviewDb", null, "url", sourceUrl);
+    }
+    JdbcAccountPatchReviewStore sourceJdbcAccountPatchReviewStore =
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(fakeCfg,
+            sitePaths);
+
+    Injector dbInjector = createDbInjector(DataSourceProvider.Context.SINGLE_USER);
+    Config cfg = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    String targetUrl = cfg.getString("accountPatchReviewDb", null, "url");
+    if (targetUrl == null) {
+      System.err.println("accountPatchReviewDb.url is null in gerrit.config");
+      return 1;
+    }
+    System.out.println("target Url: " + targetUrl);
+    JdbcAccountPatchReviewStore targetJdbcAccountPatchReviewStore =
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(cfg, sitePaths);
+    targetJdbcAccountPatchReviewStore.createTableIfNotExists();
+
+    if (!isTargetTableEmpty(targetJdbcAccountPatchReviewStore)) {
+      System.err.println("target table is not empty, cannot proceed");
+      return 1;
+    }
+
+    try (Connection sourceCon = sourceJdbcAccountPatchReviewStore.getConnection();
+        Connection targetCon = targetJdbcAccountPatchReviewStore.getConnection();
+        PreparedStatement sourceStmt =
+            sourceCon.prepareStatement(
+                "SELECT account_id, change_id, patch_set_id, file_name "
+                    + "FROM account_patch_reviews "
+                    + "LIMIT ? "
+                    + "OFFSET ?");
+        PreparedStatement targetStmt =
+            targetCon.prepareStatement("INSERT INTO account_patch_reviews "
+                + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                + "(?, ?, ?, ?)")) {
+      targetCon.setAutoCommit(false);
+      long offset = 0;
+      List<Row> rows = selectRows(sourceStmt, offset);
+      while (!rows.isEmpty()) {
+        insertRows(targetCon, targetStmt, rows);
+        offset += rows.size();
+        rows = selectRows(sourceStmt, offset);
+      }
+    }
+    return 0;
+  }
+
+  @AutoValue
+  abstract static class Row {
+    abstract int accountId();
+    abstract int changeId();
+    abstract int patchSetId();
+    abstract String fileName();
+  }
+
+  private static boolean isTargetTableEmpty(JdbcAccountPatchReviewStore store)
+      throws SQLException {
+    try (Connection con = store.getConnection();
+        Statement s = con.createStatement();
+        ResultSet r = s.executeQuery(
+            "SELECT COUNT(1) FROM account_patch_reviews")) {
+      if (r.next()) {
+        return r.getInt(1) == 0;
+      }
+      return true;
+    }
+  }
+
+  private static List<Row> selectRows(PreparedStatement stmt, long offset)
+      throws SQLException {
+    List<Row> results = new ArrayList<>();
+    stmt.setLong(1, chunkSize);
+    stmt.setLong(2, offset);
+    try (ResultSet rs = stmt.executeQuery()) {
+      while (rs.next()) {
+        results.add(new AutoValue_MigrateAccountPatchReviewDb_Row(
+            rs.getInt("account_id"),
+            rs.getInt("change_id"),
+            rs.getInt("patch_set_id"),
+            rs.getString("file_name")));
+      }
+    }
+    return results;
+  }
+
+  private static void insertRows(Connection con, PreparedStatement stmt,
+      List<Row> rows) throws SQLException {
+    for (Row r : rows) {
+      stmt.setLong(1, r.accountId());
+      stmt.setLong(2, r.changeId());
+      stmt.setLong(3, r.patchSetId());
+      stmt.setString(4, r.fileName());
+      stmt.addBatch();
+    }
+    stmt.executeBatch();
+    con.commit();
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
new file mode 100644
index 0000000..643323f
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InstallAllPlugins;
+import com.google.gerrit.pgm.init.api.InstallPlugins;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.config.GerritServerConfigModule;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.securestore.SecureStoreClassName;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.util.Providers;
+
+import org.kohsuke.args4j.Argument;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+public class Passwd extends SiteProgram {
+  private String section;
+  private String key;
+
+  @Argument(metaVar = "SECTION.KEY", index = 0, required = true,
+      usage = "Section and key separated by a dot of the password to set")
+  private String sectionAndKey;
+
+  @Argument(metaVar = "PASSWORD", index = 1, required = false,
+      usage = "Password to set")
+  private String password;
+
+  private void init() {
+    String[] varParts = sectionAndKey.split("\\.");
+    if (varParts.length != 2) {
+      throw new IllegalArgumentException("Invalid name '" + sectionAndKey
+          + "': expected section.key format");
+    }
+    section = varParts[0];
+    key = varParts[1];
+  }
+
+  @Override
+  public int run() throws Exception {
+    init();
+    SetPasswd setPasswd = getSysInjector().getInstance(SetPasswd.class);
+    setPasswd.run(section, key, password);
+    return 0;
+  }
+
+  private Injector getSysInjector() {
+    List<Module> modules = new ArrayList<>();
+    modules.add(new FactoryModule() {
+      @Override
+      protected void configure() {
+        bind(Path.class).annotatedWith(SitePath.class)
+            .toInstance(getSitePath());
+        bind(ConsoleUI.class).toInstance(
+            ConsoleUI.getInstance(password != null));
+        factory(Section.Factory.class);
+        bind(Boolean.class).annotatedWith(InstallAllPlugins.class).toInstance(
+            Boolean.FALSE);
+        bind(new TypeLiteral<List<String>>() {}).annotatedWith(
+            InstallPlugins.class).toInstance(new ArrayList<String>());
+        bind(String.class).annotatedWith(SecureStoreClassName.class)
+            .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+      }
+    });
+    modules.add(new GerritServerConfigModule());
+    return Guice.createInjector(modules);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
index 84741d4..7dba8ed 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
@@ -21,7 +21,6 @@
 import com.google.gwtorm.schema.java.JavaSchemaModel;
 
 import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.IO;
 import org.kohsuke.args4j.Option;
 
@@ -39,7 +38,7 @@
 
   @Override
   public int run() throws Exception {
-    LockFile lock = new LockFile(file.getAbsoluteFile(), FS.DETECTED);
+    LockFile lock = new LockFile(file.getAbsoluteFile());
     if (!lock.lock()) {
       throw die("Cannot lock " + file);
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
new file mode 100644
index 0000000..0adb1af
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+
+import com.google.common.base.Function;
+import com.google.common.base.Predicates;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.util.BatchProgramModule;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.pgm.util.ThreadLimiter;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.DummyIndexModule;
+import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+public class RebuildNoteDb extends SiteProgram {
+  private static final Logger log =
+      LoggerFactory.getLogger(RebuildNoteDb.class);
+
+  @Option(name = "--threads",
+      usage = "Number of threads to use for rebuilding NoteDb")
+  private int threads = Runtime.getRuntime().availableProcessors();
+
+  @Option(name = "--project",
+      usage = "Projects to rebuild; recommended for debugging only")
+  private List<String> projects = new ArrayList<>();
+
+  @Option(name = "--change",
+      usage = "Individual change numbers to rebuild; recommended for debugging only")
+  private List<Integer> changes = new ArrayList<>();
+
+  private Injector dbInjector;
+  private Injector sysInjector;
+
+  @Inject
+  private AllUsersName allUsersName;
+
+  @Inject
+  private ChangeRebuilder rebuilder;
+
+  @Inject
+  @GerritServerConfig
+  private Config cfg;
+
+  @Inject
+  private GitRepositoryManager repoManager;
+
+  @Inject
+  private NotesMigration notesMigration;
+
+  @Inject
+  private SchemaFactory<ReviewDb> schemaFactory;
+
+  @Inject
+  private WorkQueue workQueue;
+
+  @Override
+  public int run() throws Exception {
+    mustHaveValidSite();
+    dbInjector = createDbInjector(MULTI_USER);
+    threads = ThreadLimiter.limitThreads(dbInjector, threads);
+
+    LifecycleManager dbManager = new LifecycleManager();
+    dbManager.add(dbInjector);
+    dbManager.start();
+
+    sysInjector = createSysInjector();
+    sysInjector.injectMembers(this);
+    if (!notesMigration.enabled()) {
+      throw die("NoteDb is not enabled.");
+    }
+    LifecycleManager sysManager = new LifecycleManager();
+    sysManager.add(sysInjector);
+    sysManager.start();
+
+    ListeningExecutorService executor = newExecutor();
+    System.out.println("Rebuilding the NoteDb");
+
+    final ImmutableMultimap<Project.NameKey, Change.Id> changesByProject =
+        getChangesByProject();
+    boolean ok;
+    Stopwatch sw = Stopwatch.createStarted();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      deleteRefs(RefNames.REFS_DRAFT_COMMENTS, allUsersRepo);
+
+      List<ListenableFuture<Boolean>> futures = new ArrayList<>();
+      List<Project.NameKey> projectNames = Ordering.usingToString()
+          .sortedCopy(changesByProject.keySet());
+      for (final Project.NameKey project : projectNames) {
+        ListenableFuture<Boolean> future = executor.submit(
+            new Callable<Boolean>() {
+              @Override
+              public Boolean call() {
+                try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+                  return rebuilder.rebuildProject(
+                      db, changesByProject, project, allUsersRepo);
+                } catch (Exception e) {
+                  log.error("Error rebuilding project " + project, e);
+                  return false;
+                }
+              }
+            });
+        futures.add(future);
+      }
+
+      try {
+        ok = Iterables.all(
+            Futures.allAsList(futures).get(), Predicates.equalTo(true));
+      } catch (InterruptedException | ExecutionException e) {
+        log.error("Error rebuilding projects", e);
+        ok = false;
+      }
+    }
+
+    double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+    System.out.format("Rebuild %d changes in %.01fs (%.01f/s)\n",
+        changesByProject.size(), t, changesByProject.size() / t);
+    return ok ? 0 : 1;
+  }
+
+  private static void execute(BatchRefUpdate bru, Repository repo)
+      throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand command : bru.getCommands()) {
+      if (command.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException(String.format("Command %s failed: %s",
+            command.toString(), command.getResult()));
+      }
+    }
+  }
+
+  private void deleteRefs(String prefix, Repository allUsersRepo)
+      throws IOException {
+    RefDatabase refDb = allUsersRepo.getRefDatabase();
+    Map<String, Ref> allRefs = refDb.getRefs(prefix);
+    BatchRefUpdate bru = refDb.newBatchUpdate();
+    for (Map.Entry<String, Ref> ref : allRefs.entrySet()) {
+      bru.addCommand(new ReceiveCommand(ref.getValue().getObjectId(),
+          ObjectId.zeroId(), prefix + ref.getKey()));
+    }
+    execute(bru, allUsersRepo);
+  }
+
+  private Injector createSysInjector() {
+    return dbInjector.createChildInjector(new FactoryModule() {
+      @Override
+      public void configure() {
+        install(dbInjector.getInstance(BatchProgramModule.class));
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(
+            ReindexAfterUpdate.class);
+        install(new DummyIndexModule());
+        factory(ChangeResource.Factory.class);
+      }
+    });
+  }
+
+  private ListeningExecutorService newExecutor() {
+    if (threads > 0) {
+      return MoreExecutors.listeningDecorator(
+          workQueue.createQueue(threads, "RebuildChange"));
+    }
+    return MoreExecutors.newDirectExecutorService();
+  }
+
+  private ImmutableMultimap<Project.NameKey, Change.Id> getChangesByProject()
+      throws OrmException {
+    // Memorize all changes so we can close the db connection and allow
+    // rebuilder threads to use the full connection pool.
+    Multimap<Project.NameKey, Change.Id> changesByProject =
+        ArrayListMultimap.create();
+    try (ReviewDb db = schemaFactory.open()) {
+      if (projects.isEmpty() && !changes.isEmpty()) {
+        Iterable<Change> todo = unwrapDb(db).changes().get(
+            Iterables.transform(changes, new Function<Integer, Change.Id>() {
+              @Override
+              public Change.Id apply(Integer in) {
+                return new Change.Id(in);
+              }
+            }));
+        for (Change c : todo) {
+          changesByProject.put(c.getProject(), c.getId());
+        }
+      } else {
+        for (Change c : unwrapDb(db).changes().all()) {
+          boolean include = false;
+          if (projects.isEmpty() && changes.isEmpty()) {
+            include = true;
+          } else if (!projects.isEmpty()
+              && projects.contains(c.getProject().get())) {
+            include = true;
+          } else if (!changes.isEmpty() && changes.contains(c.getId().get())) {
+            include = true;
+          }
+          if (include) {
+            changesByProject.put(c.getProject(), c.getId());
+          }
+        }
+      }
+      return ImmutableMultimap.copyOf(changesByProject);
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNotedb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNotedb.java
deleted file mode 100644
index dd5fe0c..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNotedb.java
+++ /dev/null
@@ -1,278 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.FormatUtil;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.BatchProgramModule;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.pgm.util.ThreadLimiter;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.ReindexAfterUpdate;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
-import com.google.gerrit.server.notedb.NoteDbModule;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.AbstractModule;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public class RebuildNotedb extends SiteProgram {
-  private static final Logger log =
-      LoggerFactory.getLogger(RebuildNotedb.class);
-
-  @Option(name = "--threads",
-      usage = "Number of threads to use for rebuilding NoteDb")
-  private int threads = Runtime.getRuntime().availableProcessors();
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-    dbInjector = createDbInjector(MULTI_USER);
-    threads = ThreadLimiter.limitThreads(dbInjector, threads);
-
-    LifecycleManager dbManager = new LifecycleManager();
-    dbManager.add(dbInjector);
-    dbManager.start();
-
-    sysInjector = createSysInjector();
-    NotesMigration notesMigration = sysInjector.getInstance(
-        NotesMigration.class);
-    if (!notesMigration.enabled()) {
-      die("Notedb is not enabled.");
-    }
-    LifecycleManager sysManager = new LifecycleManager();
-    sysManager.add(sysInjector);
-    sysManager.start();
-
-    ListeningExecutorService executor = newExecutor();
-    System.out.println("Rebuilding the notedb");
-    ChangeRebuilder rebuilder = sysInjector.getInstance(ChangeRebuilder.class);
-
-    Multimap<Project.NameKey, Change> changesByProject = getChangesByProject();
-    final AtomicBoolean ok = new AtomicBoolean(true);
-    Stopwatch sw = Stopwatch.createStarted();
-    GitRepositoryManager repoManager =
-        sysInjector.getInstance(GitRepositoryManager.class);
-    final Project.NameKey allUsersName =
-        sysInjector.getInstance(AllUsersName.class);
-    try (Repository allUsersRepo =
-        repoManager.openMetadataRepository(allUsersName)) {
-      deleteDraftRefs(allUsersRepo);
-      for (final Project.NameKey project : changesByProject.keySet()) {
-        try (Repository repo = repoManager.openMetadataRepository(project)) {
-          final BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-          final BatchRefUpdate bruForDrafts =
-              allUsersRepo.getRefDatabase().newBatchUpdate();
-          List<ListenableFuture<?>> futures = Lists.newArrayList();
-
-          // Here, we elide the project name to 50 characters to ensure that
-          // the whole monitor line for a project fits on one line (<80 chars).
-          final MultiProgressMonitor mpm = new MultiProgressMonitor(System.out,
-              FormatUtil.elide(project.get(), 50));
-          final Task doneTask =
-              mpm.beginSubTask("done", changesByProject.get(project).size());
-          final Task failedTask =
-              mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
-
-          for (final Change c : changesByProject.get(project)) {
-            final ListenableFuture<?> future = rebuilder.rebuildAsync(c,
-                executor, bru, bruForDrafts, repo, allUsersRepo);
-            futures.add(future);
-            future.addListener(
-                new RebuildListener(c.getId(), future, ok, doneTask, failedTask),
-                MoreExecutors.directExecutor());
-          }
-
-          mpm.waitFor(Futures.transformAsync(Futures.successfulAsList(futures),
-              new AsyncFunction<List<?>, Void>() {
-                  @Override
-                public ListenableFuture<Void> apply(List<?> input)
-                    throws Exception {
-                  execute(bru, repo);
-                  execute(bruForDrafts, allUsersRepo);
-                  mpm.end();
-                  return Futures.immediateFuture(null);
-                }
-              }));
-        } catch (Exception e) {
-          log.error("Error rebuilding notedb", e);
-          ok.set(false);
-          break;
-        }
-      }
-    }
-
-    double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    System.out.format("Rebuild %d changes in %.01fs (%.01f/s)\n",
-        changesByProject.size(), t, changesByProject.size() / t);
-    return ok.get() ? 0 : 1;
-  }
-
-  private static void execute(BatchRefUpdate bru, Repository repo)
-      throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    }
-  }
-
-  private void deleteDraftRefs(Repository allUsersRepo) throws IOException {
-    RefDatabase refDb = allUsersRepo.getRefDatabase();
-    Map<String, Ref> allRefs = refDb.getRefs(RefNames.REFS_DRAFT_COMMENTS);
-    BatchRefUpdate bru = refDb.newBatchUpdate();
-    for (Map.Entry<String, Ref> ref : allRefs.entrySet()) {
-      bru.addCommand(new ReceiveCommand(ref.getValue().getObjectId(),
-          ObjectId.zeroId(), RefNames.REFS_DRAFT_COMMENTS + ref.getKey()));
-    }
-    execute(bru, allUsersRepo);
-  }
-
-  private Injector createSysInjector() {
-    return dbInjector.createChildInjector(new AbstractModule() {
-      @Override
-      public void configure() {
-        install(dbInjector.getInstance(BatchProgramModule.class));
-        install(SearchingChangeCacheImpl.module());
-        install(new NoteDbModule());
-        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(
-            ReindexAfterUpdate.class);
-        install(new DummyIndexModule());
-      }
-    });
-  }
-
-  private ListeningExecutorService newExecutor() {
-    if (threads > 0) {
-      return MoreExecutors.listeningDecorator(
-          dbInjector.getInstance(WorkQueue.class)
-            .createQueue(threads, "RebuildChange"));
-    } else {
-      return MoreExecutors.newDirectExecutorService();
-    }
-  }
-
-  private Multimap<Project.NameKey, Change> getChangesByProject()
-      throws OrmException {
-    // Memorize all changes so we can close the db connection and allow
-    // rebuilder threads to use the full connection pool.
-    SchemaFactory<ReviewDb> schemaFactory = sysInjector.getInstance(Key.get(
-        new TypeLiteral<SchemaFactory<ReviewDb>>() {}));
-    Multimap<Project.NameKey, Change> changesByProject =
-        ArrayListMultimap.create();
-    try (ReviewDb db = schemaFactory.open()) {
-      for (Change c : db.changes().all()) {
-        changesByProject.put(c.getProject(), c);
-      }
-      return changesByProject;
-    }
-  }
-
-  private static class RebuildListener implements Runnable {
-    private Change.Id changeId;
-    private ListenableFuture<?> future;
-    private AtomicBoolean ok;
-    private Task doneTask;
-    private Task failedTask;
-
-
-    private RebuildListener(Change.Id changeId, ListenableFuture<?> future,
-        AtomicBoolean ok, Task doneTask, Task failedTask) {
-      this.changeId = changeId;
-      this.future = future;
-      this.ok = ok;
-      this.doneTask = doneTask;
-      this.failedTask = failedTask;
-    }
-
-    @Override
-    public void run() {
-      try {
-        future.get();
-        doneTask.update(1);
-      } catch (ExecutionException | InterruptedException e) {
-        fail(e);
-      } catch (RuntimeException e) {
-        failAndThrow(e);
-      } catch (Error e) {
-        // Can't join with RuntimeException because "RuntimeException
-        // | Error" becomes Throwable, which messes with signatures.
-        failAndThrow(e);
-      }
-    }
-
-    private void fail(Throwable t) {
-      log.error("Failed to rebuild change " + changeId, t);
-      ok.set(false);
-      failedTask.update(1);
-    }
-
-    private void failAndThrow(RuntimeException e) {
-      fail(e);
-      throw e;
-    }
-
-    private void failAndThrow(Error e) {
-      fail(e);
-      throw e;
-    }
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 00914d2..2e7d88a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -14,59 +14,70 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
-import com.google.common.collect.Lists;
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.pgm.util.ThreadLimiter;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.ScanningChangeCacheImpl;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.ChangeSchemas;
-import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.SiteIndexer;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
 
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.util.io.NullOutputStream;
 import org.kohsuke.args4j.Option;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
 
 public class Reindex extends SiteProgram {
   @Option(name = "--threads", usage = "Number of threads to use for indexing")
   private int threads = Runtime.getRuntime().availableProcessors();
 
-  @Option(name = "--schema-version",
-      usage = "Schema version to reindex; default is most recent version")
-  private Integer version;
-
-  @Option(name = "--output", usage = "Prefix for output; path for local disk index, or prefix for remote index")
-  private String outputBase;
+  @Option(name = "--changes-schema-version",
+      usage = "Schema version to reindex, for changes; default is most recent version")
+  private Integer changesVersion;
 
   @Option(name = "--verbose", usage = "Output debug information for each change")
   private boolean verbose;
 
+  @Option(name = "--list", usage = "List supported indices and exit")
+  private boolean list;
+
+  @Option(name = "--index", usage = "Only reindex specified indices")
+  private List<String> indices = new ArrayList<>();
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Config globalConfig;
-  private ChangeIndex index;
+
+  @Inject
+  private Collection<IndexDefinition<?, ?, ?>> indexDefs;
 
   @Override
   public int run() throws Exception {
@@ -78,9 +89,6 @@
     checkNotSlaveMode();
     disableLuceneAutomaticCommit();
     disableChangeCache();
-    if (version == null) {
-      version = ChangeSchemas.getLatest().getVersion();
-    }
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
     dbManager.start();
@@ -89,20 +97,58 @@
     LifecycleManager sysManager = new LifecycleManager();
     sysManager.add(sysInjector);
     sysManager.start();
+    sysInjector.injectMembers(this);
+    checkIndicesOption();
 
-    index = sysInjector.getInstance(IndexCollection.class).getSearchIndex();
-    int result = 0;
     try {
-      index.markReady(false);
-      index.deleteAll();
-      result = indexAll();
-      index.markReady(true);
+      boolean ok = list ? list() : reindex();
+      return ok ? 0 : 1;
     } catch (Exception e) {
       throw die(e.getMessage(), e);
+    } finally {
+      sysManager.stop();
+      dbManager.stop();
     }
-    sysManager.stop();
-    dbManager.stop();
-    return result;
+  }
+
+  private boolean list() {
+    for (IndexDefinition<?, ?, ?> def : indexDefs) {
+      System.out.format("%s\n", def.getName());
+    }
+    return true;
+  }
+
+  private boolean reindex() throws IOException {
+    boolean ok = true;
+    for (IndexDefinition<?, ?, ?> def : indexDefs) {
+      if (indices.isEmpty() || indices.contains(def.getName())) {
+        ok &= reindex(def);
+      }
+    }
+    return ok;
+  }
+
+  private void checkIndicesOption() throws Die {
+    if (indices.isEmpty()) {
+      return;
+    }
+
+    checkNotNull(indexDefs, "Called this method before injectMembers?");
+    Set<String> valid = FluentIterable.from(indexDefs).transform(
+        new Function<IndexDefinition<?, ?, ?>, String>() {
+          @Override
+          public String apply(IndexDefinition<?, ?, ?> input) {
+            return input.getName();
+          }
+        }).toSortedSet(Ordering.natural());
+
+    Set<String> invalid = Sets.difference(Sets.newHashSet(indices), valid);
+    if (invalid.isEmpty()) {
+      return;
+    }
+
+    throw die("invalid index name(s): " + new TreeSet<>(invalid)
+        + " available indices are: " + valid);
   }
 
   private void checkNotSlaveMode() throws Die {
@@ -112,20 +158,28 @@
   }
 
   private Injector createSysInjector() {
-    List<Module> modules = Lists.newArrayList();
-    Module changeIndexModule;
+    Map<String, Integer> versions = new HashMap<>();
+    if (changesVersion != null) {
+      versions.put(ChangeSchemaDefinitions.INSTANCE.getName(), changesVersion);
+    }
+    List<Module> modules = new ArrayList<>();
+    Module indexModule;
     switch (IndexModule.getIndexType(dbInjector)) {
       case LUCENE:
-        changeIndexModule = new LuceneIndexModule(version, threads, outputBase);
+        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(
+            versions, threads);
         break;
       default:
         throw new IllegalStateException("unsupported index.type");
     }
-    modules.add(changeIndexModule);
-    // Scan changes from git instead of relying on the secondary index, as we
-    // will have just deleted the old (possibly corrupt) index.
-    modules.add(ScanningChangeCacheImpl.module());
+    modules.add(indexModule);
     modules.add(dbInjector.getInstance(BatchProgramModule.class));
+    modules.add(new FactoryModule() {
+      @Override
+      protected void configure() {
+        factory(ChangeResource.Factory.class);
+      }
+    });
 
     return dbInjector.createChildInjector(modules);
   }
@@ -141,31 +195,25 @@
     globalConfig.setLong("cache", "changes", "maximumWeight", 0);
   }
 
-  private int indexAll() throws Exception {
-    ProgressMonitor pm = new TextProgressMonitor();
-    pm.start(1);
-    pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    Set<Project.NameKey> projects = Sets.newTreeSet();
-    int changeCount = 0;
-    try (ReviewDb db = sysInjector.getInstance(ReviewDb.class)) {
-      for (Change change : db.changes().all()) {
-        changeCount++;
-        if (projects.add(change.getProject())) {
-          pm.update(1);
-        }
-      }
-    }
-    pm.endTask();
+  private <K, V, I extends Index<K, V>> boolean reindex(
+      IndexDefinition<K, V, I> def) throws IOException {
+    I index = def.getIndexCollection().getSearchIndex();
+    checkNotNull(index,
+        "no active search index configured for %s", def.getName());
+    index.markReady(false);
+    index.deleteAll();
 
-    SiteIndexer batchIndexer =
-        sysInjector.getInstance(SiteIndexer.class);
-    SiteIndexer.Result result = batchIndexer.setNumChanges(changeCount)
-        .setProgressOut(System.err)
-        .setVerboseOut(verbose ? System.out : NullOutputStream.INSTANCE)
-        .indexAll(index, projects);
+    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer();
+    siteIndexer.setProgressOut(System.err);
+    siteIndexer.setVerboseOut(verbose ? System.out : NullOutputStream.INSTANCE);
+    SiteIndexer.Result result = siteIndexer.indexAll(index);
     int n = result.doneCount() + result.failedCount();
     double t = result.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    System.out.format("Reindexed %d changes in %.01fs (%.01f/s)\n", n, t, n/t);
-    return result.success() ? 0 : 1;
+    System.out.format("Reindexed %d documents in %s index in %.01fs (%.01f/s)\n",
+        n, def.getName(), t, n / t);
+    if (result.success()) {
+      index.markReady(true);
+    }
+    return result.success();
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SetPasswd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SetPasswd.java
new file mode 100644
index 0000000..c6ece21
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SetPasswd.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.init.api.Section.Factory;
+import com.google.inject.Inject;
+
+public class SetPasswd {
+
+  private ConsoleUI ui;
+  private Factory sections;
+
+  @Inject
+  public SetPasswd(ConsoleUI ui, Section.Factory sections) {
+    this.ui = ui;
+    this.sections = sections;
+  }
+
+  public void run(String section, String key, String password) throws Exception {
+    Section passwordSection = sections.get(section, null);
+
+    if (ui.isBatch()) {
+      passwordSection.setSecure(key, password);
+    } else {
+      ui.header("Set password for [%s]", section);
+      passwordSection.passwordForKey("Enter password", key);
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
index 1b663ae..0c2ec78 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
@@ -64,10 +64,15 @@
   }
 
   private static byte[] message(HttpConnection conn) {
-    String msg = conn.getHttpChannel().getResponse().getReason();
-    if (msg == null) {
-      msg = HttpStatus.getMessage(conn.getHttpChannel()
-          .getResponse().getStatus());
+    String msg;
+    if (conn == null) {
+      msg = "";
+    } else {
+      msg = conn.getHttpChannel().getResponse().getReason();
+      if (msg == null) {
+        msg = HttpStatus.getMessage(conn.getHttpChannel()
+            .getResponse().getStatus());
+      }
     }
     return msg.getBytes(ISO_8859_1);
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 0684650..b065436 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
@@ -127,11 +128,14 @@
   private boolean reverseProxy;
 
   @Inject
-  JettyServer(@GerritServerConfig final Config cfg, final SitePaths site,
-      final JettyEnv env, final HttpLogFactory httpLogFactory) {
+  JettyServer(@GerritServerConfig Config cfg,
+      ThreadSettingsConfig threadSettingsConfig,
+      SitePaths site,
+      JettyEnv env,
+      HttpLogFactory httpLogFactory) {
     this.site = site;
 
-    httpd = new Server(threadPool(cfg));
+    httpd = new Server(threadPool(cfg, threadSettingsConfig));
     httpd.setConnectors(listen(httpd, cfg));
 
     Handler app = makeContext(env, cfg);
@@ -259,8 +263,10 @@
       } catch (URISyntaxException e) {
         throw new IllegalArgumentException("Invalid httpd.listenurl " + u, e);
       }
-
+      c.setInheritChannel(cfg.getBoolean("httpd", "inheritChannel", false));
       c.setReuseAddress(reuseAddress);
+      c.setIdleTimeout(
+          cfg.getTimeUnit("httpd", null, "idleTimeout", 30000L, MILLISECONDS));
       connectors[idx] = c;
     }
     return connectors;
@@ -315,8 +321,8 @@
     return site.resolve(path);
   }
 
-  private ThreadPool threadPool(Config cfg) {
-    int maxThreads = cfg.getInt("httpd", null, "maxthreads", 25);
+  private ThreadPool threadPool(Config cfg, ThreadSettingsConfig threadSettingsConfig) {
+    int maxThreads = threadSettingsConfig.getHttpdMaxThreads();
     int minThreads = cfg.getInt("httpd", null, "minthreads", 5);
     int maxQueued = cfg.getInt("httpd", null, "maxqueued", 200);
     int idleTimeout = (int)MILLISECONDS.convert(60, SECONDS);
@@ -359,15 +365,14 @@
       // without any wrapping so Jetty has less work to do per-request.
       //
       return all.get(0);
-    } else {
-      // We have more than one path served out of this container so
-      // combine them in a handler which supports dispatching to the
-      // individual contexts.
-      //
-      final ContextHandlerCollection r = new ContextHandlerCollection();
-      r.setHandlers(all.toArray(new Handler[0]));
-      return r;
     }
+    // We have more than one path served out of this container so
+    // combine them in a handler which supports dispatching to the
+    // individual contexts.
+    //
+    final ContextHandlerCollection r = new ContextHandlerCollection();
+    r.setHandlers(all.toArray(new Handler[0]));
+    return r;
   }
 
   private ContextHandler makeContext(final String contextPath,
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 0f75a09..ccb4192 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -239,9 +239,9 @@
         String path = m.group(1);
         String cmd = m.group(2);
         return cmd + " " + path + userName;
-      } else {
-        return req.getMethod() + " " + uri + userName;
       }
+
+      return req.getMethod() + " " + uri + userName;
     }
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index b200ed5..f625f75 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -14,18 +14,22 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.SINGLE_USER;
 import static com.google.inject.Scopes.SINGLETON;
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InstallAllPlugins;
 import com.google.gerrit.pgm.init.api.InstallPlugins;
+import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfigModule;
@@ -117,6 +121,8 @@
     init.flags.autoStart = getAutoStart() && init.site.isNew;
     init.flags.dev = isDev() && init.site.isNew;
     init.flags.skipPlugins = skipPlugins();
+    init.flags.deleteCaches = getDeleteCaches();
+
 
     final SiteRun run;
     try {
@@ -147,6 +153,14 @@
     return null;
   }
 
+  protected boolean skipAllDownloads() {
+    return false;
+  }
+
+  protected List<String> getSkippedDownloads() {
+    return Collections.emptyList();
+  }
+
   /**
    * Invoked before site init is called.
    *
@@ -188,6 +202,10 @@
     }
   }
 
+  protected boolean installAllPlugins() {
+    return false;
+  }
+
   protected boolean getAutoStart() {
     return false;
   }
@@ -233,9 +251,11 @@
         bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
         List<String> plugins =
             MoreObjects.firstNonNull(
-                getInstallPlugins(), Lists.<String> newArrayList());
+                getInstallPlugins(), new ArrayList<String>());
         bind(new TypeLiteral<List<String>>() {}).annotatedWith(
             InstallPlugins.class).toInstance(plugins);
+        bind(new TypeLiteral<Boolean>() {}).annotatedWith(
+            InstallAllPlugins.class).toInstance(installAllPlugins());
         bind(PluginsDistribution.class).toInstance(pluginsDistribution);
 
         String secureStoreClassName;
@@ -252,6 +272,12 @@
         bind(String.class).annotatedWith(SecureStoreClassName.class)
             .toProvider(Providers.of(secureStoreClassName));
         bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
+        bind(new TypeLiteral<List<String>>() {}).annotatedWith(
+            LibraryDownload.class).toInstance(getSkippedDownloads());
+        bind(Boolean.class).annotatedWith(
+            LibraryDownload.class).toInstance(skipAllDownloads());
+
+        bind(MetricMaker.class).to(DisabledMetricMaker.class);
       }
     });
 
@@ -380,7 +406,7 @@
           System.err.flush();
 
         } else if (ui.yesno(true, "%s\nExecute now", msg)) {
-          try (JdbcSchema db = (JdbcSchema) schema.open();
+          try (JdbcSchema db = (JdbcSchema) unwrapDb(schema.open());
               JdbcExecutor e = new JdbcExecutor(db)) {
             for (String sql : pruneList) {
               e.execute(sql);
@@ -451,4 +477,8 @@
   protected boolean isDev() {
     return false;
   }
+
+  protected boolean getDeleteCaches() {
+    return false;
+  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
index c30edf8..bc9ce8c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
@@ -23,5 +23,5 @@
    * Performs database platform specific configuration steps and writes
    * configuration parameters into the given database section
    */
-  public void initConfig(Section databaseSection);
+  void initConfig(Section databaseSection);
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
index a11f56f..9dda276 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
@@ -45,5 +45,7 @@
         Names.named("postgresql")).to(PostgreSQLInitializer.class);
     bind(DatabaseConfigInitializer.class).annotatedWith(
         Names.named("maxdb")).to(MaxDbInitializer.class);
+    bind(DatabaseConfigInitializer.class).annotatedWith(
+        Names.named("hana")).to(HANAInitializer.class);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
new file mode 100644
index 0000000..fa0acbd
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.gerrit.pgm.init.api.InitUtil.username;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.pgm.init.api.InitUtil;
+import com.google.gerrit.pgm.init.api.Section;
+
+public class HANAInitializer implements DatabaseConfigInitializer {
+
+  @Override
+  public void initConfig(Section databaseSection) {
+    final String defInstanceNumber = "00";
+    databaseSection.string("Server hostname", "hostname", "localhost");
+    databaseSection.string("Instance number", "instance", defInstanceNumber,
+        false);
+    String instance = databaseSection.get("instance");
+    Integer instanceNumber = Ints.tryParse(instance);
+    if (instanceNumber == null || instanceNumber < 0 || instanceNumber > 99) {
+      instanceIsInvalid();
+    }
+    databaseSection.string("Database username", "username", username());
+    databaseSection.password("username", "password");
+  }
+
+  private void instanceIsInvalid() {
+    throw InitUtil.die("database.instance must be in the range of 00 to 99");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 40a07b4..2de71cc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -43,14 +43,17 @@
 public class InitAdminUser implements InitStep {
   private final ConsoleUI ui;
   private final InitFlags flags;
+  private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private SchemaFactory<ReviewDb> dbFactory;
 
   @Inject
   InitAdminUser(
       InitFlags flags,
-      ConsoleUI ui) {
+      ConsoleUI ui,
+      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory) {
     this.flags = flags;
     this.ui = ui;
+    this.authorizedKeysFactory = authorizedKeysFactory;
   }
 
   @Override
@@ -110,7 +113,10 @@
           db.accountGroupMembers().insert(Collections.singleton(m));
 
           if (sshKey != null) {
-            db.accountSshKeys().insert(Collections.singleton(sshKey));
+            VersionedAuthorizedKeysOnInit authorizedKeys =
+                authorizedKeysFactory.create(id).load();
+            authorizedKeys.addKey(sshKey.getSshPublicKey());
+            authorizedKeys.save("Added SSH key for initial admin user\n");
           }
         }
       }
@@ -158,6 +164,6 @@
           "Cannot add public SSH key: %s is not a file", keyFile));
     }
     String content = new String(Files.readAllBytes(p), UTF_8);
-    return new AccountSshKey(new AccountSshKey.Id(id, 0), content);
+    return new AccountSshKey(new AccountSshKey.Id(id, 1), content);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
index 4e5d044..33dc204 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
@@ -15,28 +15,41 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 
 /** Initialize the {@code cache} configuration section. */
 @Singleton
 class InitCache implements InitStep {
+  private final ConsoleUI ui;
+  private final InitFlags flags;
   private final SitePaths site;
   private final Section cache;
 
   @Inject
-  InitCache(final SitePaths site, final Section.Factory sections) {
+  InitCache(final ConsoleUI ui, final InitFlags flags,
+      final SitePaths site, final Section.Factory sections) {
+    this.ui = ui;
+    this.flags = flags;
     this.site = site;
     this.cache = sections.get("cache", null);
   }
 
   @Override
   public void run() {
+    ui.header("Cache");
     String path = cache.get("directory");
 
     if (path != null && path.isEmpty()) {
@@ -53,6 +66,28 @@
 
     Path loc = site.resolve(path);
     FileUtil.mkdirsOrDie(loc, "cannot create cache.directory");
+    List<Path> cacheFiles = new ArrayList<>();
+    try (DirectoryStream<Path> stream =
+        Files.newDirectoryStream(loc, "*.{lock,h2,trace}.db")) {
+      for (Path entry : stream) {
+        cacheFiles.add(entry);
+      }
+    } catch (IOException e) {
+      ui.message("IO error during cache directory scan");
+      return;
+    }
+    if (!cacheFiles.isEmpty()) {
+      for (Path entry : cacheFiles) {
+        if (flags.deleteCaches ||
+            ui.yesno(false, "Delete cache file %s", entry)) {
+          try {
+            Files.deleteIfExists(entry);
+          } catch (IOException e) {
+            ui.message("Could not delete " + entry);
+          }
+        }
+      }
+    }
   }
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
index 60ff665..36754a1 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
@@ -27,7 +27,6 @@
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.util.FS;
 
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -96,7 +95,7 @@
         try (InputStream in = Files.newInputStream(myWar)) {
           Files.createDirectories(siteWar.getParent());
 
-          LockFile lf = new LockFile(siteWar.toFile(), FS.DETECTED);
+          LockFile lf = new LockFile(siteWar.toFile());
           if (!lf.lock()) {
             throw new IOException("Cannot lock " + siteWar);
           }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
index abea521..7e4d3c1 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Binding;
 import com.google.inject.Guice;
@@ -43,6 +44,7 @@
   private final SitePaths site;
   private final Libraries libraries;
   private final Section database;
+  private final Section idSection;
 
   @Inject
   InitDatabase(final ConsoleUI ui, final SitePaths site, final Libraries libraries,
@@ -51,6 +53,7 @@
     this.site = site;
     this.libraries = libraries;
     this.database = sections.get("database", null);
+    this.idSection = sections.get(GerritServerIdProvider.SECTION, null);
   }
 
   @Override
@@ -86,9 +89,18 @@
       libraries.oracleDriver.downloadRequired();
     } else if (dci instanceof DB2Initializer) {
       libraries.db2Driver.downloadRequired();
+    } else if (dci instanceof HANAInitializer) {
+      libraries.hanaDriver.downloadRequired();
     }
 
     dci.initConfig(database);
+
+    // Initialize UUID for NoteDb on first init.
+    String id = idSection.get(GerritServerIdProvider.KEY);
+    if (Strings.isNullOrEmpty(id)) {
+      idSection.set(
+          GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
+    }
   }
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index a177fe7..018211b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -14,18 +14,23 @@
 
 package com.google.gerrit.pgm.init;
 
-import com.google.gerrit.lucene.LuceneChangeIndex;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.lucene.AbstractLuceneIndex;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.ChangeSchemas;
+import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.SchemaDefinitions;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
 
 /** Initialize the {@code index} configuration section. */
 @Singleton
@@ -34,6 +39,7 @@
   private final Section index;
   private final SitePaths site;
   private final InitFlags initFlags;
+  private final Section gerrit;
 
   @Inject
   InitIndex(ConsoleUI ui,
@@ -42,20 +48,29 @@
       InitFlags initFlags) {
     this.ui = ui;
     this.index = sections.get("index", null);
+    this.gerrit = sections.get("gerrit", null);
     this.site = site;
     this.initFlags = initFlags;
   }
 
   @Override
   public void run() throws IOException {
-    ui.header("Index");
+    IndexType type = IndexType.LUCENE;
+    if (IndexType.values().length > 1) {
+      ui.header("Index");
+      type = index.select("Type", "type", type);
+    }
 
-    IndexType type = index.select("Type", "type", IndexType.LUCENE);
-    if (site.isNew && type == IndexType.LUCENE) {
-      LuceneChangeIndex.setReady(
-          site, ChangeSchemas.getLatest().getVersion(), true);
+    if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
+      for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
+        AbstractLuceneIndex.setReady(
+            site, def.getName(), def.getLatest().getVersion(), true);
+      }
     } else {
-      final String message = String.format(
+      if (IndexType.values().length <= 1) {
+        ui.header("Index");
+      }
+      String message = String.format(
         "\nThe index must be %sbuilt before starting Gerrit:\n"
         + "  java -jar gerrit.war reindex -d site_path\n",
         site.isNew ? "" : "re");
@@ -64,6 +79,15 @@
     }
   }
 
+  private boolean isEmptySite() {
+    try (DirectoryStream<Path> files =
+        Files.newDirectoryStream(site.resolve(gerrit.get("basePath")))) {
+      return Iterables.isEmpty(files);
+    } catch (IOException e) {
+      return true;
+    }
+  }
+
   @Override
   public void postRun() throws Exception {
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index 57a1a30..b5aa625 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -42,6 +42,7 @@
     bind(Libraries.class);
     bind(LibraryDownloader.class);
     factory(Section.Factory.class);
+    factory(VersionedAuthorizedKeysOnInit.Factory.class);
 
     // Steps are executed in the order listed here.
     //
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index 3476de5..ee20d99 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.Ordering;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -30,12 +30,9 @@
 import java.io.IOException;
 import java.net.URL;
 import java.net.URLClassLoader;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -116,23 +113,12 @@
   }
 
   private List<Path> scanJarsInPluginsDirectory() {
-    if (pluginsDir == null || !Files.isDirectory(pluginsDir)) {
-      return Collections.emptyList();
-    }
-    DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
-      @Override
-      public boolean accept(Path entry) throws IOException {
-        return entry.getFileName().toString().endsWith(".jar")
-            && Files.isRegularFile(entry);
-      }
-    };
-    try (DirectoryStream<Path> paths =
-        Files.newDirectoryStream(pluginsDir, filter)) {
-      return Ordering.natural().sortedCopy(paths);
+    try {
+      return PluginLoader.listPlugins(pluginsDir, ".jar");
     } catch (IOException e) {
       ui.message("WARN: Cannot list %s: %s", pluginsDir.toAbsolutePath(),
           e.getMessage());
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
index 6a3d7cb..43d7d3b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init;
 
-import com.google.common.collect.Lists;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.common.PluginData;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -29,7 +29,9 @@
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -53,7 +55,7 @@
   private static List<PluginData> listPlugins(final SitePaths site,
       final boolean deleteTempPluginFile, PluginsDistribution pluginsDistribution)
           throws IOException {
-    final List<PluginData> result = Lists.newArrayList();
+    final List<PluginData> result = new ArrayList<>();
     pluginsDistribution.foreach(new PluginsDistribution.Processor() {
       @Override
       public void process(String pluginName, InputStream in) throws IOException {
@@ -65,7 +67,12 @@
         result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
       }
     });
-    return result;
+    return FluentIterable.from(result).toSortedList(new Comparator<PluginData>() {
+        @Override
+        public int compare(PluginData a, PluginData b) {
+          return a.name.compareTo(b.name);
+        }
+      });
   }
 
   private final ConsoleUI ui;
@@ -115,17 +122,18 @@
         Path p = site.plugins_dir.resolve(plugin.name + ".jar");
         boolean upgrade = Files.exists(p);
 
-        if (!(initFlags.installPlugins.contains(pluginName) || ui.yesno(upgrade,
-            "Install plugin %s version %s", pluginName, plugin.version))) {
+        if (!(initFlags.installPlugins.contains(pluginName)
+            || initFlags.installAllPlugins
+            || ui.yesno(upgrade, "Install plugin %s version %s", pluginName,
+                plugin.version))) {
           Files.deleteIfExists(tmpPlugin);
           continue;
         }
 
         if (upgrade) {
           final String installedPluginVersion = getVersion(p);
-          if (!ui.yesno(upgrade,
-              "version %s is already installed, overwrite it",
-              installedPluginVersion)) {
+          if (!ui.yesno(upgrade, "%s %s is already installed, overwrite it",
+              plugin.name, installedPluginVersion)) {
             Files.deleteIfExists(tmpPlugin);
             continue;
           }
@@ -138,6 +146,12 @@
         }
         try {
           Files.move(tmpPlugin, p);
+          if (upgrade) {
+            // or update that is not an upgrade
+            ui.message("Updated %s to %s\n", plugin.name, plugin.version);
+          } else {
+            ui.message("Installed %s %s\n", plugin.name, plugin.version);
+          }
         } catch (IOException e) {
           throw new IOException("Failed to install plugin " + pluginName
               + ": " + tmpPlugin.toAbsolutePath() + " -> "
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
index c654c8d..cb4439a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -105,22 +105,22 @@
 
         System.err.print(" rsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen", //
-            "-q" /* quiet */, //
-            "-t", "rsa", //
-            "-P", "", //
-            "-C", comment, //
-            "-f", site.ssh_rsa.toAbsolutePath().toString() //
+        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
+            "-q" /* quiet */,
+            "-t", "rsa",
+            "-P", "",
+            "-C", comment,
+            "-f", site.ssh_rsa.toAbsolutePath().toString(),
             }).waitFor();
 
         System.err.print(" dsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen", //
-            "-q" /* quiet */, //
-            "-t", "dsa", //
-            "-P", "", //
-            "-C", comment, //
-            "-f", site.ssh_dsa.toAbsolutePath().toString() //
+        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
+            "-q" /* quiet */,
+            "-t", "dsa",
+            "-P", "",
+            "-C", comment,
+            "-f", site.ssh_dsa.toAbsolutePath().toString(),
             }).waitFor();
 
       } else {
@@ -144,7 +144,7 @@
         System.err.print(" rsa(simple)...");
         System.err.flush();
         p = new SimpleGeneratorHostKeyProvider();
-        p.setPath(tmpkey.toAbsolutePath().toString());
+        p.setPath(tmpkey.toAbsolutePath());
         p.setAlgorithm("RSA");
         p.loadKeys(); // forces the key to generate.
         chmod(0600, tmpkey);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
index 7cc8f10..b4c672a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -30,6 +31,7 @@
 import java.io.Reader;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
+import java.util.List;
 
 /** Standard {@link LibraryDownloader} instances derived from configuration. */
 @Singleton
@@ -38,19 +40,25 @@
       "com/google/gerrit/pgm/init/libraries.config";
 
   private final Provider<LibraryDownloader> downloadProvider;
+  private final List<String> skippedDownloads;
+  private final boolean skipAllDownloads;
 
   /* final */LibraryDownloader bouncyCastlePGP;
   /* final */LibraryDownloader bouncyCastleProvider;
   /* final */LibraryDownloader bouncyCastleSSL;
   /* final */LibraryDownloader db2Driver;
   /* final */LibraryDownloader db2DriverLicense;
+  /* final */LibraryDownloader hanaDriver;
   /* final */LibraryDownloader mysqlDriver;
   /* final */LibraryDownloader oracleDriver;
 
   @Inject
-  Libraries(final Provider<LibraryDownloader> downloadProvider) {
+  Libraries(final Provider<LibraryDownloader> downloadProvider,
+      @LibraryDownload List<String> skippedDownloads,
+      @LibraryDownload Boolean skipAllDownloads) {
     this.downloadProvider = downloadProvider;
-
+    this.skippedDownloads = skippedDownloads;
+    this.skipAllDownloads = skipAllDownloads;
     init();
   }
 
@@ -97,6 +105,7 @@
     for (String d : cfg.getStringList("library", n, "needs")) {
       dl.addNeeds((LibraryDownloader) getClass().getDeclaredField(d).get(this));
     }
+    dl.setSkipDownload(skipAllDownloads || skippedDownloads.contains(n));
   }
 
   private static String getOptional(Config cfg, String name, String key) {
@@ -107,7 +116,7 @@
     return doGet(cfg, name, key, true);
   }
 
-  private static final String doGet(Config cfg, String name, String key,
+  private static String doGet(Config cfg, String name, String key,
       boolean required) {
     String val = cfg.getString("library", name, key);
     if ((val == null || val.isEmpty()) && required) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
index e4cc305..653f2c5 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
@@ -58,6 +58,7 @@
   private Path dst;
   private boolean download; // download or copy
   private boolean exists;
+  private boolean skipDownload;
 
   @Inject
   LibraryDownloader(ConsoleUI ui, SitePaths site) {
@@ -87,6 +88,10 @@
     needs.add(lib);
   }
 
+  void setSkipDownload(boolean skipDownload) {
+    this.skipDownload = skipDownload;
+  }
+
   void downloadRequired() {
     setRequired(true);
     download();
@@ -105,6 +110,10 @@
   }
 
   private void download() {
+    if (skipDownload) {
+      return;
+    }
+
     if (jarUrl == null || !jarUrl.contains("/")) {
       throw new IllegalStateException("Invalid JarUrl for " + name);
     }
@@ -136,25 +145,23 @@
   private boolean shouldGet() {
     if (ui.isBatch()) {
       return required;
-
-    } else {
-      final StringBuilder msg = new StringBuilder();
-      msg.append("\n");
-      msg.append("Gerrit Code Review is not shipped with %s\n");
-      if (neededBy != null) {
-        msg.append(String.format(
-            "** This library is required by %s. **\n",
-            neededBy.name));
-      } else if (required) {
-        msg.append("**  This library is required for your configuration. **\n");
-      } else {
-        msg.append("  If available, Gerrit can take advantage of features\n");
-        msg.append("  in the library, but will also function without it.\n");
-      }
-      msg.append(String.format(
-          "%s and install it now", download ? "Download" : "Copy"));
-      return ui.yesno(true, msg.toString(), name);
     }
+    final StringBuilder msg = new StringBuilder();
+    msg.append("\n");
+    msg.append("Gerrit Code Review is not shipped with %s\n");
+    if (neededBy != null) {
+      msg.append(String.format(
+          "** This library is required by %s. **\n",
+          neededBy.name));
+    } else if (required) {
+      msg.append("**  This library is required for your configuration. **\n");
+    } else {
+      msg.append("  If available, Gerrit can take advantage of features\n");
+      msg.append("  in the library, but will also function without it.\n");
+    }
+    msg.append(String.format(
+        "%s and install it now", download ? "Download" : "Copy"));
+    return ui.yesno(true, msg.toString(), name);
   }
 
   private void doGet() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
index 6b7386d..68af83f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
@@ -33,7 +33,7 @@
      *         IOException caused by dealing with the InputStream back to the
      *         caller
      */
-    public void process(String pluginName, InputStream in) throws IOException;
+    void process(String pluginName, InputStream in) throws IOException;
   }
 
   /**
@@ -45,7 +45,7 @@
    * @throws IOException in case of any other IO error caused by reading the
    *         plugin input stream
    */
-  public void foreach(Processor processor) throws FileNotFoundException, IOException;
+  void foreach(Processor processor) throws FileNotFoundException, IOException;
 
   /**
    * List plugins included in the Gerrit distribution
@@ -53,5 +53,5 @@
    * @throws FileNotFoundException if the location of the plugins couldn't be
    *         determined
    */
-  public List<String> listPluginNames() throws FileNotFoundException;
+  List<String> listPluginNames() throws FileNotFoundException;
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
index ffb0017..65a66de 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PostgreSQLInitializer.java
@@ -29,4 +29,4 @@
     databaseSection.string("Database username", "username", username());
     databaseSection.password("username", "password");
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 6270a15..f16e2ec 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -105,9 +105,10 @@
     extractMailExample("ChangeSubject.vm");
     extractMailExample("Comment.vm");
     extractMailExample("CommentFooter.vm");
+    extractMailExample("DeleteReviewer.vm");
+    extractMailExample("DeleteVote.vm");
     extractMailExample("Footer.vm");
     extractMailExample("Merged.vm");
-    extractMailExample("MergeFail.vm");
     extractMailExample("NewChange.vm");
     extractMailExample("RegisterNewEmail.vm");
     extractMailExample("ReplacePatchSet.vm");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
index 3c91241..52f9096 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
@@ -283,9 +283,8 @@
         }
       }
       return dbprop;
-    } else {
-      return null;
     }
+    return null;
   }
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
new file mode 100644
index 0000000..6739ce0
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AuthorizedKeys;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+
+import java.io.IOException;
+import java.util.List;
+
+public class VersionedAuthorizedKeysOnInit extends VersionedMetaDataOnInit {
+  public interface Factory {
+    VersionedAuthorizedKeysOnInit create(Account.Id accountId);
+  }
+
+  private final Account.Id accountId;
+  private List<Optional<AccountSshKey>> keys;
+
+  @Inject
+  public VersionedAuthorizedKeysOnInit(
+      AllUsersNameOnInitProvider allUsers,
+      SitePaths site,
+      InitFlags flags,
+      @Assisted Account.Id accountId) {
+    super(flags, site, allUsers.get(), RefNames.refsUsers(accountId));
+    this.accountId = accountId;
+  }
+
+  @Override
+  public VersionedAuthorizedKeysOnInit load()
+      throws IOException, ConfigInvalidException {
+    super.load();
+    return this;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    keys = AuthorizedKeys.parse(accountId, readUTF8(AuthorizedKeys.FILE_NAME));
+  }
+
+  public AccountSshKey addKey(String pub) {
+    checkState(keys != null, "SSH keys not loaded yet");
+    int seq = keys.isEmpty() ? 1 : keys.size() + 1;
+    AccountSshKey.Id keyId = new AccountSshKey.Id(accountId, seq);
+    AccountSshKey key =
+        new VersionedAuthorizedKeys.SimpleSshKeyCreator().create(keyId, pub);
+    keys.add(Optional.of(key));
+    return key;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Updated SSH keys\n");
+    }
+
+    saveUTF8(AuthorizedKeys.FILE_NAME, AuthorizedKeys.serialize(keys));
+    return true;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index aea438c..a7ebd33 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -18,73 +18,32 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GroupList;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
 import java.io.IOException;
-import java.nio.file.Path;
 
-public class AllProjectsConfig extends VersionedMetaData {
+public class AllProjectsConfig extends VersionedMetaDataOnInit {
 
   private static final Logger log = LoggerFactory.getLogger(AllProjectsConfig.class);
 
-  private final String project;
-  private final SitePaths site;
-  private final InitFlags flags;
-
   private Config cfg;
-  private ObjectId revision;
   private GroupList groupList;
 
   @Inject
   AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site,
       InitFlags flags) {
-    this.project = allProjects.get();
-    this.site = site;
-    this.flags = flags;
+    super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
 
   }
 
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_CONFIG;
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    if (basePath == null) {
-      throw new IllegalStateException("gerrit.basePath must be configured");
-    }
-    return FileKey.resolve(basePath.resolve(project).toFile(), FS.DETECTED);
-  }
-
-  public AllProjectsConfig load() throws IOException, ConfigInvalidException {
-    File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path)) {
-        load(repo);
-      }
-    }
-    return this;
-  }
-
   public Config getConfig() {
     return cfg;
   }
@@ -94,10 +53,16 @@
   }
 
   @Override
+  public AllProjectsConfig load()
+      throws IOException, ConfigInvalidException {
+    super.load();
+    return this;
+  }
+
+  @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     groupList = readGroupList();
     cfg = readConfig(ProjectConfig.PROJECT_CONFIG);
-    revision = getRevision();
   }
 
   private GroupList readGroupList() throws IOException {
@@ -105,89 +70,31 @@
         GroupList.createLoggerSink(GroupList.FILE_NAME, log));
   }
 
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
-    throw new UnsupportedOperationException();
-  }
-
-  public void save(String message) throws IOException {
-    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
-  }
-
-  public void save(String pluginName, String message) throws IOException {
+  public void save(String pluginName, String message)
+      throws IOException, ConfigInvalidException {
     save(new PersonIdent(pluginName, pluginName + "@gerrit"),
         "Update from plugin " + pluginName + ": " + message);
   }
 
-  private void save(PersonIdent ident, String msg) throws IOException {
-    File path = getPath();
-    if (path == null) {
-      throw new IOException("All-Projects does not exist.");
-    }
-
-    try (Repository repo = new FileRepository(path)) {
-      inserter = repo.newObjectInserter();
-      reader = repo.newObjectReader();
-      try (RevWalk rw = new RevWalk(reader)) {
-        RevTree srcTree = revision != null ? rw.parseTree(revision) : null;
-        newTree = readTree(srcTree);
-        saveConfig(ProjectConfig.PROJECT_CONFIG, cfg);
-        saveGroupList();
-        ObjectId res = newTree.writeTree(inserter);
-        if (res.equals(srcTree)) {
-          // If there are no changes to the content, don't create the commit.
-          return;
-        }
-
-        CommitBuilder commit = new CommitBuilder();
-        commit.setAuthor(ident);
-        commit.setCommitter(ident);
-        commit.setMessage(msg);
-        commit.setTreeId(res);
-        if (revision != null) {
-          commit.addParentId(revision);
-        }
-        ObjectId newRevision = inserter.insert(commit);
-        updateRef(repo, ident, newRevision, "commit: " + msg);
-        revision = newRevision;
-      } finally {
-        if (inserter != null) {
-          inserter.close();
-          inserter = null;
-        }
-        if (reader != null) {
-          reader.close();
-          reader = null;
-        }
-      }
-    }
+  @Override
+  protected void save(PersonIdent ident, String msg)
+      throws IOException, ConfigInvalidException {
+    super.save(ident, msg);
 
     // we need to invalidate the JGit cache if the group list is invalidated in
     // an unattended init step
     RepositoryCache.clear();
   }
 
-  private void saveGroupList() throws IOException {
-    saveUTF8(GroupList.FILE_NAME, groupList.asText());
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException,
+      ConfigInvalidException {
+    saveConfig(ProjectConfig.PROJECT_CONFIG, cfg);
+    saveGroupList();
+    return true;
   }
 
-  private void updateRef(Repository repo, PersonIdent ident,
-      ObjectId newRevision, String refLogMsg) throws IOException {
-    RefUpdate ru = repo.updateRef(getRefName());
-    ru.setRefLogIdent(ident);
-    ru.setNewObjectId(newRevision);
-    ru.setExpectedOldObjectId(revision);
-    ru.setRefLogMessage(refLogMsg, false);
-    RefUpdate.Result r = ru.update();
-    switch(r) {
-      case FAST_FORWARD:
-      case NEW:
-      case NO_CHANGE:
-        break;
-      default:
-        throw new IOException("Failed to update " + getRefName() + " of "
-            + project + ": " + r.name());
-    }
+  private void saveGroupList() throws IOException {
+    saveUTF8(GroupList.FILE_NAME, groupList.asText());
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
index bdd8b86..fa62d93 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
@@ -39,23 +39,28 @@
   /** Skip plugins */
   public boolean skipPlugins;
 
+  /** Delete all cache files */
+  public boolean deleteCaches;
+
   /** Dev mode */
   public boolean dev;
 
   public final FileBasedConfig cfg;
   public final SecureStore sec;
   public final List<String> installPlugins;
+  public final boolean installAllPlugins;
 
   @VisibleForTesting
   @Inject
   public InitFlags(final SitePaths site,
       final SecureStore secureStore,
-      @InstallPlugins final List<String> installPlugins) throws IOException,
-      ConfigInvalidException {
+      @InstallPlugins final List<String> installPlugins,
+      @InstallAllPlugins final Boolean installAllPlugins) throws IOException,
+          ConfigInvalidException {
     sec = secureStore;
     this.installPlugins = installPlugins;
+    this.installAllPlugins = installAllPlugins;
     cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
-
     cfg.load();
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
index 250cf59..fd28399 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
@@ -16,8 +16,8 @@
 
 /** A single step in the site initialization process. */
 public interface InitStep {
-  public void run() throws Exception;
+  void run() throws Exception;
 
   /** Executed after the site has been initialized */
-  public void postRun() throws Exception;
+  void postRun() throws Exception;
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
index 904af2f..1e1ddd7 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -21,7 +21,6 @@
 
 import org.eclipse.jgit.internal.storage.file.LockFile;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 
 import java.io.ByteArrayInputStream;
@@ -162,7 +161,7 @@
     }
 
     Files.createDirectories(dst.getParent());
-    LockFile lf = new LockFile(dst.toFile(), FS.DETECTED);
+    LockFile lf = new LockFile(dst.toFile());
     if (!lf.lock()) {
       throw new IOException("Cannot lock " + dst);
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
new file mode 100644
index 0000000..809a197
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@BindingAnnotation
+@Retention(RetentionPolicy.RUNTIME)
+public @interface InstallAllPlugins {
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
new file mode 100644
index 0000000..7e46b21
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@BindingAnnotation
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LibraryDownload {
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
index 52b0daa..8cda882 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
@@ -171,20 +171,20 @@
     return nv;
   }
 
-  public String passwordForKey(String key, String password) {
-    String ov = getSecure(password);
+  public String passwordForKey(String prompt, String passwordKey) {
+    String ov = getSecure(passwordKey);
     if (ov != null) {
       // If the password is already stored, try to reuse it
       // rather than prompting for a whole new one.
       //
-      if (ui.isBatch() || !ui.yesno(false, "Change %s", key)) {
+      if (ui.isBatch() || !ui.yesno(false, "Change %s", passwordKey)) {
         return ov;
       }
     }
 
-    final String nv = ui.password("%s", key);
+    final String nv = ui.password("%s", prompt);
     if (!eq(ov, nv)) {
-      setSecure(password, nv);
+      setSecure(passwordKey, nv);
     }
     return nv;
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
new file mode 100644
index 0000000..b953a0b
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.VersionedMetaData;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.FS;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+public abstract class VersionedMetaDataOnInit extends VersionedMetaData {
+
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String project;
+  private final String ref;
+
+  protected VersionedMetaDataOnInit(InitFlags flags, SitePaths site,
+      String project, String ref) {
+    this.flags = flags;
+    this.site = site;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  public VersionedMetaDataOnInit load()
+      throws IOException, ConfigInvalidException {
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path)) {
+        load(repo);
+      }
+    }
+    return this;
+  }
+
+  public void save(String message) throws IOException, ConfigInvalidException {
+    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
+  }
+
+  protected void save(PersonIdent ident, String msg)
+      throws IOException, ConfigInvalidException {
+    File path = getPath();
+    if (path == null) {
+      throw new IOException(project + " does not exist.");
+    }
+
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter i = repo.newObjectInserter();
+        ObjectReader r = repo.newObjectReader();
+        RevWalk rw = new RevWalk(r)) {
+      inserter = i;
+      reader = r;
+
+      RevTree srcTree = revision != null ? rw.parseTree(revision) : null;
+      newTree = readTree(srcTree);
+
+      CommitBuilder commit = new CommitBuilder();
+      commit.setAuthor(ident);
+      commit.setCommitter(ident);
+      commit.setMessage(msg);
+
+      onSave(commit);
+
+      ObjectId res = newTree.writeTree(inserter);
+      if (res.equals(srcTree)) {
+        return;
+      }
+      commit.setTreeId(res);
+
+      if (revision != null) {
+        commit.addParentId(revision);
+      }
+      ObjectId newRevision = inserter.insert(commit);
+      updateRef(repo, ident, newRevision, "commit: " + msg);
+      revision = rw.parseCommit(newRevision);
+    } finally {
+      inserter = null;
+      reader = null;
+    }
+  }
+
+  private void updateRef(Repository repo, PersonIdent ident,
+      ObjectId newRevision, String refLogMsg) throws IOException {
+    RefUpdate ru = repo.updateRef(getRefName());
+    ru.setRefLogIdent(ident);
+    ru.setNewObjectId(newRevision);
+    ru.setExpectedOldObjectId(revision);
+    ru.setRefLogMessage(refLogMsg, false);
+    RefUpdate.Result r = ru.update();
+    switch(r) {
+      case FAST_FORWARD:
+      case NEW:
+      case NO_CHANGE:
+        break;
+      case FORCED:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      default:
+        throw new IOException("Failed to update " + getRefName() + " of "
+            + project + ": " + r.name());
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(project).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 064cc19..8d3c766 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -66,7 +66,7 @@
     PrologCompiler create(Repository git);
   }
 
-  public static enum Status {
+  public enum Status {
     NO_RULES, COMPILED
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
index be573e6..0360cd6 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.pgm.util;
 
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.common.DisabledChangeHooks;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -27,7 +25,6 @@
 public class BatchGitModule extends FactoryModule {
   @Override
   protected void configure() {
-    bind(ChangeHooks.class).to(DisabledChangeHooks.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     factory(CommitValidators.Factory.class);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index b6539f1..f076e54 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -17,6 +17,9 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -26,6 +29,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
+import com.google.gerrit.server.account.AccountVisibility;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
+import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupCacheImpl;
@@ -33,18 +39,23 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DisableReverseDnsLookup;
 import com.google.gerrit.server.config.DisableReverseDnsLookupProvider;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.group.GroupModule;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
@@ -52,7 +63,6 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.CommentLinkInfo;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectControl;
@@ -64,6 +74,8 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 
+import org.eclipse.jgit.lib.Config;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
@@ -76,10 +88,13 @@
  * concurrently.
  */
 public class BatchProgramModule extends FactoryModule {
+  private final Config cfg;
   private final Module reviewDbModule;
 
   @Inject
-  BatchProgramModule(PerThreadReviewDbModule reviewDbModule) {
+  BatchProgramModule(@GerritServerConfig Config cfg,
+      PerThreadReviewDbModule reviewDbModule) {
+    this.cfg = cfg;
     this.reviewDbModule = reviewDbModule;
   }
 
@@ -88,6 +103,7 @@
   protected void configure() {
     install(reviewDbModule);
     install(new DiffExecutorModule());
+    install(new ReceiveCommitsExecutorModule());
     install(PatchListCacheImpl.module());
 
     // Plugins are not loaded and we're just running through each change
@@ -113,19 +129,27 @@
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
+    // As Reindex is a batch program, don't assume the index is available for
+    // the change cache.
+    bind(SearchingChangeCacheImpl.class).toProvider(
+        Providers.<SearchingChangeCacheImpl>of(null));
+
+    bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
+      .annotatedWith(AdministrateServerGroups.class)
+      .toInstance(ImmutableSet.<GroupReference> of());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
       .annotatedWith(GitUploadPackGroups.class)
       .toInstance(Collections.<AccountGroup.UUID> emptySet());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
       .annotatedWith(GitReceivePackGroups.class)
       .toInstance(Collections.<AccountGroup.UUID> emptySet());
-    factory(ChangeControl.AssistedFactory.class);
+    bind(ChangeControl.Factory.class);
     factory(ProjectControl.AssistedFactory.class);
 
     install(new BatchGitModule());
     install(new DefaultCacheFactory.Module());
     install(new GroupModule());
-    install(new NoteDbModule());
+    install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(AccountByEmailCacheImpl.module());
     install(AccountCacheImpl.module());
@@ -136,8 +160,15 @@
     install(ChangeKindCacheImpl.module());
     install(MergeabilityCacheImpl.module());
     install(TagCache.module());
+    factory(CapabilityCollection.Factory.class);
     factory(CapabilityControl.Factory.class);
     factory(ChangeData.Factory.class);
     factory(ProjectState.Factory.class);
+
+    bind(ChangeJson.Factory.class).toProvider(
+        Providers.<ChangeJson.Factory>of(null));
+    bind(AccountVisibility.class)
+        .toProvider(AccountVisibilityProvider.class)
+        .in(SINGLETON);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
index 1107208..262997b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -23,6 +24,8 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 
+import org.joda.time.DateTime;
+import org.joda.time.Duration;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -48,17 +51,25 @@
 
   static class Lifecycle implements LifecycleListener {
     private final WorkQueue queue;
-    private final LogFileCompressor compresser;
+    private final LogFileCompressor compressor;
 
     @Inject
-    Lifecycle(final WorkQueue queue, final LogFileCompressor compressor) {
+    Lifecycle(WorkQueue queue,
+        LogFileCompressor compressor) {
       this.queue = queue;
-      this.compresser = compressor;
+      this.compressor = compressor;
     }
 
     @Override
     public void start() {
-      queue.getDefaultQueue().scheduleAtFixedRate(compresser, 1, 24, HOURS);
+      //compress log once and then schedule compression every day at 11:00pm
+      queue.getDefaultQueue().execute(compressor);
+      DateTime now = DateTime.now();
+      long milliSecondsUntil11am =
+          new Duration(now, now.withTimeAtStartOfDay().plusHours(23))
+              .getMillis();
+      queue.getDefaultQueue().scheduleAtFixedRate(compressor,
+          milliSecondsUntil11am, HOURS.toMillis(24), MILLISECONDS);
     }
 
     @Override
@@ -69,7 +80,7 @@
   private final Path logs_dir;
 
   @Inject
-  LogFileCompressor(final SitePaths site) {
+  LogFileCompressor(SitePaths site) {
     logs_dir = resolve(site.logs_dir);
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
index eb12937..9ef31ff 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.pgm.util;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -24,6 +23,7 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
@@ -43,7 +43,7 @@
   @Override
   protected void configure() {
     final List<ReviewDb> dbs = Collections.synchronizedList(
-        Lists.<ReviewDb> newArrayList());
+        new ArrayList<ReviewDb>());
     final ThreadLocal<ReviewDb> localDb = new ThreadLocal<>();
 
     bind(ReviewDb.class).toProvider(new Provider<ReviewDb>() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index 40aaa75..dc3a915 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -63,11 +63,10 @@
           tasks.add(newTask);
           return true;
 
-        } else {
-          // We don't permit adding a task once shutdown has started.
-          //
-          return false;
         }
+        // We don't permit adding a task once shutdown has started.
+        //
+        return false;
       }
     }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SecureStoreException.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SecureStoreException.java
deleted file mode 100644
index 9693399..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SecureStoreException.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-public class SecureStoreException extends RuntimeException {
-  private static final long serialVersionUID = 5581700510568485065L;
-
-  SecureStoreException(String msg) {
-    super(msg);
-  }
-
-  SecureStoreException(String msg, Exception e) {
-    super(msg, e);
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
index 048c2ee..6443e21 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Inject;
@@ -37,9 +39,11 @@
   @Inject
   SiteLibraryBasedDataSourceProvider(SitePaths site,
       @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      ThreadSettingsConfig tsc,
       DataSourceProvider.Context ctx,
       DataSourceType dst) {
-    super(cfg, ctx, dst);
+    super(cfg, metrics, tsc, ctx, dst);
     libdir = site.lib_dir;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
index a6f1f93..9e2da5c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -18,14 +18,17 @@
 import static com.google.inject.Scopes.SINGLETON;
 import static com.google.inject.Stage.PRODUCTION;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.git.GitRepositoryManagerModule;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -41,6 +44,7 @@
 import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import com.google.inject.name.Names;
@@ -93,7 +97,13 @@
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(final DataSourceProvider.Context context) {
+  protected Injector createDbInjector(DataSourceProvider.Context context) {
+    return createDbInjector(false, context);
+  }
+
+  /** @return provides database connectivity and site path. */
+  protected Injector createDbInjector(final boolean enableMetrics,
+      final DataSourceProvider.Context context) {
     final Path sitePath = getSitePath();
     final List<Module> modules = new ArrayList<>();
 
@@ -107,6 +117,17 @@
     };
     modules.add(sitePathModule);
 
+    if (enableMetrics) {
+      modules.add(new DropWizardMetricMaker.ApiModule());
+    } else {
+      modules.add(new AbstractModule() {
+        @Override
+        protected void configure() {
+          bind(MetricMaker.class).to(DisabledMetricMaker.class);
+        }
+      });
+    }
+
     modules.add(new LifecycleModule() {
       @Override
       protected void configure() {
@@ -137,6 +158,10 @@
       dbType = cfg.getString("database", null, "type");
     }
 
+    if (dbType == null) {
+      throw new ProvisionException("database.type must be defined");
+    }
+
     final DataSourceType dst = Guice.createInjector(new DataSourceModule(), configModule,
             sitePathModule).getInstance(
             Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
@@ -149,7 +174,8 @@
     });
     modules.add(new DatabaseModule());
     modules.add(new SchemaModule());
-    modules.add(new LocalDiskRepositoryManager.Module());
+    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    modules.add(new ConfigNotesMigration.Module());
 
     try {
       return Guice.createInjector(PRODUCTION, modules);
@@ -197,7 +223,7 @@
       throw new RuntimeException(e);
     }
 
-    List<Module> modules = Lists.newArrayList();
+    List<Module> modules = new ArrayList<>();
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
index 44361aa..7b0e4da 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -24,7 +24,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-// TODO(dborowitz): Not necessary once we switch to notedb.
+// TODO(dborowitz): Not necessary once we switch to NoteDb.
 /** Utility to limit threads used by a batch program. */
 public class ThreadLimiter {
   private static final Logger log =
@@ -34,14 +34,15 @@
     return limitThreads(
         dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class)),
         dbInjector.getInstance(DataSourceType.class),
+        dbInjector.getInstance(ThreadSettingsConfig.class),
         threads);
   }
 
-  private static int limitThreads(Config cfg, DataSourceType dst, int threads) {
+  private static int limitThreads(Config cfg, DataSourceType dst,
+      ThreadSettingsConfig threadSettingsConfig, int threads) {
     boolean usePool = cfg.getBoolean("database", "connectionpool",
         dst.usePool());
-    int poolLimit = cfg.getInt("database", "poollimit",
-        DataSourceProvider.DEFAULT_POOL_LIMIT);
+    int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
     if (usePool && threads > poolLimit) {
       log.warn("Limiting program to " + poolLimit
           + " threads due to database.poolLimit");
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
index 7e6f943..5952880 100755
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -273,7 +273,7 @@
 # Add Gerrit properties to Java VM options.
 #####################################################
 
-GERRIT_OPTIONS=`get_config --get-all container.javaOptions`
+GERRIT_OPTIONS=`get_config --get-all container.javaOptions | tr '\n' ' '`
 if test -n "$GERRIT_OPTIONS" ; then
   JAVA_OPTIONS="$JAVA_OPTIONS $GERRIT_OPTIONS"
 fi
@@ -288,6 +288,9 @@
 GERRIT_FDS=`expr $GERRIT_FDS + $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
+GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
+test -z "$GERRIT_STARTUP_TIMEOUT" && GERRIT_STARTUP_TIMEOUT=90  # seconds
+
 GERRIT_USER=`get_config --get container.user`
 
 #####################################################
@@ -434,7 +437,7 @@
         fi
     fi
 
-    TIMEOUT=90  # seconds
+    TIMEOUT="$GERRIT_STARTUP_TIMEOUT"
     sleep 1
     while running "$GERRIT_PID" && test $TIMEOUT -gt 0 ; do
       if test "x$RUN_ID" = "x`cat $GERRIT_RUN 2>/dev/null`" ; then
@@ -498,6 +501,7 @@
     $GERRIT_SH stop $*
     sleep 5
     $GERRIT_SH start $*
+    exit $?
   ;;
 
   supervise)
@@ -525,17 +529,18 @@
 
   check|status)
     echo "Checking arguments to Gerrit Code Review:"
-    echo "  GERRIT_SITE     =  $GERRIT_SITE"
-    echo "  GERRIT_CONFIG   =  $GERRIT_CONFIG"
-    echo "  GERRIT_PID      =  $GERRIT_PID"
-    echo "  GERRIT_TMP      =  $GERRIT_TMP"
-    echo "  GERRIT_WAR      =  $GERRIT_WAR"
-    echo "  GERRIT_FDS      =  $GERRIT_FDS"
-    echo "  GERRIT_USER     =  $GERRIT_USER"
-    echo "  JAVA            =  $JAVA"
-    echo "  JAVA_OPTIONS    =  $JAVA_OPTIONS"
-    echo "  RUN_EXEC        =  $RUN_EXEC $RUN_Arg1 '$RUN_Arg2' $RUN_Arg3"
-    echo "  RUN_ARGS        =  $RUN_ARGS"
+    echo "  GERRIT_SITE            =  $GERRIT_SITE"
+    echo "  GERRIT_CONFIG          =  $GERRIT_CONFIG"
+    echo "  GERRIT_PID             =  $GERRIT_PID"
+    echo "  GERRIT_TMP             =  $GERRIT_TMP"
+    echo "  GERRIT_WAR             =  $GERRIT_WAR"
+    echo "  GERRIT_FDS             =  $GERRIT_FDS"
+    echo "  GERRIT_USER            =  $GERRIT_USER"
+    echo "  GERRIT_STARTUP_TIMEOUT =  $GERRIT_STARTUP_TIMEOUT"
+    echo "  JAVA                   =  $JAVA"
+    echo "  JAVA_OPTIONS           =  $JAVA_OPTIONS"
+    echo "  RUN_EXEC               =  $RUN_EXEC $RUN_Arg1 '$RUN_Arg2' $RUN_Arg3"
+    echo "  RUN_ARGS               =  $RUN_ARGS"
     echo
 
     if test -f "$GERRIT_PID" ; then
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index 6614b2b..4d9d0f0 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -16,14 +16,14 @@
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastleProvider"]
   name = Bouncy Castle Crypto Provider v152
-  url = http://repo2.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.52/bcprov-jdk15on-1.52.jar
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.52/bcprov-jdk15on-1.52.jar
   sha1 = 88a941faf9819d371e3174b5ed56a3f3f7d73269
   remove = bcprov-.*[.]jar
 
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastleSSL"]
   name = Bouncy Castle Crypto SSL v152
-  url = http://repo2.maven.org/maven2/org/bouncycastle/bcpkix-jdk15on/1.52/bcpkix-jdk15on-1.52.jar
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpkix-jdk15on/1.52/bcpkix-jdk15on-1.52.jar
   sha1 = b8ffac2bbc6626f86909589c8cc63637cc936504
   needs = bouncyCastleProvider
   remove = bcpkix-.*[.]jar
@@ -31,14 +31,14 @@
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastlePGP"]
   name = Bouncy Castle Crypto OpenPGP v152
-  url = http://repo2.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.52/bcpg-jdk15on-1.52.jar
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.52/bcpg-jdk15on-1.52.jar
   sha1 = ff4665a4b5633ff6894209d5dd10b7e612291858
   needs = bouncyCastleProvider
   remove = bcpg-.*[.]jar
 
 [library "mysqlDriver"]
   name = MySQL Connector/J 5.1.21
-  url = http://repo2.maven.org/maven2/mysql/mysql-connector-java/5.1.21/mysql-connector-java-5.1.21.jar
+  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.21/mysql-connector-java-5.1.21.jar
   sha1 = 7abbd19fc2e2d5b92c0895af8520f7fa30266be9
   remove = mysql-connector-java-.*[.]jar
 
@@ -61,3 +61,8 @@
   name = DB2 Type 4 JDBC driver license (10.5)
   url = file:///opt/ibm/db2/V10.5/java/db2jcc_license_cu.jar
   remove = db2jcc_license_cu.jar
+
+[library "hanaDriver"]
+  name = HANA JDBC driver
+  url = file:///usr/sap/hdbclient/ngdbc.jar
+  remove = ngdbc.jar
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.service b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.service
new file mode 100644
index 0000000..750dbb4
--- /dev/null
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.service
@@ -0,0 +1,17 @@
+# Systemd unit file for gerrit
+
+[Unit]
+Description=Gerrit Code Review
+After=syslog.target network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/gerritsrv/
+Environment=GERRIT_HOME=/opt/gerritsrv/gerrit/ JAVA_HOME=/opt/jdk1.8.0_45/
+ExecStart=/usr/bin/java -Xmx1024m -jar ${GERRIT_HOME}/bin/gerrit.war daemon -d ${GERRIT_HOME}
+User=gerritsrv
+SyslogIdentifier=GerritCodeReview
+StandardInput=socket
+
+[Install]
+WantedBy=multi-user.target
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.socket b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.socket
new file mode 100644
index 0000000..cee5d12
--- /dev/null
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.socket
@@ -0,0 +1,9 @@
+[Unit]
+Description=Gerrit HTTP socket
+
+[Socket]
+ListenStream=80
+Accept=no
+
+[Install]
+WantedBy=sockets.target
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
index 150309e..959ba69 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.pgm.init;
 
 import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
+import org.junit.Ignore;
 
 import java.io.IOException;
 import java.nio.file.Path;
 
+@Ignore
 public abstract class InitTestCase extends LocalDiskRepositoryTestCase {
   protected Path newSitePath() throws IOException {
     return createWorkRepository().getWorkTree().toPath().resolve("test_site");
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
index 2198788..48754f1 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
@@ -26,6 +26,7 @@
 import org.junit.Test;
 
 import java.nio.file.Paths;
+import java.util.Collections;
 
 public class LibrariesTest {
   @Test
@@ -40,7 +41,7 @@
       public LibraryDownloader get() {
         return new LibraryDownloader(ui, site);
       }
-    });
+    }, Collections.<String> emptyList(), false);
 
     assertNotNull(lib.bouncyCastleProvider);
     assertNotNull(lib.mysqlDriver);
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
index dc7ce59..89f61cc 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
@@ -71,8 +71,8 @@
     old.save();
 
     final InMemorySecureStore secureStore = new InMemorySecureStore();
-    final InitFlags flags =
-        new InitFlags(site, secureStore, Collections.<String> emptyList());
+    final InitFlags flags = new InitFlags(site, secureStore,
+        Collections.<String> emptyList(), false);
     final ConsoleUI ui = createStrictMock(ConsoleUI.class);
     Section.Factory sections = new Section.Factory() {
       @Override
@@ -128,6 +128,12 @@
     }
 
     @Override
+    public String[] getListForPlugin(String pluginName, String section,
+        String subsection, String name) {
+      throw new UnsupportedOperationException("not used by tests");
+    }
+
+    @Override
     public void setList(String section, String subsection, String name,
         List<String> values) {
       cfg.setStringList(section, subsection, name, values);
diff --git a/gerrit-plugin-api/BUCK b/gerrit-plugin-api/BUCK
index abcacf9..8cbf1a1 100644
--- a/gerrit-plugin-api/BUCK
+++ b/gerrit-plugin-api/BUCK
@@ -28,6 +28,8 @@
     '//gerrit-gwtexpui:server',
     '//gerrit-reviewdb:server',
     '//lib:args4j',
+    '//lib:blame-cache',
+    '//lib:gson',
     '//lib:guava',
     '//lib:gwtorm',
     '//lib:jsch',
@@ -35,13 +37,17 @@
     '//lib:servlet-api-3_1',
     '//lib:velocity',
     '//lib/commons:lang',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
     '//lib/joda:joda-time',
     '//lib/log:api',
     '//lib/mina:sshd',
+    '//lib/prolog:compiler',
+    '//lib/prolog:runtime',
   ],
   visibility = ['PUBLIC'],
 )
@@ -64,6 +70,7 @@
     ':plugin-api',
     '//lib/bouncycastle:bcprov',
     '//lib/bouncycastle:bcpg',
+    '//lib/bouncycastle:bcpkix',
   ],
   visibility = ['PUBLIC'],
   do_it_wrong = True,
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
new file mode 100644
index 0000000..2c18ca6
--- /dev/null
+++ b/gerrit-plugin-api/BUILD
@@ -0,0 +1,51 @@
+SRCS = [
+  'gerrit-server/src/main/java/',
+  'gerrit-httpd/src/main/java/',
+  'gerrit-sshd/src/main/java/',
+]
+
+PLUGIN_API = [
+  '//gerrit-httpd:httpd',
+  '//gerrit-pgm:init-api',
+  '//gerrit-server:server',
+  '//gerrit-sshd:sshd',
+]
+
+java_binary(
+  name = 'plugin-api',
+  main_class = 'Dummy',
+  runtime_deps = [':lib'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lib',
+  exports = PLUGIN_API + [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-antlr:query_parser',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-gwtexpui:server',
+    '//gerrit-reviewdb:server',
+    '//lib:args4j',
+    '//lib:blame-cache',
+    '//lib/dropwizard:dropwizard-core',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:jsch',
+    '//lib:mime-util',
+    '//lib:servlet-api-3_1',
+    '//lib:velocity',
+    '//lib/commons:lang',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+    '//lib/mina:sshd',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 527bf58..504d53f 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -23,15 +23,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-plugin-archetype/pom.xml b/gerrit-plugin-archetype/pom.xml
index 399a3fd..a8794da 100644
--- a/gerrit-plugin-archetype/pom.xml
+++ b/gerrit-plugin-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-archetype</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <name>Gerrit Code Review - Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
@@ -63,15 +63,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index 270e15c..e32a0d6 100644
--- a/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -31,9 +31,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://www.gerritcodereview.com/</defaultValue>
-    </requiredProperty>
 
     <requiredProperty key="gerritApiType">
       <defaultValue>plugin</defaultValue>
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
index a6103b1..026e21d 100644
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
@@ -50,7 +50,6 @@
 #end
 
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-plugin-gwt-archetype/pom.xml b/gerrit-plugin-gwt-archetype/pom.xml
index ef94e95..3b052bf 100644
--- a/gerrit-plugin-gwt-archetype/pom.xml
+++ b/gerrit-plugin-gwt-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwt-archetype</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <name>Gerrit Code Review - Web UI GWT Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Web UI GWT Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
@@ -63,15 +63,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index 3c3508c..32a603b 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -21,9 +21,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://www.gerritcodereview.com/</defaultValue>
-    </requiredProperty>
     <requiredProperty key="Gwt-Version">
       <defaultValue>2.7.0</defaultValue>
     </requiredProperty>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK
index b224bf6..f33929d 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK
@@ -10,7 +10,6 @@
     'Gerrit-ApiType: plugin',
     'Gerrit-ApiVersion: ${gerritApiVersion}',
     'Gerrit-Module: ${package}.Module',
-    'Gerrit-HttpModule: ${package}.HttpModule',
   ],
 )
 
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
index d67c7cb..2c7fe88 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
@@ -45,7 +45,6 @@
               <Gerrit-Module>${package}.Module</Gerrit-Module>
               <Gerrit-HttpModule>${package}.HttpModule</Gerrit-HttpModule>
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java
deleted file mode 100644
index 4f043d0..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.webui.GwtPlugin;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
-
-public class HttpModule extends HttpPluginModule {
-
-  @Override
-  protected void configureServlets() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new GwtPlugin("hello_gwt_plugin"));
-  }
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
index c734bb7..73e5695 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
@@ -15,7 +15,9 @@
 package ${package};
 
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.GwtPlugin;
 import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.inject.AbstractModule;
 
 public class Module extends AbstractModule {
@@ -23,5 +25,7 @@
   @Override
   protected void configure() {
     DynamicSet.bind(binder(), TopMenu.class).to(HelloMenu.class);
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new GwtPlugin("hello_gwt_plugin"));
   }
 }
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index c4e2765..728c4b3 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
@@ -23,15 +23,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
index 4354072..14e3155 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.client.GerritUiExtensionPoint;
 import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.client.info.AccountPreferencesInfo;
+import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gerrit.client.info.ServerInfo;
 import com.google.gerrit.plugin.client.extension.Panel;
 import com.google.gerrit.plugin.client.screen.Screen;
@@ -39,48 +39,48 @@
   }
 
   /** Installed name of the plugin. */
-  public final String getName() {
+  public String getName() {
     return getPluginName();
   }
 
   /** Installed name of the plugin. */
-  public final native String getPluginName()
+  public native String getPluginName()
   /*-{ return this.getPluginName() }-*/;
 
   /** Navigate the UI to the screen identified by the token. */
-  public final native void go(String token)
+  public native void go(String token)
   /*-{ return this.go(token) }-*/;
 
   /** Refresh the current UI. */
-  public final native void refresh()
+  public native void refresh()
   /*-{ return this.refresh() }-*/;
 
   /** Refresh Gerrit's menu bar. */
-  public final native void refreshMenuBar()
+  public native void refreshMenuBar()
   /*-{ return this.refreshMenuBar() }-*/;
 
   /** @return the preferences of the currently signed in user, the default preferences if not signed in */
-  public final native AccountPreferencesInfo getUserPreferences()
+  public native GeneralPreferences getUserPreferences()
   /*-{ return this.getUserPreferences() }-*/;
 
   /** Refresh the user preferences of the current user. */
-  public final native void refreshUserPreferences()
+  public native void refreshUserPreferences()
   /*-{ return this.refreshUserPreferences() }-*/;
 
   /** @return the server info */
-  public final native ServerInfo getServerInfo()
+  public native ServerInfo getServerInfo()
   /*-{ return this.getServerInfo() }-*/;
 
   /** @return the current user */
-  public final native AccountInfo getCurrentUser()
+  public native AccountInfo getCurrentUser()
   /*-{ return this.getCurrentUser() }-*/;
 
   /** Check if user is signed in. */
-  public final native boolean isSignedIn()
+  public native boolean isSignedIn()
   /*-{ return this.isSignedIn() }-*/;
 
   /** Show message in Gerrit's ErrorDialog. */
-  public final native void showError(String message)
+  public native void showError(String message)
   /*-{ return this.showError(message) }-*/;
 
   /**
@@ -90,11 +90,11 @@
    *        regular expression matching use {@code screenRegex()} .
    * @param entry callback function invoked to create the screen widgets.
    */
-  public final void screen(String token, Screen.EntryPoint entry) {
+  public void screen(String token, Screen.EntryPoint entry) {
     screen(token, wrap(entry));
   }
 
-  private final native void screen(String t, JavaScriptObject e)
+  private native void screen(String t, JavaScriptObject e)
   /*-{ this.screen(t, e) }-*/;
 
   /**
@@ -105,11 +105,11 @@
    *        {@code Screen} object passed into the {@code Screen.EntryPoint}.
    * @param entry callback function invoked to create the screen widgets.
    */
-  public final void screenRegex(String regex, Screen.EntryPoint entry) {
+  public void screenRegex(String regex, Screen.EntryPoint entry) {
     screenRegex(regex, wrap(entry));
   }
 
-  private final native void screenRegex(String p, JavaScriptObject e)
+  private native void screenRegex(String p, JavaScriptObject e)
   /*-{ this.screen(new $wnd.RegExp(p), e) }-*/;
 
   /**
@@ -118,11 +118,11 @@
    * @param token literal anchor token appearing after the plugin name.
    * @param entry callback function invoked to create the settings screen widgets.
    */
-  public final void settingsScreen(String token, String menu, Screen.EntryPoint entry) {
+  public void settingsScreen(String token, String menu, Screen.EntryPoint entry) {
     settingsScreen(token, menu, wrap(entry));
   }
 
-  private final native void settingsScreen(String t, String m, JavaScriptObject e)
+  private native void settingsScreen(String t, String m, JavaScriptObject e)
   /*-{ this.settingsScreen(t, m, e) }-*/;
 
   /**
@@ -132,11 +132,11 @@
    *        registered.
    * @param entry callback function invoked to create the panel widgets.
    */
-  public final void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
+  public void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
     panel(extensionPoint.name(), wrap(entry));
   }
 
-  private final native void panel(String i, JavaScriptObject e)
+  private native void panel(String i, JavaScriptObject e)
   /*-{ this.panel(i, e) }-*/;
 
   protected Plugin() {
@@ -144,17 +144,17 @@
 
   native void _initialized() /*-{ this._success = true }-*/;
   native void _loaded() /*-{ this._loadedGwt() }-*/;
-  private static final native Plugin install(String u)
+  private static native Plugin install(String u)
   /*-{ return $wnd.Gerrit.installGwt(u) }-*/;
 
-  private static final native JavaScriptObject wrap(Screen.EntryPoint b) /*-{
+  private static native JavaScriptObject wrap(Screen.EntryPoint b) /*-{
     return $entry(function(c){
       b.@com.google.gerrit.plugin.client.screen.Screen.EntryPoint::onLoad(Lcom/google/gerrit/plugin/client/screen/Screen;)(
         @com.google.gerrit.plugin.client.screen.Screen::new(Lcom/google/gerrit/plugin/client/screen/Screen$Context;)(c));
     });
   }-*/;
 
-  private static final native JavaScriptObject wrap(Panel.EntryPoint b) /*-{
+  private static native JavaScriptObject wrap(Panel.EntryPoint b) /*-{
     return $entry(function(c){
       b.@com.google.gerrit.plugin.client.extension.Panel.EntryPoint::onLoad(Lcom/google/gerrit/plugin/client/extension/Panel;)(
         @com.google.gerrit.plugin.client.extension.Panel::new(Lcom/google/gerrit/plugin/client/extension/Panel$Context;)(c));
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
index 6d4e719..0200a14 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
@@ -56,24 +56,24 @@
      *
      * @param panel panel that will contain the panel widget.
      */
-    public void onLoad(Panel panel);
+    void onLoad(Panel panel);
   }
 
   static final class Context extends JavaScriptObject {
-    final native Element body() /*-{ return this.body }-*/;
+    native Element body() /*-{ return this.body }-*/;
 
-    final native String get(String k) /*-{ return this.p[k]; }-*/;
-    final native int getInt(String k, int d) /*-{
+    native String get(String k) /*-{ return this.p[k]; }-*/;
+    native int getInt(String k, int d) /*-{
       return this.p.hasOwnProperty(k) ? this.p[k] : d
     }-*/;
-    final native int getBoolean(String k, boolean d) /*-{
+    native int getBoolean(String k, boolean d) /*-{
       return this.p.hasOwnProperty(k) ? this.p[k] : d
     }-*/;
-    final native JavaScriptObject getObject(String k)
+    native JavaScriptObject getObject(String k)
     /*-{ return this.p[k]; }-*/;
 
 
-    final native void detach(Panel p) /*-{
+    native void detach(Panel p) /*-{
       this.onUnload($entry(function(){
         p.@com.google.gwt.user.client.ui.Widget::onDetach()();
       }));
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
index 8d408fb..d627959 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
@@ -37,7 +37,7 @@
   }
 
   public RestApi id(String id) {
-    return idRaw(URL.encodeQueryString(id));
+    return idRaw(URL.encodePathSegment(id));
   }
 
   public RestApi id(int id) {
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
index 2a872fd..5e0ba4a 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
@@ -58,16 +58,16 @@
      *
      * @param screen panel that will contain the screen widget.
      */
-    public void onLoad(Screen screen);
+    void onLoad(Screen screen);
   }
 
   static final class Context extends JavaScriptObject {
-    final native Element body() /*-{ return this.body }-*/;
-    final native JsArrayString token_match() /*-{ return this.token_match }-*/;
-    final native void show() /*-{ this.show() }-*/;
-    final native void setTitle(String t) /*-{ this.setTitle(t) }-*/;
-    final native void setWindowTitle(String t) /*-{ this.setWindowTitle(t) }-*/;
-    final native void detach(Screen s) /*-{
+    native Element body() /*-{ return this.body }-*/;
+    native JsArrayString token_match() /*-{ return this.token_match }-*/;
+    native void show() /*-{ this.show() }-*/;
+    native void setTitle(String t) /*-{ this.setTitle(t) }-*/;
+    native void setWindowTitle(String t) /*-{ this.setWindowTitle(t) }-*/;
+    native void detach(Screen s) /*-{
       this.onUnload($entry(function(){
         s.@com.google.gwt.user.client.ui.Widget::onDetach()();
       }));
@@ -87,7 +87,7 @@
   }
 
   /** @return the token suffix after {@code "/#/x/plugin-name/"}. */
-  public final String getToken() {
+  public String getToken() {
     return getToken(0);
   }
 
@@ -96,12 +96,12 @@
    *        group 0 is the entire token, see {@link #getToken()}.
    * @return the token from the regex match group.
    */
-  public final String getToken(int group) {
+  public String getToken(int group) {
     return ctx.token_match().get(group);
   }
 
   /** @return total number of token groups. */
-  public final int getTokenGroups() {
+  public int getTokenGroups() {
     return ctx.token_match().length();
   }
 
@@ -110,7 +110,7 @@
    *
    * @param titleText text to display above the widget.
    */
-  public final void setPageTitle(String titleText) {
+  public void setPageTitle(String titleText) {
     ctx.setTitle(titleText);
   }
 
@@ -119,7 +119,7 @@
    *
    * @param titleText text to display in the window title bar.
    */
-  public final void setWindowTitle(String titleText) {
+  public void setWindowTitle(String titleText) {
     ctx.setWindowTitle(titleText);
   }
 
@@ -128,13 +128,13 @@
    *
    * @param w child containing the content.
    */
-  public final void show(Widget w) {
+  public void show(Widget w) {
     setWidget(w);
     ctx.show();
   }
 
   /** Show this screen in the web interface. */
-  public final void show() {
+  public void show() {
     ctx.show();
   }
 }
diff --git a/gerrit-plugin-js-archetype/pom.xml b/gerrit-plugin-js-archetype/pom.xml
index da05bb7..4140da4 100644
--- a/gerrit-plugin-js-archetype/pom.xml
+++ b/gerrit-plugin-js-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-js-archetype</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <name>Gerrit Code Review - Web UI JavaScript Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Web UI JavaScript Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
@@ -63,15 +63,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index fbf1e46..ef0e96c 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -21,9 +21,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://gerrit.googlesource.com/</defaultValue>
-    </requiredProperty>
 
     <requiredProperty key="gerritApiType">
       <defaultValue>js</defaultValue>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
index f24d81e..8f4aadd 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
@@ -44,7 +44,6 @@
             <manifestEntries>
               <Gerrit-PluginName>${pluginName}</Gerrit-PluginName>
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-prettify/BUCK b/gerrit-prettify/BUCK
index 9a0136e..bf2e02a 100644
--- a/gerrit-prettify/BUCK
+++ b/gerrit-prettify/BUCK
@@ -3,40 +3,25 @@
 gwt_module(
   name = 'client',
   srcs = glob([
-    SRC + 'client/**/*.java',
     SRC + 'common/**/*.java',
   ]),
   gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
-  resources = glob([
-    'src/main/java/com/google/gerrit/prettify/client/*.properties',
-  ]),
   deps = [
-    ':google-code-prettify',
     '//gerrit-gwtexpui:SafeHtml',
   ],
   exported_deps = [
     '//gerrit-extension-api:client',
     '//gerrit-patch-jgit:client',
+    '//gerrit-patch-jgit:Edit',
     '//gerrit-reviewdb:client',
     '//lib:gwtjsonrpc',
     '//lib:gwtjsonrpc_src',
-    '//lib/jgit:Edit',
   ],
   provided_deps = ['//lib/gwt:user'],
   visibility = ['PUBLIC'],
 )
 
 java_library(
-  name = 'google-code-prettify',
-  resources = glob([
-    'src/main/resources/com/google/gerrit/prettify/client/**/*',
-  ]),
-  deps = [
-    '//lib:LICENSE-Apache2.0',
-  ],
-)
-
-java_library(
   name = 'server',
   srcs = glob([SRC + 'common/**/*.java']),
   deps = [
@@ -44,7 +29,7 @@
     '//gerrit-reviewdb:server',
     '//lib:guava',
     '//lib:gwtjsonrpc',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
new file mode 100644
index 0000000..063feee
--- /dev/null
+++ b/gerrit-prettify/BUILD
@@ -0,0 +1,35 @@
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+
+SRC = 'src/main/java/com/google/gerrit/prettify/'
+
+gwt_module(
+  name = 'client',
+  srcs = glob([
+    SRC + 'common/**/*.java',
+  ]),
+  gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
+  deps = ['//lib/gwt:user'],
+  exported_deps = [
+    '//gerrit-extension-api:client',
+    '//gerrit-gwtexpui:SafeHtml',
+    '//gerrit-patch-jgit:client',
+    '//gerrit-patch-jgit:Edit',
+    '//gerrit-reviewdb:client',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtjsonrpc_src',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  srcs = glob([SRC + 'common/**/*.java']),
+  deps = [
+    '//gerrit-patch-jgit:server',
+    '//gerrit-reviewdb:server',
+    '//lib:guava',
+    '//lib:gwtjsonrpc',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
index fd88f6c..06035d27 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/PrettyFormatter.gwt.xml
@@ -24,5 +24,4 @@
   <inherits name='com.google.gwt.resources.Resources'/>
   <inherits name='com.google.gwtexpui.safehtml.SafeHtml'/>
   <source path='common' />
-  <source path='client' />
 </module>
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java
deleted file mode 100644
index 34ddde2..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/ClientSideFormatter.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.ui.RootPanel;
-
-/** Evaluates prettify using the host browser's JavaScript engine. */
-public class ClientSideFormatter extends PrettyFormatter {
-  public static final PrettyFactory FACTORY = new PrettyFactory() {
-    @Override
-    public PrettyFormatter get() {
-      return new ClientSideFormatter();
-    }
-  };
-
-  private static final PrivateScopeImpl prettify;
-
-  static {
-    Resources.I.prettify_css().ensureInjected();
-    Resources.I.gerrit_css().ensureInjected();
-
-    prettify = GWT.create(PrivateScopeImpl.class);
-    RootPanel.get().add(prettify);
-
-    prettify.compile(Resources.I.core());
-    prettify.compile(Resources.I.lang_apollo());
-    prettify.compile(Resources.I.lang_basic());
-    prettify.compile(Resources.I.lang_clj());
-    prettify.compile(Resources.I.lang_css());
-    prettify.compile(Resources.I.lang_dart());
-    prettify.compile(Resources.I.lang_erlang());
-    prettify.compile(Resources.I.lang_go());
-    prettify.compile(Resources.I.lang_hs());
-    prettify.compile(Resources.I.lang_lisp());
-    prettify.compile(Resources.I.lang_llvm());
-    prettify.compile(Resources.I.lang_lua());
-    prettify.compile(Resources.I.lang_matlab());
-    prettify.compile(Resources.I.lang_ml());
-    prettify.compile(Resources.I.lang_mumps());
-    prettify.compile(Resources.I.lang_n());
-    prettify.compile(Resources.I.lang_pascal());
-    prettify.compile(Resources.I.lang_proto());
-    prettify.compile(Resources.I.lang_r());
-    prettify.compile(Resources.I.lang_rd());
-    prettify.compile(Resources.I.lang_scala());
-    prettify.compile(Resources.I.lang_sql());
-    prettify.compile(Resources.I.lang_tcl());
-    prettify.compile(Resources.I.lang_tex());
-    prettify.compile(Resources.I.lang_vb());
-    prettify.compile(Resources.I.lang_vhdl());
-    prettify.compile(Resources.I.lang_wiki());
-    prettify.compile(Resources.I.lang_xq());
-    prettify.compile(Resources.I.lang_yaml());
-  }
-
-  @Override
-  protected String prettify(String html, String type) {
-    return go(prettify.getContext(), html, type);
-  }
-
-  private static native String go(JavaScriptObject ctx, String srcText,
-      String srcType)
-  /*-{
-     return ctx.prettyPrintOne(srcText, srcType);
-  }-*/;
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.java
deleted file mode 100644
index c191fa5..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.i18n.client.Constants;
-
-public interface PrettifyConstants extends Constants {
-  static final PrettifyConstants C = GWT.create(PrettifyConstants.class);
-
-  String wseTabAfterSpace();
-  String wseTrailingSpace();
-  String wseBareCR();
-  String leCR();
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.properties b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.properties
deleted file mode 100644
index 97ab0cf..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettifyConstants.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-wseTabAfterSpace=Whitespace error: Tab after space
-wseTrailingSpace=Whitespace error: Trailing space at end of line
-wseBareCR=CR without LF
-leCR=Carriage Return
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFactory.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFactory.java
deleted file mode 100644
index f68b629..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFactory.java
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-/** Creates a new PrettyFormatter instance for one formatting run. */
-public interface PrettyFactory {
-  PrettyFormatter get();
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java
deleted file mode 100644
index 49dc2fc..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrettyFormatter.java
+++ /dev/null
@@ -1,561 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.prettify.common.SparseFileContent;
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.ReplaceEdit;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public abstract class PrettyFormatter implements SparseHtmlFile {
-  public abstract static class EditFilter {
-    abstract String getStyleName();
-
-    abstract int getBegin(Edit edit);
-
-    abstract int getEnd(Edit edit);
-  }
-
-  public static final EditFilter A = new EditFilter() {
-    @Override
-    String getStyleName() {
-      return "wdd";
-    }
-
-    @Override
-    int getBegin(Edit edit) {
-      return edit.getBeginA();
-    }
-
-    @Override
-    int getEnd(Edit edit) {
-      return edit.getEndA();
-    }
-  };
-
-  public static final EditFilter B = new EditFilter() {
-    @Override
-    String getStyleName() {
-      return "wdi";
-    }
-
-    @Override
-    int getBegin(Edit edit) {
-      return edit.getBeginB();
-    }
-
-    @Override
-    int getEnd(Edit edit) {
-      return edit.getEndB();
-    }
-  };
-
-  protected SparseFileContent content;
-  protected EditFilter side;
-  protected List<Edit> edits;
-  protected DiffPreferencesInfo diffPrefs;
-  protected String fileName;
-  protected Set<Integer> trailingEdits;
-
-  private int col;
-  private int lineIdx;
-  private Tag lastTag;
-  private StringBuilder buf;
-
-  @Override
-  public SafeHtml getSafeHtmlLine(int lineNo) {
-    return SafeHtml.asis(content.get(lineNo));
-  }
-
-  @Override
-  public int size() {
-    return content.size();
-  }
-
-  @Override
-  public boolean contains(int idx) {
-    return content.contains(idx);
-  }
-
-  @Override
-  public boolean hasTrailingEdit(int idx) {
-    return trailingEdits.contains(idx);
-  }
-
-  public void setEditFilter(EditFilter f) {
-    side = f;
-  }
-
-  public void setEditList(List<Edit> all) {
-    edits = all;
-  }
-
-  public void setDiffPrefs(DiffPreferencesInfo how) {
-    diffPrefs = how;
-  }
-
-  public void setFileName(String fileName) {
-    this.fileName = fileName;
-  }
-
-  /**
-   * Parse and format a complete source code file.
-   *
-   * @param src raw content of the file to format. The line strings will be HTML
-   *        escaped before processing, so it must be the raw text.
-   */
-  public void format(SparseFileContent src) {
-    content = new SparseFileContent();
-    content.setSize(src.size());
-    trailingEdits = new HashSet<>();
-
-    String html = toHTML(src);
-
-    html = expandTabs(html);
-    if (diffPrefs.syntaxHighlighting && getFileType() != null
-        && src.isWholeFile()) {
-      // The prettify parsers don't like &#39; as an entity for the
-      // single quote character. Replace them all out so we don't
-      // confuse the parser.
-      //
-      html = html.replaceAll("&#39;", "'");
-
-      // If a line is modified at its end and the line ending is changed from
-      // '\n' to '\r\n' then the '\r' of the new line is part of the modified
-      // text. If intraline diffs are highlighted the modified text is
-      // surrounded by a 'span' tag. As result '\r' and '\n' of the new line get
-      // separated by '</span>'. For the prettify parser this now looks like two
-      // separate line endings. This messes up the line counting below.
-      // Drop any '\r' to avoid this problem.
-      html = html.replaceAll("\r</span>(<span class=\"wdc\">)?\n", "</span>$1\n");
-
-      html = html.replaceAll("(\r)?\n", " $1\n");
-      html = prettify(html, getFileType());
-      html = html.replaceAll(" (\r)?\n", "$1\n");
-    }
-
-    int pos = 0;
-    int textChunkStart = 0;
-
-    lastTag = Tag.NULL;
-    col = 0;
-    lineIdx = 0;
-
-    buf = new StringBuilder();
-    while (pos <= html.length()) {
-      int tagStart = html.indexOf('<', pos);
-      int lf = html.indexOf('\n', pos);
-
-      if (tagStart < 0 && lf < 0) {
-        // No more tags remaining. What's left is plain text.
-        //
-        assert lastTag == Tag.NULL;
-        pos = html.length();
-        if (textChunkStart < pos) {
-          htmlText(html.substring(textChunkStart, pos));
-        }
-        if (0 < buf.length()) {
-          content.addLine(src.mapIndexToLine(lineIdx), buf.toString());
-        }
-        break;
-      }
-
-      // Line end occurs before the next HTML tag. Break the line.
-      if (0 <= lf && (lf < tagStart || tagStart < 0)) {
-        if (textChunkStart < lf) {
-          lastTag.open(buf, html);
-          htmlText(html.substring(textChunkStart, lf));
-        }
-        pos = lf + 1;
-        textChunkStart = pos;
-
-        lastTag.close(buf, html);
-        content.addLine(src.mapIndexToLine(lineIdx++), buf.toString());
-        buf = new StringBuilder();
-        col = 0;
-        continue;
-      }
-
-      // Assume no attribute contains '>' and that all tags
-      // within the HTML will be well-formed.
-      //
-      int tagEnd = html.indexOf('>', tagStart);
-      assert tagStart < tagEnd;
-      pos = tagEnd + 1;
-
-      // Handle any text between the end of the last tag,
-      // and the start of this tag.
-      //
-      if (textChunkStart < tagStart) {
-        lastTag.open(buf, html);
-        htmlText(html.substring(textChunkStart, tagStart));
-      }
-      textChunkStart = pos;
-
-      if (html.charAt(tagStart + 1) == '/') {
-        lastTag = lastTag.pop(buf, html);
-
-      } else if (html.charAt(tagEnd - 1) != '/') {
-        lastTag = new Tag(lastTag, tagStart, tagEnd);
-      }
-    }
-    buf = null;
-  }
-
-  private void htmlText(String txt) {
-    int pos = 0;
-    while (pos < txt.length()) {
-      int start = txt.indexOf('&', pos);
-      if (start < 0) {
-        break;
-      }
-
-      cleanText(txt, pos, start);
-      pos = txt.indexOf(';', start + 1) + 1;
-
-      if (diffPrefs.lineLength <= col) {
-        buf.append("<br />");
-        col = 0;
-      }
-
-      buf.append(txt.substring(start, pos));
-      col++;
-    }
-
-    cleanText(txt, pos, txt.length());
-  }
-
-  private void cleanText(String txt, int pos, int end) {
-    while (pos < end) {
-      int free = diffPrefs.lineLength - col;
-      if (free <= 0) {
-        // The current line is full. Throw an explicit line break
-        // onto the end, and we'll continue on the next line.
-        //
-        buf.append("<br />");
-        col = 0;
-        free = diffPrefs.lineLength;
-      }
-
-      int n = Math.min(end - pos, free);
-      buf.append(txt.substring(pos, pos + n));
-      col += n;
-      pos += n;
-    }
-  }
-
-  /** Run the prettify engine over the text and return the result. */
-  protected abstract String prettify(String html, String type);
-
-  private static class Tag {
-    static final Tag NULL = new Tag(null, 0, 0) {
-      @Override
-      void open(StringBuilder buf, String html) {
-      }
-
-      @Override
-      void close(StringBuilder buf, String html) {
-      }
-
-      @Override
-      Tag pop(StringBuilder buf, String html) {
-        return this;
-      }
-    };
-
-    final Tag parent;
-    final int start;
-    final int end;
-    boolean open;
-
-    Tag(Tag p, int s, int e) {
-      parent = p;
-      start = s;
-      end = e;
-    }
-
-    void open(StringBuilder buf, String html) {
-      if (!open) {
-        parent.open(buf, html);
-        buf.append(html.substring(start, end + 1));
-        open = true;
-      }
-    }
-
-    void close(StringBuilder buf, String html) {
-      pop(buf, html);
-      parent.close(buf, html);
-    }
-
-    Tag pop(StringBuilder buf, String html) {
-      if (open) {
-        int sp = html.indexOf(' ', start + 1);
-        if (sp < 0 || end < sp) {
-          sp = end;
-        }
-
-        buf.append("</");
-        buf.append(html.substring(start + 1, sp));
-        buf.append('>');
-        open = false;
-      }
-      return parent;
-    }
-  }
-
-  private String toHTML(SparseFileContent src) {
-    SafeHtml html;
-
-    if (diffPrefs.intralineDifference) {
-      html = colorLineEdits(src);
-    } else {
-      SafeHtmlBuilder b = new SafeHtmlBuilder();
-      for (int index = src.first(); index < src.size(); index = src.next(index)) {
-        b.append(src.get(index));
-        b.append('\n');
-      }
-      html = b;
-
-      final String r = "<span class=\"wse\"" //
-          + " title=\"" + PrettifyConstants.C.wseBareCR() + "\"" //
-          + ">&nbsp;</span>$1";
-      html = html.replaceAll("\r([^\n])", r);
-    }
-
-    if (diffPrefs.showWhitespaceErrors) {
-      // We need to do whitespace errors before showing tabs, because
-      // these patterns rely on \t as a literal, before it expands.
-      //
-      html = showTabAfterSpace(html);
-      html = showTrailingWhitespace(html);
-    }
-
-    if (diffPrefs.showLineEndings){
-      html = showLineEndings(html);
-    }
-
-    if (diffPrefs.showTabs) {
-      String t = 1 < diffPrefs.tabSize ? "\t" : "";
-      html = html.replaceAll("\t", "<span class=\"vt\">\u00BB</span>" + t);
-    }
-
-    return html.asString();
-  }
-
-  private SafeHtml colorLineEdits(SparseFileContent src) {
-    // Make a copy of the edits with a sentinel that is after all lines
-    // in the source. That simplifies our loop below because we'll never
-    // run off the end of the edit list.
-    //
-    List<Edit> edits = new ArrayList<>(this.edits.size() + 1);
-    edits.addAll(this.edits);
-    edits.add(new Edit(src.size(), src.size()));
-
-    SafeHtmlBuilder buf = new SafeHtmlBuilder();
-
-    int curIdx = 0;
-    Edit curEdit = edits.get(curIdx);
-
-    ReplaceEdit lastReplace = null;
-    List<Edit> charEdits = null;
-    int lastPos = 0;
-    int lastIdx = 0;
-
-    for (int index = src.first(); index < src.size(); index = src.next(index)) {
-      int cmp = compare(index, curEdit);
-      while (0 < cmp) {
-        // The index is after the edit. Skip to the next edit.
-        //
-        curEdit = edits.get(curIdx++);
-        cmp = compare(index, curEdit);
-      }
-
-      if (cmp < 0) {
-        // index occurs before the edit. This is a line of context.
-        //
-        appendShowBareCR(buf, src.get(index), true);
-        buf.append('\n');
-        continue;
-      }
-
-      // index occurs within the edit. The line is a modification.
-      //
-      if (curEdit instanceof ReplaceEdit) {
-        if (lastReplace != curEdit) {
-          lastReplace = (ReplaceEdit) curEdit;
-          charEdits = lastReplace.getInternalEdits();
-          lastPos = 0;
-          lastIdx = 0;
-        }
-
-        String line = src.get(index) + "\n";
-        for (int c = 0; c < line.length();) {
-          if (charEdits == null || (charEdits.size() <= lastIdx)) {
-            appendShowBareCR(buf, line.substring(c), false);
-            break;
-          }
-
-          final Edit edit = charEdits.get(lastIdx);
-          final int b = side.getBegin(edit) - lastPos;
-          final int e = side.getEnd(edit) - lastPos;
-
-          if (c < b) {
-            // There is text at the start of this line that is common
-            // with the other side. Copy it with no style around it.
-            //
-            final int cmnLen = Math.min(b, line.length());
-            buf.openSpan();
-            buf.setStyleName("wdc");
-            appendShowBareCR(buf, line.substring(c, cmnLen), //
-                cmnLen == line.length() - 1);
-            buf.closeSpan();
-            c = cmnLen;
-          }
-
-          final int modLen = Math.min(e, line.length());
-          if (c < e && c < modLen) {
-            buf.openSpan();
-            buf.setStyleName(side.getStyleName());
-            appendShowBareCR(buf, line.substring(c, modLen), //
-                modLen == line.length() - 1);
-            buf.closeSpan();
-            if (modLen == line.length()) {
-              trailingEdits.add(index);
-            }
-            c = modLen;
-          }
-
-          if (e <= c) {
-            lastIdx++;
-          }
-        }
-        lastPos += line.length();
-
-      } else {
-        appendShowBareCR(buf, src.get(index), true);
-        buf.append('\n');
-      }
-    }
-    return buf;
-  }
-
-  private void appendShowBareCR(SafeHtmlBuilder buf, String src, boolean end) {
-    while (!src.isEmpty()) {
-      int cr = src.indexOf('\r');
-      if (cr < 0) {
-        buf.append(src);
-        return;
-
-      } else if (end) {
-        if (cr == src.length() - 1) {
-          buf.append(src.substring(0, cr + 1));
-          return;
-        }
-      } else if (cr == src.length() - 2 && src.charAt(cr + 1) == '\n') {
-        buf.append(src);
-        return;
-      }
-
-      buf.append(src.substring(0, cr));
-      buf.openSpan();
-      buf.setStyleName("wse");
-      buf.setAttribute("title", PrettifyConstants.C.wseBareCR());
-      buf.nbsp();
-      buf.closeSpan();
-      src = src.substring(cr + 1);
-    }
-  }
-
-  private int compare(int index, Edit edit) {
-    if (index < side.getBegin(edit)) {
-      return -1; // index occurs before the edit.
-
-    } else if (index < side.getEnd(edit)) {
-      return 0; // index occurs within the edit.
-
-    } else {
-      return 1; // index occurs after the edit.
-    }
-  }
-
-  private SafeHtml showTabAfterSpace(SafeHtml src) {
-    final String m = "( ( |<span[^>]*>|</span>)*\t)";
-    final String r = "<span class=\"wse\"" //
-        + " title=\"" + PrettifyConstants.C.wseTabAfterSpace() + "\"" //
-        + ">$1</span>";
-    src = src.replaceFirst("^" + m, r);
-    src = src.replaceAll("\n" + m, "\n" + r);
-    return src;
-  }
-
-  private SafeHtml showTrailingWhitespace(SafeHtml src) {
-    final String r = "<span class=\"wse\"" //
-        + " title=\"" + PrettifyConstants.C.wseTrailingSpace() + "\"" //
-        + ">$1</span>$2";
-    src = src.replaceAll("([ \t][ \t]*)(\r?(</span>)?\n)", r);
-    src = src.replaceFirst("([ \t][ \t]*)(\r?(</span>)?\n?)$", r);
-    return src;
-  }
-
-  private SafeHtml showLineEndings(SafeHtml src) {
-    final String r = "<span class=\"lecr\""
-        + " title=\"" + PrettifyConstants.C.leCR() + "\"" //
-        + ">\\\\r</span>";
-    src = src.replaceAll("\r", r);
-    return src;
-  }
-
-  private String expandTabs(String html) {
-    StringBuilder tmp = new StringBuilder();
-    int i = 0;
-    if (diffPrefs.showTabs) {
-      i = 1;
-    }
-    for (; i < diffPrefs.tabSize; i++) {
-      tmp.append("&nbsp;");
-    }
-    return html.replaceAll("\t", tmp.toString());
-  }
-
-  private String getFileType() {
-    String srcType = fileName;
-    if (srcType == null) {
-      return null;
-    }
-
-    int dot = srcType.lastIndexOf('.');
-    if (dot < 0) {
-      return null;
-    }
-
-    if (0 < dot) {
-      srcType = srcType.substring(dot + 1);
-    }
-
-    if ("txt".equalsIgnoreCase(srcType)) {
-      return null;
-    }
-
-    return srcType;
-  }
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImpl.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImpl.java
deleted file mode 100644
index 65ee212..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImpl.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.resources.client.TextResource;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.NamedFrame;
-
-/**
- * Creates a private JavaScript environment, typically inside an IFrame.
- * <p>
- * Instances must be created through {@code GWT.create(PrivateScopeImpl.class)}.
- * A scope must remain attached to the primary document for its entire life.
- * Behavior is undefined if a scope is detached and attached again later. It is
- * best to attach the scope with {@code RootPanel.get().add(scope)} as soon as
- * it has been created.
- */
-public class PrivateScopeImpl extends Composite {
-  private static int scopeId;
-
-  protected final String scopeName;
-
-  public PrivateScopeImpl() {
-    scopeName = nextScopeName();
-
-    NamedFrame frame = new NamedFrame(scopeName);
-    frame.setUrl("javascript:''");
-    initWidget(frame);
-
-    setVisible(false);
-  }
-
-  public void compile(TextResource js) {
-    eval(js.getText());
-  }
-
-  public void eval(String js) {
-    nativeEval(getContext(), js);
-  }
-
-  public JavaScriptObject getContext() {
-    return nativeGetContext(scopeName);
-  }
-
-  private static String nextScopeName() {
-    return "_PrivateScope" + (++scopeId);
-  }
-
-  private static native void nativeEval(JavaScriptObject ctx, String js)
-  /*-{ ctx.eval(js); }-*/;
-
-  private static native JavaScriptObject nativeGetContext(String scopeName)
-  /*-{ return $wnd[scopeName]; }-*/;
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImplIE8.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImplIE8.java
deleted file mode 100644
index 0496d91..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/PrivateScopeImplIE8.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwt.core.client.JavaScriptObject;
-
-/** MSIE requires us to initialize the document before we can use it. */
-public class PrivateScopeImplIE8 extends PrivateScopeImpl {
-  private JavaScriptObject context;
-
-  @Override
-  protected void onAttach() {
-    super.onAttach();
-    context = nativeInitContext(scopeName);
-  }
-
-  @Override
-  public JavaScriptObject getContext() {
-    return context;
-  }
-
-  private static native JavaScriptObject nativeInitContext(String scopeName)
-  /*-{
-    var fe = $wnd[scopeName];
-    fe.document.write(
-        '<script>'
-      + 'parent._PrivateScopeNewChild = this;'
-      + '</' + 'script>'
-    );
-    var ctx = $wnd._PrivateScopeNewChild;
-    $wnd._PrivateScopeNewChild = undefined;
-    return ctx;
-  }-*/;
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/Resources.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/Resources.java
deleted file mode 100644
index 93c2988..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/Resources.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwt.core.client.GWT;
-import com.google.gwt.resources.client.ClientBundle;
-import com.google.gwt.resources.client.CssResource;
-import com.google.gwt.resources.client.TextResource;
-
-/** Loads the minimized form of prettify into the client. */
-interface Resources extends ClientBundle {
-  static final Resources I = GWT.create(Resources.class);
-
-  @Source("prettify.css")
-  CssResource prettify_css();
-
-  @Source("gerrit.css")
-  CssResource gerrit_css();
-
-  @Source("prettify.js")
-  TextResource core();
-
-  @Source("lang-apollo.js") TextResource lang_apollo();
-  @Source("lang-basic.js") TextResource lang_basic();
-  @Source("lang-clj.js") TextResource lang_clj();
-  @Source("lang-css.js") TextResource lang_css();
-  @Source("lang-dart.js") TextResource lang_dart();
-  @Source("lang-erlang.js") TextResource lang_erlang();
-  @Source("lang-go.js") TextResource lang_go();
-  @Source("lang-hs.js") TextResource lang_hs();
-  @Source("lang-lisp.js") TextResource lang_lisp();
-  @Source("lang-llvm.js") TextResource lang_llvm();
-  @Source("lang-lua.js") TextResource lang_lua();
-  @Source("lang-matlab.js") TextResource lang_matlab();
-  @Source("lang-ml.js") TextResource lang_ml();
-  @Source("lang-mumps.js") TextResource lang_mumps();
-  @Source("lang-n.js") TextResource lang_n();
-  @Source("lang-pascal.js") TextResource lang_pascal();
-  @Source("lang-proto.js") TextResource lang_proto();
-  @Source("lang-r.js") TextResource lang_r();
-  @Source("lang-rd.js") TextResource lang_rd();
-  @Source("lang-scala.js") TextResource lang_scala();
-  @Source("lang-sql.js") TextResource lang_sql();
-  @Source("lang-tcl.js") TextResource lang_tcl();
-  @Source("lang-tex.js") TextResource lang_tex();
-  @Source("lang-vb.js") TextResource lang_vb();
-  @Source("lang-vhdl.js") TextResource lang_vhdl();
-  @Source("lang-wiki.js") TextResource lang_wiki();
-  @Source("lang-xq.js") TextResource lang_xq();
-  @Source("lang-yaml.js") TextResource lang_yaml();
-}
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java
deleted file mode 100644
index 0c2af36..0000000
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.prettify.client;
-
-import com.google.gwtexpui.safehtml.client.SafeHtml;
-
-public interface SparseHtmlFile {
-  /** @return the line of formatted HTML. */
-  public SafeHtml getSafeHtmlLine(int lineNo);
-
-  /** @return the number of lines in this sparse list. */
-  public int size();
-
-  /** @return true if the line is valid in this sparse list. */
-  public boolean contains(int idx);
-
-  /** @return true if this line ends in the middle of a character edit span. */
-  public boolean hasTrailingEdit(int idx);
-}
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/gerrit.css b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/gerrit.css
deleted file mode 100644
index 23e7e46..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/gerrit.css
+++ /dev/null
@@ -1,98 +0,0 @@
-/* Copyright (C) 2009 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@external .wse;
-@external .lecr;
-@external .vt;
-@external .wdd;
-@external .wdi;
-
-@external .str;
-@external .kwd;
-@external .com;
-@external .typ;
-@external .lit;
-@external .pun;
-@external .pln;
-@external .tag;
-@external .atn;
-@external .atv;
-@external .dec;
-
-.wse {
-  background: red;
-  cursor: pointer;
-}
-
-.lecr {
-  border-bottom: #aaaaaa 1px dashed;
-  border-left: #aaaaaa 1px dashed;
-  padding-bottom: 0px;
-  margin: 0px 2px;
-  padding-left: 2px;
-  padding-right: 2px;
-  border-top: #aaaaaa 1px dashed;
-  border-right: #aaaaaa 1px dashed;
-  padding-top: 0px;
-  cursor: pointer;
-}
-
-.vt,
-.vt .str,
-.vt .kwd,
-.vt .com,
-.vt .typ,
-.vt .lit,
-.vt .pun,
-.vt .pln,
-.vt .tag,
-.vt .atn,
-.vt .atv,
-.vt .dec {
-  color: red;
-}
-
-.wdd {
-  background: #FAA;
-}
-.wdi {
-  background: #9F9;
-}
-
-/* Use special rules for special styles contained within a whitespace
- * error.  For these we want the whitespace error to take precedence
- * so we have to override the contained style.
- */
-.wse .vt, .wdd .vt,
-.wse .vt .pun, .wdd .vt .pun,
-.wse .vt .str, .wdd .vt .str,
-.wse .vt .kwd, .wdd .vt .kwd,
-.wse .vt .com, .wdd .vt .com,
-.wse .vt .typ, .wdd .vt .typ,
-.wse .vt .lit, .wdd .vt .lit,
-.wse .vt .pun, .wdd .vt .pun,
-.wse .vt .pln, .wdd .vt .pln,
-.wse .vt .tag, .wdd .vt .tag,
-.wse .vt .atn, .wdd .vt .atn,
-.wse .vt .atv, .wdd .vt .atv,
-.wse .vt .dec, .wdd .vt .dec {
-  color: black;
-}
-.wse .wdd {
-  background: red;
-}
-.wse .wdi {
-  background: red;
-}
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-apollo.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-apollo.js
deleted file mode 100644
index 99e4a97..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-apollo.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["com",/^#[^\n\r]*/,null,"#"],["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,null,'"']],[["kwd",/^(?:ADS|AD|AUG|BZF|BZMF|CAE|CAF|CA|CCS|COM|CS|DAS|DCA|DCOM|DCS|DDOUBL|DIM|DOUBLE|DTCB|DTCF|DV|DXCH|EDRUPT|EXTEND|INCR|INDEX|NDX|INHINT|LXCH|MASK|MSK|MP|MSU|NOOP|OVSK|QXCH|RAND|READ|RELINT|RESUME|RETURN|ROR|RXOR|SQUARE|SU|TCR|TCAA|OVSK|TCF|TC|TS|WAND|WOR|WRITE|XCH|XLQ|XXALQ|ZL|ZQ|ADD|ADZ|SUB|SUZ|MPY|MPR|MPZ|DVP|COM|ABS|CLA|CLZ|LDQ|STO|STQ|ALS|LLS|LRS|TRA|TSQ|TMI|TOV|AXT|TIX|DLY|INP|OUT)\s/,
-null],["typ",/^(?:-?GENADR|=MINUS|2BCADR|VN|BOF|MM|-?2CADR|-?[1-6]DNADR|ADRES|BBCON|[ES]?BANK=?|BLOCK|BNKSUM|E?CADR|COUNT\*?|2?DEC\*?|-?DNCHAN|-?DNPTR|EQUALS|ERASE|MEMORY|2?OCT|REMADR|SETLOC|SUBRO|ORG|BSS|BES|SYN|EQU|DEFINE|END)\s/,null],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[!-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["apollo","agc","aea"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-basic.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-basic.js
deleted file mode 100644
index 6b784d4..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-basic.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["str",/^"(?:[^\n\r"\\]|\\.)*(?:"|$)/,a,'"'],["pln",/^\s+/,a," \r\n\t\u00a0"]],[["com",/^REM[^\n\r]*/,a],["kwd",/^\b(?:AND|CLOSE|CLR|CMD|CONT|DATA|DEF ?FN|DIM|END|FOR|GET|GOSUB|GOTO|IF|INPUT|LET|LIST|LOAD|NEW|NEXT|NOT|ON|OPEN|OR|POKE|PRINT|READ|RESTORE|RETURN|RUN|SAVE|STEP|STOP|SYS|THEN|TO|VERIFY|WAIT)\b/,a],["pln",/^[a-z][^\W_]?(?:\$|%)?/i,a],["lit",/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i,a,"0123456789"],["pun",
-/^.[^\s\w"$%.]*/,a]]),["basic","cbm"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-clj.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-clj.js
deleted file mode 100644
index 1bb539c..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-clj.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- Copyright (C) 2011 Google Inc.
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-*/
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["opn",/^[([{]+/,a,"([{"],["clo",/^[)\]}]+/,a,")]}"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \u00a0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:def|if|do|let|quote|var|fn|loop|recur|throw|try|monitor-enter|monitor-exit|defmacro|defn|defn-|macroexpand|macroexpand-1|for|doseq|dosync|dotimes|and|or|when|not|assert|doto|proxy|defstruct|first|rest|cons|defprotocol|deftype|defrecord|reify|defmulti|defmethod|meta|with-meta|ns|in-ns|create-ns|import|intern|refer|alias|namespace|resolve|ref|deref|refset|new|set!|memfn|to-array|into-array|aset|gen-class|reduce|map|filter|find|nil?|empty?|hash-map|hash-set|vec|vector|seq|flatten|reverse|assoc|dissoc|list|list?|disj|get|union|difference|intersection|extend|extend-type|extend-protocol|prn)\b/,a],
-["typ",/^:[\dA-Za-z-]+/]]),["clj"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-css.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-css.js
deleted file mode 100644
index d7a4640..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-css.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n\u000c"]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]+)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],
-["com",/^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}\b/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-dart.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-dart.js
deleted file mode 100644
index eefccc9..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-dart.js
+++ /dev/null
@@ -1,3 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"]],[["com",/^#!.*/],["kwd",/^\b(?:import|library|part of|part|as|show|hide)\b/i],["com",/^\/\/.*/],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["kwd",/^\b(?:class|interface)\b/i],["kwd",/^\b(?:assert|break|case|catch|continue|default|do|else|finally|for|if|in|is|new|return|super|switch|this|throw|try|while)\b/i],["kwd",/^\b(?:abstract|const|extends|factory|final|get|implements|native|operator|set|static|typedef|var)\b/i],
-["typ",/^\b(?:bool|double|dynamic|int|num|object|string|void)\b/i],["kwd",/^\b(?:false|null|true)\b/i],["str",/^r?'''[\S\s]*?[^\\]'''/],["str",/^r?"""[\S\s]*?[^\\]"""/],["str",/^r?'('|[^\n\f\r]*?[^\\]')/],["str",/^r?"("|[^\n\f\r]*?[^\\]")/],["pln",/^[$_a-z]\w*/i],["pun",/^[!%&*+/:<-?^|~-]/],["lit",/^\b0x[\da-f]+/i],["lit",/^\b\d+(?:\.\d*)?(?:e[+-]?\d+)?/i],["lit",/^\b\.\d+(?:e[+-]?\d+)?/i],["pun",/^[(),.;[\]{}]/]]),
-["dart"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-erlang.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-erlang.js
deleted file mode 100644
index 27214a5..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-erlang.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t-\r ]+/,null,"\t\n\u000b\u000c\r "],["str",/^"(?:[^\n\f\r"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["lit",/^[a-z]\w*/],["lit",/^'(?:[^\n\f\r'\\]|\\[^&])+'?/,null,"'"],["lit",/^\?[^\t\n ({]+/,null,"?"],["lit",/^(?:0o[0-7]+|0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i,null,"0123456789"]],[["com",/^%[^\n]*/],["kwd",/^(?:module|attributes|do|let|in|letrec|apply|call|primop|case|of|end|when|fun|try|catch|receive|after|char|integer|float,atom,string,var)\b/],
-["kwd",/^-[_a-z]+/],["typ",/^[A-Z_]\w*/],["pun",/^[,.;]/]]),["erlang","erl"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-go.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-go.js
deleted file mode 100644
index 1caca23..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-go.js
+++ /dev/null
@@ -1 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["pln",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])+(?:'|$)|`[^`]*(?:`|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\/\*[\S\s]*?\*\/)/],["pln",/^(?:[^"'/`]|\/(?![*/]))+/]]),["go"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-hs.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-hs.js
deleted file mode 100644
index ff3729b..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-hs.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t-\r ]+/,null,"\t\n\u000b\u000c\r "],["str",/^"(?:[^\n\f\r"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["str",/^'(?:[^\n\f\r'\\]|\\[^&])'?/,null,"'"],["lit",/^(?:0o[0-7]+|0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i,null,"0123456789"]],[["com",/^(?:--+[^\n\f\r]*|{-(?:[^-]|-+[^}-])*-})/],["kwd",/^(?:case|class|data|default|deriving|do|else|if|import|in|infix|infixl|infixr|instance|let|module|newtype|of|then|type|where|_)(?=[^\d'A-Za-z]|$)/,
-null],["pln",/^(?:[A-Z][\w']*\.)*[A-Za-z][\w']*/],["pun",/^[^\d\t-\r "'A-Za-z]+/]]),["hs"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lisp.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lisp.js
deleted file mode 100644
index 9c8cfa5..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lisp.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["opn",/^\(+/,a,"("],["clo",/^\)+/,a,")"],["com",/^;[^\n\r]*/,a,";"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \u00a0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:block|c[ad]+r|catch|con[ds]|def(?:ine|un)|do|eq|eql|equal|equalp|eval-when|flet|format|go|if|labels|lambda|let|load-time-value|locally|macrolet|multiple-value-call|nil|progn|progv|quote|require|return-from|setq|symbol-macrolet|t|tagbody|the|throw|unwind)\b/,a],
-["lit",/^[+-]?(?:[#0]x[\da-f]+|\d+\/\d+|(?:\.\d+|\d+(?:\.\d*)?)(?:[de][+-]?\d+)?)/i],["lit",/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[_a-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/i],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["cl","el","lisp","lsp","scm","ss","rkt"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-llvm.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-llvm.js
deleted file mode 100644
index 16fade2..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-llvm.js
+++ /dev/null
@@ -1 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^!?"(?:[^"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["com",/^;[^\n\r]*/,null,";"]],[["pln",/^[!%@](?:[$\-.A-Z_a-z][\w$\-.]*|\d+)/],["kwd",/^[^\W\d]\w*/,null],["lit",/^\d+\.\d+/],["lit",/^(?:\d+|0[Xx][\dA-Fa-f]+)/],["pun",/^[(-*,:<->[\]{}]|\.\.\.$/]]),["llvm","ll"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lua.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lua.js
deleted file mode 100644
index 7e44cca..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-lua.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$))/,null,"\"'"]],[["com",/^--(?:\[(=*)\[[\S\s]*?(?:]\1]|$)|[^\n\r]*)/],["str",/^\[(=*)\[[\S\s]*?(?:]\1]|$)/],["kwd",/^(?:and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/,null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],
-["pln",/^[_a-z]\w*/i],["pun",/^[^\w\t\n\r \xa0][^\w\t\n\r "'+=\xa0-]*/]]),["lua"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-matlab.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-matlab.js
deleted file mode 100644
index d0d3516..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-matlab.js
+++ /dev/null
@@ -1,6 +0,0 @@
-var a=null,b=window.PR,c=[[b.PR_PLAIN,/^[\t-\r \xa0]+/,a," \t\r\n\u000b\u000c\u00a0"],[b.PR_COMMENT,/^%{[^%]*%+(?:[^%}][^%]*%+)*}/,a],[b.PR_COMMENT,/^%[^\n\r]*/,a,"%"],["syscmd",/^![^\n\r]*/,a,"!"]],d=[["linecont",/^\.\.\.\s*[\n\r]/,a],["err",/^\?\?\? [^\n\r]*/,a],["wrn",/^Warning: [^\n\r]*/,a],["codeoutput",/^>>\s+/,a],["codeoutput",/^octave:\d+>\s+/,a],["lang-matlab-operators",/^((?:[A-Za-z]\w*(?:\.[A-Za-z]\w*)*|[).\]}])')/,a],["lang-matlab-identifiers",/^([A-Za-z]\w*(?:\.[A-Za-z]\w*)*)(?!')/,a],
-[b.PR_STRING,/^'(?:[^']|'')*'/,a],[b.PR_LITERAL,/^[+-]?\.?\d+(?:\.\d*)?(?:[Ee][+-]?\d+)?[ij]?/,a],[b.PR_TAG,/^[()[\]{}]/,a],[b.PR_PUNCTUATION,/^[!&*-/:->@\\^|~]/,a]],e=[["lang-matlab-identifiers",/^([A-Za-z]\w*(?:\.[A-Za-z]\w*)*)/,a],[b.PR_TAG,/^[()[\]{}]/,a],[b.PR_PUNCTUATION,/^[!&*-/:->@\\^|~]/,a],["transpose",/^'/,a]];
-b.registerLangHandler(b.createSimpleLexer([],[[b.PR_KEYWORD,/^\b(?:break|case|catch|classdef|continue|else|elseif|end|for|function|global|if|otherwise|parfor|persistent|return|spmd|switch|try|while)\b/,a],["const",/^\b(?:true|false|inf|Inf|nan|NaN|eps|pi|ans|nargin|nargout|varargin|varargout)\b/,a],[b.PR_TYPE,/^\b(?:cell|struct|char|double|single|logical|u?int(?:8|16|32|64)|sparse)\b/,a],["fun",/^\b(?:abs|accumarray|acos(?:d|h)?|acot(?:d|h)?|acsc(?:d|h)?|actxcontrol(?:list|select)?|actxGetRunningServer|actxserver|addlistener|addpath|addpref|addtodate|airy|align|alim|all|allchild|alpha|alphamap|amd|ancestor|and|angle|annotation|any|area|arrayfun|asec(?:d|h)?|asin(?:d|h)?|assert|assignin|atan[2dh]?|audiodevinfo|audioplayer|audiorecorder|aufinfo|auread|autumn|auwrite|avifile|aviinfo|aviread|axes|axis|balance|bar(?:3|3h|h)?|base2dec|beep|BeginInvoke|bench|bessel[h-ky]|beta|betainc|betaincinv|betaln|bicg|bicgstab|bicgstabl|bin2dec|bitand|bitcmp|bitget|bitmax|bitnot|bitor|bitset|bitshift|bitxor|blanks|blkdiag|bone|box|brighten|brush|bsxfun|builddocsearchdb|builtin|bvp4c|bvp5c|bvpget|bvpinit|bvpset|bvpxtend|calendar|calllib|callSoapService|camdolly|cameratoolbar|camlight|camlookat|camorbit|campan|campos|camproj|camroll|camtarget|camup|camva|camzoom|cart2pol|cart2sph|cast|cat|caxis|cd|cdf2rdf|cdfepoch|cdfinfo|cdflib(?:.(?:close|closeVar|computeEpoch|computeEpoch16|create|createAttr|createVar|delete|deleteAttr|deleteAttrEntry|deleteAttrgEntry|deleteVar|deleteVarRecords|epoch16Breakdown|epochBreakdown|getAttrEntry|getAttrgEntry|getAttrMaxEntry|getAttrMaxgEntry|getAttrName|getAttrNum|getAttrScope|getCacheSize|getChecksum|getCompression|getCompressionCacheSize|getConstantNames|getConstantValue|getCopyright|getFileBackward|getFormat|getLibraryCopyright|getLibraryVersion|getMajority|getName|getNumAttrEntries|getNumAttrgEntries|getNumAttributes|getNumgAttributes|getReadOnlyMode|getStageCacheSize|getValidate|getVarAllocRecords|getVarBlockingFactor|getVarCacheSize|getVarCompression|getVarData|getVarMaxAllocRecNum|getVarMaxWrittenRecNum|getVarName|getVarNum|getVarNumRecsWritten|getVarPadValue|getVarRecordData|getVarReservePercent|getVarsMaxWrittenRecNum|getVarSparseRecords|getVersion|hyperGetVarData|hyperPutVarData|inquire|inquireAttr|inquireAttrEntry|inquireAttrgEntry|inquireVar|open|putAttrEntry|putAttrgEntry|putVarData|putVarRecordData|renameAttr|renameVar|setCacheSize|setChecksum|setCompression|setCompressionCacheSize|setFileBackward|setFormat|setMajority|setReadOnlyMode|setStageCacheSize|setValidate|setVarAllocBlockRecords|setVarBlockingFactor|setVarCacheSize|setVarCompression|setVarInitialRecs|setVarPadValue|SetVarReservePercent|setVarsCacheSize|setVarSparseRecords))?|cdfread|cdfwrite|ceil|cell2mat|cell2struct|celldisp|cellfun|cellplot|cellstr|cgs|checkcode|checkin|checkout|chol|cholinc|cholupdate|circshift|cla|clabel|class|clc|clear|clearvars|clf|clipboard|clock|close|closereq|cmopts|cmpermute|cmunique|colamd|colon|colorbar|colordef|colormap|colormapeditor|colperm|Combine|comet|comet3|commandhistory|commandwindow|compan|compass|complex|computer|cond|condeig|condest|coneplot|conj|containers.Map|contour(?:[3cf]|slice)?|contrast|conv|conv2|convhull|convhulln|convn|cool|copper|copyfile|copyobj|corrcoef|cos(?:d|h)?|cot(?:d|h)?|cov|cplxpair|cputime|createClassFromWsdl|createSoapMessage|cross|csc(?:d|h)?|csvread|csvwrite|ctranspose|cumprod|cumsum|cumtrapz|curl|customverctrl|cylinder|daqread|daspect|datacursormode|datatipinfo|date|datenum|datestr|datetick|datevec|dbclear|dbcont|dbdown|dblquad|dbmex|dbquit|dbstack|dbstatus|dbstep|dbstop|dbtype|dbup|dde23|ddeget|ddesd|ddeset|deal|deblank|dec2base|dec2bin|dec2hex|decic|deconv|del2|delaunay|delaunay3|delaunayn|DelaunayTri|delete|demo|depdir|depfun|det|detrend|deval|diag|dialog|diary|diff|diffuse|dir|disp|display|dither|divergence|dlmread|dlmwrite|dmperm|doc|docsearch|dos|dot|dragrect|drawnow|dsearch|dsearchn|dynamicprops|echo|echodemo|edit|eig|eigs|ellipj|ellipke|ellipsoid|empty|enableNETfromNetworkDrive|enableservice|EndInvoke|enumeration|eomday|eq|erf|erfc|erfcinv|erfcx|erfinv|error|errorbar|errordlg|etime|etree|etreeplot|eval|evalc|evalin|event.(?:EventData|listener|PropertyEvent|proplistener)|exifread|exist|exit|exp|expint|expm|expm1|export2wsdlg|eye|ezcontour|ezcontourf|ezmesh|ezmeshc|ezplot|ezplot3|ezpolar|ezsurf|ezsurfc|factor|factorial|fclose|feather|feature|feof|ferror|feval|fft|fft2|fftn|fftshift|fftw|fgetl|fgets|fieldnames|figure|figurepalette|fileattrib|filebrowser|filemarker|fileparts|fileread|filesep|fill|fill3|filter|filter2|find|findall|findfigs|findobj|findstr|finish|fitsdisp|fitsinfo|fitsread|fitswrite|fix|flag|flipdim|fliplr|flipud|floor|flow|fminbnd|fminsearch|fopen|format|fplot|fprintf|frame2im|fread|freqspace|frewind|fscanf|fseek|ftell|FTP|full|fullfile|func2str|functions|funm|fwrite|fzero|gallery|gamma|gammainc|gammaincinv|gammaln|gca|gcbf|gcbo|gcd|gcf|gco|ge|genpath|genvarname|get|getappdata|getenv|getfield|getframe|getpixelposition|getpref|ginput|gmres|gplot|grabcode|gradient|gray|graymon|grid|griddata(?:3|n)?|griddedInterpolant|gsvd|gt|gtext|guidata|guide|guihandles|gunzip|gzip|h5create|h5disp|h5info|h5read|h5readatt|h5write|h5writeatt|hadamard|handle|hankel|hdf|hdf5|hdf5info|hdf5read|hdf5write|hdfinfo|hdfread|hdftool|help|helpbrowser|helpdesk|helpdlg|helpwin|hess|hex2dec|hex2num|hgexport|hggroup|hgload|hgsave|hgsetget|hgtransform|hidden|hilb|hist|histc|hold|home|horzcat|hostid|hot|hsv|hsv2rgb|hypot|ichol|idivide|ifft|ifft2|ifftn|ifftshift|ilu|im2frame|im2java|imag|image|imagesc|imapprox|imfinfo|imformats|import|importdata|imread|imwrite|ind2rgb|ind2sub|inferiorto|info|inline|inmem|inpolygon|input|inputdlg|inputname|inputParser|inspect|instrcallback|instrfind|instrfindall|int2str|integral(?:2|3)?|interp(?:1|1q|2|3|ft|n)|interpstreamspeed|intersect|intmax|intmin|inv|invhilb|ipermute|isa|isappdata|iscell|iscellstr|ischar|iscolumn|isdir|isempty|isequal|isequaln|isequalwithequalnans|isfield|isfinite|isfloat|isglobal|ishandle|ishghandle|ishold|isinf|isinteger|isjava|iskeyword|isletter|islogical|ismac|ismatrix|ismember|ismethod|isnan|isnumeric|isobject|isocaps|isocolors|isonormals|isosurface|ispc|ispref|isprime|isprop|isreal|isrow|isscalar|issorted|isspace|issparse|isstr|isstrprop|isstruct|isstudent|isunix|isvarname|isvector|javaaddpath|javaArray|javachk|javaclasspath|javacomponent|javaMethod|javaMethodEDT|javaObject|javaObjectEDT|javarmpath|jet|keyboard|kron|lasterr|lasterror|lastwarn|lcm|ldivide|ldl|le|legend|legendre|length|libfunctions|libfunctionsview|libisloaded|libpointer|libstruct|license|light|lightangle|lighting|lin2mu|line|lines|linkaxes|linkdata|linkprop|linsolve|linspace|listdlg|listfonts|load|loadlibrary|loadobj|log|log10|log1p|log2|loglog|logm|logspace|lookfor|lower|ls|lscov|lsqnonneg|lsqr|lt|lu|luinc|magic|makehgtform|mat2cell|mat2str|material|matfile|matlab.io.MatFile|matlab.mixin.(?:Copyable|Heterogeneous(?:.getDefaultScalarElement)?)|matlabrc|matlabroot|max|maxNumCompThreads|mean|median|membrane|memmapfile|memory|menu|mesh|meshc|meshgrid|meshz|meta.(?:class(?:.fromName)?|DynamicProperty|EnumeratedValue|event|MetaData|method|package(?:.(?:fromName|getAllPackages))?|property)|metaclass|methods|methodsview|mex(?:.getCompilerConfigurations)?|MException|mexext|mfilename|min|minres|minus|mislocked|mkdir|mkpp|mldivide|mlint|mlintrpt|mlock|mmfileinfo|mmreader|mod|mode|more|move|movefile|movegui|movie|movie2avi|mpower|mrdivide|msgbox|mtimes|mu2lin|multibandread|multibandwrite|munlock|namelengthmax|nargchk|narginchk|nargoutchk|native2unicode|nccreate|ncdisp|nchoosek|ncinfo|ncread|ncreadatt|ncwrite|ncwriteatt|ncwriteschema|ndgrid|ndims|ne|NET(?:.(?:addAssembly|Assembly|convertArray|createArray|createGeneric|disableAutoRelease|enableAutoRelease|GenericClass|invokeGenericMethod|NetException|setStaticProperty))?|netcdf.(?:abort|close|copyAtt|create|defDim|defGrp|defVar|defVarChunking|defVarDeflate|defVarFill|defVarFletcher32|delAtt|endDef|getAtt|getChunkCache|getConstant|getConstantNames|getVar|inq|inqAtt|inqAttID|inqAttName|inqDim|inqDimID|inqDimIDs|inqFormat|inqGrpName|inqGrpNameFull|inqGrpParent|inqGrps|inqLibVers|inqNcid|inqUnlimDims|inqVar|inqVarChunking|inqVarDeflate|inqVarFill|inqVarFletcher32|inqVarID|inqVarIDs|open|putAtt|putVar|reDef|renameAtt|renameDim|renameVar|setChunkCache|setDefaultFormat|setFill|sync)|newplot|nextpow2|nnz|noanimate|nonzeros|norm|normest|not|notebook|now|nthroot|null|num2cell|num2hex|num2str|numel|nzmax|ode(?:113|15i|15s|23|23s|23t|23tb|45)|odeget|odeset|odextend|onCleanup|ones|open|openfig|opengl|openvar|optimget|optimset|or|ordeig|orderfields|ordqz|ordschur|orient|orth|pack|padecoef|pagesetupdlg|pan|pareto|parseSoapResponse|pascal|patch|path|path2rc|pathsep|pathtool|pause|pbaspect|pcg|pchip|pcode|pcolor|pdepe|pdeval|peaks|perl|perms|permute|pie|pink|pinv|planerot|playshow|plot|plot3|plotbrowser|plotedit|plotmatrix|plottools|plotyy|plus|pol2cart|polar|poly|polyarea|polyder|polyeig|polyfit|polyint|polyval|polyvalm|pow2|power|ppval|prefdir|preferences|primes|print|printdlg|printopt|printpreview|prod|profile|profsave|propedit|propertyeditor|psi|publish|PutCharArray|PutFullMatrix|PutWorkspaceData|pwd|qhull|qmr|qr|qrdelete|qrinsert|qrupdate|quad|quad2d|quadgk|quadl|quadv|questdlg|quit|quiver|quiver3|qz|rand|randi|randn|randperm|RandStream(?:.(?:create|getDefaultStream|getGlobalStream|list|setDefaultStream|setGlobalStream))?|rank|rat|rats|rbbox|rcond|rdivide|readasync|real|reallog|realmax|realmin|realpow|realsqrt|record|rectangle|rectint|recycle|reducepatch|reducevolume|refresh|refreshdata|regexp|regexpi|regexprep|regexptranslate|rehash|rem|Remove|RemoveAll|repmat|reset|reshape|residue|restoredefaultpath|rethrow|rgb2hsv|rgb2ind|rgbplot|ribbon|rmappdata|rmdir|rmfield|rmpath|rmpref|rng|roots|rose|rosser|rot90|rotate|rotate3d|round|rref|rsf2csf|run|save|saveas|saveobj|savepath|scatter|scatter3|schur|sec|secd|sech|selectmoveresize|semilogx|semilogy|sendmail|serial|set|setappdata|setdiff|setenv|setfield|setpixelposition|setpref|setstr|setxor|shading|shg|shiftdim|showplottool|shrinkfaces|sign|sin(?:d|h)?|size|slice|smooth3|snapnow|sort|sortrows|sound|soundsc|spalloc|spaugment|spconvert|spdiags|specular|speye|spfun|sph2cart|sphere|spinmap|spline|spones|spparms|sprand|sprandn|sprandsym|sprank|spring|sprintf|spy|sqrt|sqrtm|squeeze|ss2tf|sscanf|stairs|startup|std|stem|stem3|stopasync|str2double|str2func|str2mat|str2num|strcat|strcmp|strcmpi|stream2|stream3|streamline|streamparticles|streamribbon|streamslice|streamtube|strfind|strjust|strmatch|strncmp|strncmpi|strread|strrep|strtok|strtrim|struct2cell|structfun|strvcat|sub2ind|subplot|subsasgn|subsindex|subspace|subsref|substruct|subvolume|sum|summer|superclasses|superiorto|support|surf|surf2patch|surface|surfc|surfl|surfnorm|svd|svds|swapbytes|symamd|symbfact|symmlq|symrcm|symvar|system|tan(?:d|h)?|tar|tempdir|tempname|tetramesh|texlabel|text|textread|textscan|textwrap|tfqmr|throw|tic|Tiff(?:.(?:getTagNames|getVersion))?|timer|timerfind|timerfindall|times|timeseries|title|toc|todatenum|toeplitz|toolboxdir|trace|transpose|trapz|treelayout|treeplot|tril|trimesh|triplequad|triplot|TriRep|TriScatteredInterp|trisurf|triu|tscollection|tsearch|tsearchn|tstool|type|typecast|uibuttongroup|uicontextmenu|uicontrol|uigetdir|uigetfile|uigetpref|uiimport|uimenu|uiopen|uipanel|uipushtool|uiputfile|uiresume|uisave|uisetcolor|uisetfont|uisetpref|uistack|uitable|uitoggletool|uitoolbar|uiwait|uminus|undocheckout|unicode2native|union|unique|unix|unloadlibrary|unmesh|unmkpp|untar|unwrap|unzip|uplus|upper|urlread|urlwrite|usejava|userpath|validateattributes|validatestring|vander|var|vectorize|ver|verctrl|verLessThan|version|vertcat|VideoReader(?:.isPlatformSupported)?|VideoWriter(?:.getProfiles)?|view|viewmtx|visdiff|volumebounds|voronoi|voronoin|wait|waitbar|waitfor|waitforbuttonpress|warndlg|warning|waterfall|wavfinfo|wavplay|wavread|wavrecord|wavwrite|web|weekday|what|whatsnew|which|whitebg|who|whos|wilkinson|winopen|winqueryreg|winter|wk1finfo|wk1read|wk1write|workspace|xlabel|xlim|xlsfinfo|xlsread|xlswrite|xmlread|xmlwrite|xor|xslt|ylabel|ylim|zeros|zip|zlabel|zlim|zoom)\b/,
-a],["fun_tbx",/^\b(?:addedvarplot|andrewsplot|anova[12n]|ansaribradley|aoctool|barttest|bbdesign|beta(?:cdf|fit|inv|like|pdf|rnd|stat)|bino(?:cdf|fit|inv|pdf|rnd|stat)|biplot|bootci|bootstrp|boxplot|candexch|candgen|canoncorr|capability|capaplot|caseread|casewrite|categorical|ccdesign|cdfplot|chi2(?:cdf|gof|inv|pdf|rnd|stat)|cholcov|Classification(?:BaggedEnsemble|Discriminant(?:.(?:fit|make|template))?|Ensemble|KNN(?:.(?:fit|template))?|PartitionedEnsemble|PartitionedModel|Tree(?:.(?:fit|template))?)|classify|classregtree|cluster|clusterdata|cmdscale|combnk|Compact(?:Classification(?:Discriminant|Ensemble|Tree)|Regression(?:Ensemble|Tree)|TreeBagger)|confusionmat|controlchart|controlrules|cophenet|copula(?:cdf|fit|param|pdf|rnd|stat)|cordexch|corr|corrcov|coxphfit|createns|crosstab|crossval|cvpartition|datasample|dataset|daugment|dcovary|dendrogram|dfittool|disttool|dummyvar|dwtest|ecdf|ecdfhist|ev(?:cdf|fit|inv|like|pdf|rnd|stat)|ExhaustiveSearcher|exp(?:cdf|fit|inv|like|pdf|rnd|stat)|factoran|fcdf|ff2n|finv|fitdist|fitensemble|fpdf|fracfact|fracfactgen|friedman|frnd|fstat|fsurfht|fullfact|gagerr|gam(?:cdf|fit|inv|like|pdf|rnd|stat)|GeneralizedLinearModel(?:.fit)?|geo(?:cdf|inv|mean|pdf|rnd|stat)|gev(?:cdf|fit|inv|like|pdf|rnd|stat)|gline|glmfit|glmval|glyphplot|gmdistribution(?:.fit)?|gname|gp(?:cdf|fit|inv|like|pdf|rnd|stat)|gplotmatrix|grp2idx|grpstats|gscatter|haltonset|harmmean|hist3|histfit|hmm(?:decode|estimate|generate|train|viterbi)|hougen|hyge(?:cdf|inv|pdf|rnd|stat)|icdf|inconsistent|interactionplot|invpred|iqr|iwishrnd|jackknife|jbtest|johnsrnd|KDTreeSearcher|kmeans|knnsearch|kruskalwallis|ksdensity|kstest|kstest2|kurtosis|lasso|lassoglm|lassoPlot|leverage|lhsdesign|lhsnorm|lillietest|LinearModel(?:.fit)?|linhyptest|linkage|logn(?:cdf|fit|inv|like|pdf|rnd|stat)|lsline|mad|mahal|maineffectsplot|manova1|manovacluster|mdscale|mhsample|mle|mlecov|mnpdf|mnrfit|mnrnd|mnrval|moment|multcompare|multivarichart|mvn(?:cdf|pdf|rnd)|mvregress|mvregresslike|mvt(?:cdf|pdf|rnd)|NaiveBayes(?:.fit)?|nan(?:cov|max|mean|median|min|std|sum|var)|nbin(?:cdf|fit|inv|pdf|rnd|stat)|ncf(?:cdf|inv|pdf|rnd|stat)|nct(?:cdf|inv|pdf|rnd|stat)|ncx2(?:cdf|inv|pdf|rnd|stat)|NeighborSearcher|nlinfit|nlintool|nlmefit|nlmefitsa|nlparci|nlpredci|nnmf|nominal|NonLinearModel(?:.fit)?|norm(?:cdf|fit|inv|like|pdf|rnd|stat)|normplot|normspec|ordinal|outlierMeasure|parallelcoords|paretotails|partialcorr|pcacov|pcares|pdf|pdist|pdist2|pearsrnd|perfcurve|perms|piecewisedistribution|plsregress|poiss(?:cdf|fit|inv|pdf|rnd|tat)|polyconf|polytool|prctile|princomp|ProbDist(?:Kernel|Parametric|UnivKernel|UnivParam)?|probplot|procrustes|qqplot|qrandset|qrandstream|quantile|randg|random|randsample|randtool|range|rangesearch|ranksum|rayl(?:cdf|fit|inv|pdf|rnd|stat)|rcoplot|refcurve|refline|regress|Regression(?:BaggedEnsemble|Ensemble|PartitionedEnsemble|PartitionedModel|Tree(?:.(?:fit|template))?)|regstats|relieff|ridge|robustdemo|robustfit|rotatefactors|rowexch|rsmdemo|rstool|runstest|sampsizepwr|scatterhist|sequentialfs|signrank|signtest|silhouette|skewness|slicesample|sobolset|squareform|statget|statset|stepwise|stepwisefit|surfht|tabulate|tblread|tblwrite|tcdf|tdfread|tiedrank|tinv|tpdf|TreeBagger|treedisp|treefit|treeprune|treetest|treeval|trimmean|trnd|tstat|ttest|ttest2|unid(?:cdf|inv|pdf|rnd|stat)|unif(?:cdf|inv|it|pdf|rnd|stat)|vartest(?:2|n)?|wbl(?:cdf|fit|inv|like|pdf|rnd|stat)|wblplot|wishrnd|x2fx|xptread|zscore|ztest)\b/,
-a],["fun_tbx",/^\b(?:adapthisteq|analyze75info|analyze75read|applycform|applylut|axes2pix|bestblk|blockproc|bwarea|bwareaopen|bwboundaries|bwconncomp|bwconvhull|bwdist|bwdistgeodesic|bweuler|bwhitmiss|bwlabel|bwlabeln|bwmorph|bwpack|bwperim|bwselect|bwtraceboundary|bwulterode|bwunpack|checkerboard|col2im|colfilt|conndef|convmtx2|corner|cornermetric|corr2|cp2tform|cpcorr|cpselect|cpstruct2pairs|dct2|dctmtx|deconvblind|deconvlucy|deconvreg|deconvwnr|decorrstretch|demosaic|dicom(?:anon|dict|info|lookup|read|uid|write)|edge|edgetaper|entropy|entropyfilt|fan2para|fanbeam|findbounds|fliptform|freqz2|fsamp2|fspecial|ftrans2|fwind1|fwind2|getheight|getimage|getimagemodel|getline|getneighbors|getnhood|getpts|getrangefromclass|getrect|getsequence|gray2ind|graycomatrix|graycoprops|graydist|grayslice|graythresh|hdrread|hdrwrite|histeq|hough|houghlines|houghpeaks|iccfind|iccread|iccroot|iccwrite|idct2|ifanbeam|im2bw|im2col|im2double|im2int16|im2java2d|im2single|im2uint16|im2uint8|imabsdiff|imadd|imadjust|ImageAdapter|imageinfo|imagemodel|imapplymatrix|imattributes|imbothat|imclearborder|imclose|imcolormaptool|imcomplement|imcontour|imcontrast|imcrop|imdilate|imdisplayrange|imdistline|imdivide|imellipse|imerode|imextendedmax|imextendedmin|imfill|imfilter|imfindcircles|imfreehand|imfuse|imgca|imgcf|imgetfile|imhandles|imhist|imhmax|imhmin|imimposemin|imlincomb|imline|immagbox|immovie|immultiply|imnoise|imopen|imoverview|imoverviewpanel|impixel|impixelinfo|impixelinfoval|impixelregion|impixelregionpanel|implay|impoint|impoly|impositionrect|improfile|imputfile|impyramid|imreconstruct|imrect|imregconfig|imregionalmax|imregionalmin|imregister|imresize|imroi|imrotate|imsave|imscrollpanel|imshow|imshowpair|imsubtract|imtool|imtophat|imtransform|imview|ind2gray|ind2rgb|interfileinfo|interfileread|intlut|ippl|iptaddcallback|iptcheckconn|iptcheckhandle|iptcheckinput|iptcheckmap|iptchecknargin|iptcheckstrs|iptdemos|iptgetapi|iptGetPointerBehavior|iptgetpref|ipticondir|iptnum2ordinal|iptPointerManager|iptprefs|iptremovecallback|iptSetPointerBehavior|iptsetpref|iptwindowalign|iradon|isbw|isflat|isgray|isicc|isind|isnitf|isrgb|isrset|lab2double|lab2uint16|lab2uint8|label2rgb|labelmatrix|makecform|makeConstrainToRectFcn|makehdr|makelut|makeresampler|maketform|mat2gray|mean2|medfilt2|montage|nitfinfo|nitfread|nlfilter|normxcorr2|ntsc2rgb|openrset|ordfilt2|otf2psf|padarray|para2fan|phantom|poly2mask|psf2otf|qtdecomp|qtgetblk|qtsetblk|radon|rangefilt|reflect|regionprops|registration.metric.(?:MattesMutualInformation|MeanSquares)|registration.optimizer.(?:OnePlusOneEvolutionary|RegularStepGradientDescent)|rgb2gray|rgb2ntsc|rgb2ycbcr|roicolor|roifill|roifilt2|roipoly|rsetwrite|std2|stdfilt|strel|stretchlim|subimage|tformarray|tformfwd|tforminv|tonemap|translate|truesize|uintlut|viscircles|warp|watershed|whitepoint|wiener2|xyz2double|xyz2uint16|ycbcr2rgb)\b/,
-a],["fun_tbx",/^\b(?:bintprog|color|fgoalattain|fminbnd|fmincon|fminimax|fminsearch|fminunc|fseminf|fsolve|fzero|fzmult|gangstr|ktrlink|linprog|lsqcurvefit|lsqlin|lsqnonlin|lsqnonneg|optimget|optimset|optimtool|quadprog)\b/,a],["ident",/^[A-Za-z]\w*(?:\.[A-Za-z]\w*)*/,a]]),["matlab-identifiers"]);b.registerLangHandler(b.createSimpleLexer([],e),["matlab-operators"]);b.registerLangHandler(b.createSimpleLexer(c,d),["matlab"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-ml.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-ml.js
deleted file mode 100644
index 8ed2b0c..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-ml.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["com",/^#(?:if[\t\n\r \xa0]+(?:[$_a-z][\w']*|``[^\t\n\r`]*(?:``|$))|else|endif|light)/i,null,"#"],["str",/^(?:"(?:[^"\\]|\\[\S\s])*(?:"|$)|'(?:[^'\\]|\\[\S\s])(?:'|$))/,null,"\"'"]],[["com",/^(?:\/\/[^\n\r]*|\(\*[\S\s]*?\*\))/],["kwd",/^(?:abstract|and|as|assert|begin|class|default|delegate|do|done|downcast|downto|elif|else|end|exception|extern|false|finally|for|fun|function|if|in|inherit|inline|interface|internal|lazy|let|match|member|module|mutable|namespace|new|null|of|open|or|override|private|public|rec|return|static|struct|then|to|true|try|type|upcast|use|val|void|when|while|with|yield|asr|land|lor|lsl|lsr|lxor|mod|sig|atomic|break|checked|component|const|constraint|constructor|continue|eager|event|external|fixed|functor|global|include|method|mixin|object|parallel|process|protected|pure|sealed|trait|virtual|volatile)\b/],
-["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^(?:[_a-z][\w']*[!#?]?|``[^\t\n\r`]*(?:``|$))/i],["pun",/^[^\w\t\n\r "'\xa0]+/]]),["fs","ml"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-mumps.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-mumps.js
deleted file mode 100644
index 8a6b3fd..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-mumps.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^"(?:[^"]|\\.)*"/,null,'"']],[["com",/^;[^\n\r]*/,null,";"],["dec",/^\$(?:d|device|ec|ecode|es|estack|et|etrap|h|horolog|i|io|j|job|k|key|p|principal|q|quit|st|stack|s|storage|sy|system|t|test|tl|tlevel|tr|trestart|x|y|z[a-z]*|a|ascii|c|char|d|data|e|extract|f|find|fn|fnumber|g|get|j|justify|l|length|na|name|o|order|p|piece|ql|qlength|qs|qsubscript|q|query|r|random|re|reverse|s|select|st|stack|t|text|tr|translate|nan)\b/i,
-null],["kwd",/^(?:[^$]b|break|c|close|d|do|e|else|f|for|g|goto|h|halt|h|hang|i|if|j|job|k|kill|l|lock|m|merge|n|new|o|open|q|quit|r|read|s|set|tc|tcommit|tre|trestart|tro|trollback|ts|tstart|u|use|v|view|w|write|x|xecute)\b/i,null],["lit",/^[+-]?(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?/i],["pln",/^[a-z][^\W_]*/i],["pun",/^[^\w\t\n\r"$%;^\xa0]|_/]]),["mumps"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-n.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-n.js
deleted file mode 100644
index 27812a5..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-n.js
+++ /dev/null
@@ -1,4 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["str",/^(?:'(?:[^\n\r'\\]|\\.)*'|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,a,'"'],["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,a,"#"],["pln",/^\s+/,a," \r\n\t\u00a0"]],[["str",/^@"(?:[^"]|"")*(?:"|$)/,a],["str",/^<#[^#>]*(?:#>|$)/,a],["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,a],["com",/^\/\/[^\n\r]*/,a],["com",/^\/\*[\S\s]*?(?:\*\/|$)/,
-a],["kwd",/^(?:abstract|and|as|base|catch|class|def|delegate|enum|event|extern|false|finally|fun|implements|interface|internal|is|macro|match|matches|module|mutable|namespace|new|null|out|override|params|partial|private|protected|public|ref|sealed|static|struct|syntax|this|throw|true|try|type|typeof|using|variant|virtual|volatile|when|where|with|assert|assert2|async|break|checked|continue|do|else|ensures|for|foreach|if|late|lock|new|nolate|otherwise|regexp|repeat|requires|return|surroundwith|unchecked|unless|using|while|yield)\b/,
-a],["typ",/^(?:array|bool|byte|char|decimal|double|float|int|list|long|object|sbyte|short|string|ulong|uint|ufloat|ulong|ushort|void)\b/,a],["lit",/^@[$_a-z][\w$@]*/i,a],["typ",/^@[A-Z]+[a-z][\w$@]*/,a],["pln",/^'?[$_a-z][\w$@]*/i,a],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,a,"0123456789"],["pun",/^.[^\s\w"-$'./@`]*/,a]]),["n","nemerle"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-pascal.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-pascal.js
deleted file mode 100644
index 8435fad..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-pascal.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["str",/^'(?:[^\n\r'\\]|\\.)*(?:'|$)/,a,"'"],["pln",/^\s+/,a," \r\n\t\u00a0"]],[["com",/^\(\*[\S\s]*?(?:\*\)|$)|^{[\S\s]*?(?:}|$)/,a],["kwd",/^(?:absolute|and|array|asm|assembler|begin|case|const|constructor|destructor|div|do|downto|else|end|external|for|forward|function|goto|if|implementation|in|inline|interface|interrupt|label|mod|not|object|of|or|packed|procedure|program|record|repeat|set|shl|shr|then|to|type|unit|until|uses|var|virtual|while|with|xor)\b/i,a],
-["lit",/^(?:true|false|self|nil)/i,a],["pln",/^[a-z][^\W_]*/i,a],["lit",/^(?:\$[\da-f]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/i,a,"0123456789"],["pun",/^.[^\s\w$'./@]*/,a]]),["pascal"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-proto.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-proto.js
deleted file mode 100644
index f006ad8..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-proto.js
+++ /dev/null
@@ -1 +0,0 @@
-PR.registerLangHandler(PR.sourceDecorator({keywords:"bytes,default,double,enum,extend,extensions,false,group,import,max,message,option,optional,package,repeated,required,returns,rpc,service,syntax,to,true",types:/^(bool|(double|s?fixed|[su]?int)(32|64)|float|string)\b/,cStyleComments:!0}),["proto"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-r.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-r.js
deleted file mode 100644
index 99af8f8..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-r.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,null,'"'],["str",/^'(?:[^'\\]|\\[\S\s])*(?:'|$)/,null,"'"]],[["com",/^#.*/],["kwd",/^(?:if|else|for|while|repeat|in|next|break|return|switch|function)(?![\w.])/],["lit",/^0[Xx][\dA-Fa-f]+([Pp]\d+)?[Li]?/],["lit",/^[+-]?(\d+(\.\d+)?|\.\d+)([Ee][+-]?\d+)?[Li]?/],["lit",/^(?:NULL|NA(?:_(?:integer|real|complex|character)_)?|Inf|TRUE|FALSE|NaN|\.\.(?:\.|\d+))(?![\w.])/],
-["pun",/^(?:<<?-|->>?|-|==|<=|>=|<|>|&&?|!=|\|\|?|[!*+/^]|%.*?%|[$=@~]|:{1,3}|[(),;?[\]{}])/],["pln",/^(?:[A-Za-z]+[\w.]*|\.[^\W\d][\w.]*)(?![\w.])/],["str",/^`.+`/]]),["r","s","R","S","Splus"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-rd.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-rd.js
deleted file mode 100644
index 7a7e43f..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-rd.js
+++ /dev/null
@@ -1 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["com",/^%[^\n\r]*/,null,"%"]],[["lit",/^\\(?:cr|l?dots|R|tab)\b/],["kwd",/^\\[@-Za-z]+/],["kwd",/^#(?:ifn?def|endif)/],["pln",/^\\[{}]/],["pun",/^[()[\]{}]+/]]),["Rd","rd"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-scala.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-scala.js
deleted file mode 100644
index 3f97dba..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-scala.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^"(?:""(?:""?(?!")|[^"\\]|\\.)*"{0,3}|(?:[^\n\r"\\]|\\.)*"?)/,null,'"'],["lit",/^`(?:[^\n\r\\`]|\\.)*`?/,null,"`"],["pun",/^[!#%&(--:-@[-^{-~]+/,null,"!#%&()*+,-:;<=>?@[\\]^{|}~"]],[["str",/^'(?:[^\n\r'\\]|\\(?:'|[^\n\r']+))'/],["lit",/^'[$A-Z_a-z][\w$]*(?![\w$'])/],["kwd",/^(?:abstract|case|catch|class|def|do|else|extends|final|finally|for|forSome|if|implicit|import|lazy|match|new|object|override|package|private|protected|requires|return|sealed|super|throw|trait|try|type|val|var|while|with|yield)\b/],
-["lit",/^(?:true|false|null|this)\b/],["lit",/^(?:0(?:[0-7]+|x[\da-f]+)l?|(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:e[+-]?\d+)?f?|l?)|\\.\d+(?:e[+-]?\d+)?f?)/i],["typ",/^[$_]*[A-Z][\d$A-Z_]*[a-z][\w$]*/],["pln",/^[$A-Z_a-z][\w$]*/],["com",/^\/(?:\/.*|\*(?:\/|\**[^*/])*(?:\*+\/?)?)/],["pun",/^(?:\.+|\/)/]]),["scala"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-sql.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-sql.js
deleted file mode 100644
index 8ec4280..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-sql.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["str",/^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/,null,"\"'"]],[["com",/^(?:--[^\n\r]*|\/\*[\S\s]*?(?:\*\/|$))/],["kwd",/^(?:add|all|alter|and|any|apply|as|asc|authorization|backup|begin|between|break|browse|bulk|by|cascade|case|check|checkpoint|close|clustered|coalesce|collate|column|commit|compute|connect|constraint|contains|containstable|continue|convert|create|cross|current|current_date|current_time|current_timestamp|current_user|cursor|database|dbcc|deallocate|declare|default|delete|deny|desc|disk|distinct|distributed|double|drop|dummy|dump|else|end|errlvl|escape|except|exec|execute|exists|exit|fetch|file|fillfactor|following|for|foreign|freetext|freetexttable|from|full|function|goto|grant|group|having|holdlock|identity|identitycol|identity_insert|if|in|index|inner|insert|intersect|into|is|join|key|kill|left|like|lineno|load|match|matched|merge|natural|national|nocheck|nonclustered|nocycle|not|null|nullif|of|off|offsets|on|open|opendatasource|openquery|openrowset|openxml|option|or|order|outer|over|partition|percent|pivot|plan|preceding|precision|primary|print|proc|procedure|public|raiserror|read|readtext|reconfigure|references|replication|restore|restrict|return|revoke|right|rollback|rowcount|rowguidcol|rows?|rule|save|schema|select|session_user|set|setuser|shutdown|some|start|statistics|system_user|table|textsize|then|to|top|tran|transaction|trigger|truncate|tsequal|unbounded|union|unique|unpivot|update|updatetext|use|user|using|values|varying|view|waitfor|when|where|while|with|within|writetext|xml)(?=[^\w-]|$)/i,
-null],["lit",/^[+-]?(?:0x[\da-f]+|(?:\.\d+|\d+(?:\.\d*)?)(?:e[+-]?\d+)?)/i],["pln",/^[_a-z][\w-]*/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'+\xa0-]*/]]),["sql"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tcl.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tcl.js
deleted file mode 100644
index 490f562..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tcl.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["opn",/^{+/,a,"{"],["clo",/^}+/,a,"}"],["com",/^#[^\n\r]*/,a,"#"],["pln",/^[\t\n\r \xa0]+/,a,"\t\n\r \u00a0"],["str",/^"(?:[^"\\]|\\[\S\s])*(?:"|$)/,a,'"']],[["kwd",/^(?:after|append|apply|array|break|case|catch|continue|error|eval|exec|exit|expr|for|foreach|if|incr|info|proc|return|set|switch|trace|uplevel|upvar|while)\b/,a],["lit",/^[+-]?(?:[#0]x[\da-f]+|\d+\/\d+|(?:\.\d+|\d+(?:\.\d*)?)(?:[de][+-]?\d+)?)/i],["lit",
-/^'(?:-*(?:\w|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?)?/],["pln",/^-*(?:[_a-z]|\\[!-~])(?:[\w-]*|\\[!-~])[!=?]?/i],["pun",/^[^\w\t\n\r "'-);\\\xa0]+/]]),["tcl"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tex.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tex.js
deleted file mode 100644
index dcfdadd..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-tex.js
+++ /dev/null
@@ -1 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"],["com",/^%[^\n\r]*/,null,"%"]],[["kwd",/^\\[@-Za-z]+/],["kwd",/^\\./],["typ",/^[$&]/],["lit",/[+-]?(?:\.\d+|\d+(?:\.\d*)?)(cm|em|ex|in|pc|pt|bp|mm)/i],["pun",/^[()=[\]{}]+/]]),["latex","tex"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vb.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vb.js
deleted file mode 100644
index ddde464..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vb.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0\u2028\u2029]+/,null,"\t\n\r \u00a0\u2028\u2029"],["str",/^(?:["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})(?:["\u201c\u201d]c|$)|["\u201c\u201d](?:[^"\u201c\u201d]|["\u201c\u201d]{2})*(?:["\u201c\u201d]|$))/i,null,'"\u201c\u201d'],["com",/^['\u2018\u2019](?:_(?:\r\n?|[^\r]?)|[^\n\r_\u2028\u2029])*/,null,"'\u2018\u2019"]],[["kwd",/^(?:addhandler|addressof|alias|and|andalso|ansi|as|assembly|auto|boolean|byref|byte|byval|call|case|catch|cbool|cbyte|cchar|cdate|cdbl|cdec|char|cint|class|clng|cobj|const|cshort|csng|cstr|ctype|date|decimal|declare|default|delegate|dim|directcast|do|double|each|else|elseif|end|endif|enum|erase|error|event|exit|finally|for|friend|function|get|gettype|gosub|goto|handles|if|implements|imports|in|inherits|integer|interface|is|let|lib|like|long|loop|me|mod|module|mustinherit|mustoverride|mybase|myclass|namespace|new|next|not|notinheritable|notoverridable|object|on|option|optional|or|orelse|overloads|overridable|overrides|paramarray|preserve|private|property|protected|public|raiseevent|readonly|redim|removehandler|resume|return|select|set|shadows|shared|short|single|static|step|stop|string|structure|sub|synclock|then|throw|to|try|typeof|unicode|until|variant|wend|when|while|with|withevents|writeonly|xor|endif|gosub|let|variant|wend)\b/i,
-null],["com",/^rem\b.*/i],["lit",/^(?:true\b|false\b|nothing\b|\d+(?:e[+-]?\d+[dfr]?|[dfilrs])?|(?:&h[\da-f]+|&o[0-7]+)[ils]?|\d*\.\d+(?:e[+-]?\d+)?[dfr]?|#\s+(?:\d+[/-]\d+[/-]\d+(?:\s+\d+:\d+(?::\d+)?(\s*(?:am|pm))?)?|\d+:\d+(?::\d+)?(\s*(?:am|pm))?)\s+#)/i],["pln",/^(?:(?:[a-z]|_\w)\w*(?:\[[!#%&@]+])?|\[(?:[a-z]|_\w)\w*])/i],["pun",/^[^\w\t\n\r "'[\]\xa0\u2018\u2019\u201c\u201d\u2028\u2029]+/],["pun",/^(?:\[|])/]]),["vb","vbs"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vhdl.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vhdl.js
deleted file mode 100644
index 51f3017..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-vhdl.js
+++ /dev/null
@@ -1,3 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\r \xa0]+/,null,"\t\n\r \u00a0"]],[["str",/^(?:[box]?"(?:[^"]|"")*"|'.')/i],["com",/^--[^\n\r]*/],["kwd",/^(?:abs|access|after|alias|all|and|architecture|array|assert|attribute|begin|block|body|buffer|bus|case|component|configuration|constant|disconnect|downto|else|elsif|end|entity|exit|file|for|function|generate|generic|group|guarded|if|impure|in|inertial|inout|is|label|library|linkage|literal|loop|map|mod|nand|new|next|nor|not|null|of|on|open|or|others|out|package|port|postponed|procedure|process|pure|range|record|register|reject|rem|report|return|rol|ror|select|severity|shared|signal|sla|sll|sra|srl|subtype|then|to|transport|type|unaffected|units|until|use|variable|wait|when|while|with|xnor|xor)(?=[^\w-]|$)/i,
-null],["typ",/^(?:bit|bit_vector|character|boolean|integer|real|time|string|severity_level|positive|natural|signed|unsigned|line|text|std_u?logic(?:_vector)?)(?=[^\w-]|$)/i,null],["typ",/^'(?:active|ascending|base|delayed|driving|driving_value|event|high|image|instance_name|last_active|last_event|last_value|left|leftof|length|low|path_name|pos|pred|quiet|range|reverse_range|right|rightof|simple_name|stable|succ|transaction|val|value)(?=[^\w-]|$)/i,null],["lit",/^\d+(?:_\d+)*(?:#[\w.\\]+#(?:[+-]?\d+(?:_\d+)*)?|(?:\.\d+(?:_\d+)*)?(?:e[+-]?\d+(?:_\d+)*)?)/i],
-["pln",/^(?:[a-z]\w*|\\[^\\]*\\)/i],["pun",/^[^\w\t\n\r "'\xa0][^\w\t\n\r "'\xa0-]*/]]),["vhdl","vhd"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-wiki.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-wiki.js
deleted file mode 100644
index 96c1e34..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-wiki.js
+++ /dev/null
@@ -1,2 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\d\t a-gi-z\xa0]+/,null,"\t \u00a0abcdefgijklmnopqrstuvwxyz0123456789"],["pun",/^[*=[\]^~]+/,null,"=*~^[]"]],[["lang-wiki.meta",/(?:^^|\r\n?|\n)(#[a-z]+)\b/],["lit",/^[A-Z][a-z][\da-z]+[A-Z][a-z][^\W_]+\b/],["lang-",/^{{{([\S\s]+?)}}}/],["lang-",/^`([^\n\r`]+)`/],["str",/^https?:\/\/[^\s#/?]*(?:\/[^\s#?]*)?(?:\?[^\s#]*)?(?:#\S*)?/i],["pln",/^(?:\r\n|[\S\s])[^\n\r#*=A-[^`h{~]*/]]),["wiki"]);
-PR.registerLangHandler(PR.createSimpleLexer([["kwd",/^#[a-z]+/i,null,"#"]],[]),["wiki.meta"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-xq.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-xq.js
deleted file mode 100644
index e323ae3..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-xq.js
+++ /dev/null
@@ -1,3 +0,0 @@
-PR.registerLangHandler(PR.createSimpleLexer([["var pln",/^\$[\w-]+/,null,"$"]],[["pln",/^[\s=][<>][\s=]/],["lit",/^@[\w-]+/],["tag",/^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["com",/^\(:[\S\s]*?:\)/],["pln",/^[(),/;[\]{}]$/],["str",/^(?:"(?:[^"\\{]|\\[\S\s])*(?:"|$)|'(?:[^'\\{]|\\[\S\s])*(?:'|$))/,null,"\"'"],["kwd",/^(?:xquery|where|version|variable|union|typeswitch|treat|to|then|text|stable|sortby|some|self|schema|satisfies|returns|return|ref|processing-instruction|preceding-sibling|preceding|precedes|parent|only|of|node|namespace|module|let|item|intersect|instance|in|import|if|function|for|follows|following-sibling|following|external|except|every|else|element|descending|descendant-or-self|descendant|define|default|declare|comment|child|cast|case|before|attribute|assert|ascending|as|ancestor-or-self|ancestor|after|eq|order|by|or|and|schema-element|document-node|node|at)\b/],
-["typ",/^(?:xs:yearMonthDuration|xs:unsignedLong|xs:time|xs:string|xs:short|xs:QName|xs:Name|xs:long|xs:integer|xs:int|xs:gYearMonth|xs:gYear|xs:gMonthDay|xs:gDay|xs:float|xs:duration|xs:double|xs:decimal|xs:dayTimeDuration|xs:dateTime|xs:date|xs:byte|xs:boolean|xs:anyURI|xf:yearMonthDuration)\b/,null],["fun pln",/^(?:xp:dereference|xinc:node-expand|xinc:link-references|xinc:link-expand|xhtml:restructure|xhtml:clean|xhtml:add-lists|xdmp:zip-manifest|xdmp:zip-get|xdmp:zip-create|xdmp:xquery-version|xdmp:word-convert|xdmp:with-namespaces|xdmp:version|xdmp:value|xdmp:user-roles|xdmp:user-last-login|xdmp:user|xdmp:url-encode|xdmp:url-decode|xdmp:uri-is-file|xdmp:uri-format|xdmp:uri-content-type|xdmp:unquote|xdmp:unpath|xdmp:triggers-database|xdmp:trace|xdmp:to-json|xdmp:tidy|xdmp:subbinary|xdmp:strftime|xdmp:spawn-in|xdmp:spawn|xdmp:sleep|xdmp:shutdown|xdmp:set-session-field|xdmp:set-response-encoding|xdmp:set-response-content-type|xdmp:set-response-code|xdmp:set-request-time-limit|xdmp:set|xdmp:servers|xdmp:server-status|xdmp:server-name|xdmp:server|xdmp:security-database|xdmp:security-assert|xdmp:schema-database|xdmp:save|xdmp:role-roles|xdmp:role|xdmp:rethrow|xdmp:restart|xdmp:request-timestamp|xdmp:request-status|xdmp:request-cancel|xdmp:request|xdmp:redirect-response|xdmp:random|xdmp:quote|xdmp:query-trace|xdmp:query-meters|xdmp:product-edition|xdmp:privilege-roles|xdmp:privilege|xdmp:pretty-print|xdmp:powerpoint-convert|xdmp:platform|xdmp:permission|xdmp:pdf-convert|xdmp:path|xdmp:octal-to-integer|xdmp:node-uri|xdmp:node-replace|xdmp:node-kind|xdmp:node-insert-child|xdmp:node-insert-before|xdmp:node-insert-after|xdmp:node-delete|xdmp:node-database|xdmp:mul64|xdmp:modules-root|xdmp:modules-database|xdmp:merging|xdmp:merge-cancel|xdmp:merge|xdmp:md5|xdmp:logout|xdmp:login|xdmp:log-level|xdmp:log|xdmp:lock-release|xdmp:lock-acquire|xdmp:load|xdmp:invoke-in|xdmp:invoke|xdmp:integer-to-octal|xdmp:integer-to-hex|xdmp:http-put|xdmp:http-post|xdmp:http-options|xdmp:http-head|xdmp:http-get|xdmp:http-delete|xdmp:hosts|xdmp:host-status|xdmp:host-name|xdmp:host|xdmp:hex-to-integer|xdmp:hash64|xdmp:hash32|xdmp:has-privilege|xdmp:groups|xdmp:group-serves|xdmp:group-servers|xdmp:group-name|xdmp:group-hosts|xdmp:group|xdmp:get-session-field-names|xdmp:get-session-field|xdmp:get-response-encoding|xdmp:get-response-code|xdmp:get-request-username|xdmp:get-request-user|xdmp:get-request-url|xdmp:get-request-protocol|xdmp:get-request-path|xdmp:get-request-method|xdmp:get-request-header-names|xdmp:get-request-header|xdmp:get-request-field-names|xdmp:get-request-field-filename|xdmp:get-request-field-content-type|xdmp:get-request-field|xdmp:get-request-client-certificate|xdmp:get-request-client-address|xdmp:get-request-body|xdmp:get-current-user|xdmp:get-current-roles|xdmp:get|xdmp:function-name|xdmp:function-module|xdmp:function|xdmp:from-json|xdmp:forests|xdmp:forest-status|xdmp:forest-restore|xdmp:forest-restart|xdmp:forest-name|xdmp:forest-delete|xdmp:forest-databases|xdmp:forest-counts|xdmp:forest-clear|xdmp:forest-backup|xdmp:forest|xdmp:filesystem-file|xdmp:filesystem-directory|xdmp:exists|xdmp:excel-convert|xdmp:eval-in|xdmp:eval|xdmp:estimate|xdmp:email|xdmp:element-content-type|xdmp:elapsed-time|xdmp:document-set-quality|xdmp:document-set-property|xdmp:document-set-properties|xdmp:document-set-permissions|xdmp:document-set-collections|xdmp:document-remove-properties|xdmp:document-remove-permissions|xdmp:document-remove-collections|xdmp:document-properties|xdmp:document-locks|xdmp:document-load|xdmp:document-insert|xdmp:document-get-quality|xdmp:document-get-properties|xdmp:document-get-permissions|xdmp:document-get-collections|xdmp:document-get|xdmp:document-forest|xdmp:document-delete|xdmp:document-add-properties|xdmp:document-add-permissions|xdmp:document-add-collections|xdmp:directory-properties|xdmp:directory-locks|xdmp:directory-delete|xdmp:directory-create|xdmp:directory|xdmp:diacritic-less|xdmp:describe|xdmp:default-permissions|xdmp:default-collections|xdmp:databases|xdmp:database-restore-validate|xdmp:database-restore-status|xdmp:database-restore-cancel|xdmp:database-restore|xdmp:database-name|xdmp:database-forests|xdmp:database-backup-validate|xdmp:database-backup-status|xdmp:database-backup-purge|xdmp:database-backup-cancel|xdmp:database-backup|xdmp:database|xdmp:collection-properties|xdmp:collection-locks|xdmp:collection-delete|xdmp:collation-canonical-uri|xdmp:castable-as|xdmp:can-grant-roles|xdmp:base64-encode|xdmp:base64-decode|xdmp:architecture|xdmp:apply|xdmp:amp-roles|xdmp:amp|xdmp:add64|xdmp:add-response-header|xdmp:access|trgr:trigger-set-recursive|trgr:trigger-set-permissions|trgr:trigger-set-name|trgr:trigger-set-module|trgr:trigger-set-event|trgr:trigger-set-description|trgr:trigger-remove-permissions|trgr:trigger-module|trgr:trigger-get-permissions|trgr:trigger-enable|trgr:trigger-disable|trgr:trigger-database-online-event|trgr:trigger-data-event|trgr:trigger-add-permissions|trgr:remove-trigger|trgr:property-content|trgr:pre-commit|trgr:post-commit|trgr:get-trigger-by-id|trgr:get-trigger|trgr:document-scope|trgr:document-content|trgr:directory-scope|trgr:create-trigger|trgr:collection-scope|trgr:any-property-content|thsr:set-entry|thsr:remove-term|thsr:remove-synonym|thsr:remove-entry|thsr:query-lookup|thsr:lookup|thsr:load|thsr:insert|thsr:expand|thsr:add-synonym|spell:suggest-detailed|spell:suggest|spell:remove-word|spell:make-dictionary|spell:load|spell:levenshtein-distance|spell:is-correct|spell:insert|spell:double-metaphone|spell:add-word|sec:users-collection|sec:user-set-roles|sec:user-set-password|sec:user-set-name|sec:user-set-description|sec:user-set-default-permissions|sec:user-set-default-collections|sec:user-remove-roles|sec:user-privileges|sec:user-get-roles|sec:user-get-description|sec:user-get-default-permissions|sec:user-get-default-collections|sec:user-doc-permissions|sec:user-doc-collections|sec:user-add-roles|sec:unprotect-collection|sec:uid-for-name|sec:set-realm|sec:security-version|sec:security-namespace|sec:security-installed|sec:security-collection|sec:roles-collection|sec:role-set-roles|sec:role-set-name|sec:role-set-description|sec:role-set-default-permissions|sec:role-set-default-collections|sec:role-remove-roles|sec:role-privileges|sec:role-get-roles|sec:role-get-description|sec:role-get-default-permissions|sec:role-get-default-collections|sec:role-doc-permissions|sec:role-doc-collections|sec:role-add-roles|sec:remove-user|sec:remove-role-from-users|sec:remove-role-from-role|sec:remove-role-from-privileges|sec:remove-role-from-amps|sec:remove-role|sec:remove-privilege|sec:remove-amp|sec:protect-collection|sec:privileges-collection|sec:privilege-set-roles|sec:privilege-set-name|sec:privilege-remove-roles|sec:privilege-get-roles|sec:privilege-add-roles|sec:priv-doc-permissions|sec:priv-doc-collections|sec:get-user-names|sec:get-unique-elem-id|sec:get-role-names|sec:get-role-ids|sec:get-privilege|sec:get-distinct-permissions|sec:get-collection|sec:get-amp|sec:create-user-with-role|sec:create-user|sec:create-role|sec:create-privilege|sec:create-amp|sec:collections-collection|sec:collection-set-permissions|sec:collection-remove-permissions|sec:collection-get-permissions|sec:collection-add-permissions|sec:check-admin|sec:amps-collection|sec:amp-set-roles|sec:amp-remove-roles|sec:amp-get-roles|sec:amp-doc-permissions|sec:amp-doc-collections|sec:amp-add-roles|search:unparse|search:suggest|search:snippet|search:search|search:resolve-nodes|search:resolve|search:remove-constraint|search:parse|search:get-default-options|search:estimate|search:check-options|prof:value|prof:reset|prof:report|prof:invoke|prof:eval|prof:enable|prof:disable|prof:allowed|ppt:clean|pki:template-set-request|pki:template-set-name|pki:template-set-key-type|pki:template-set-key-options|pki:template-set-description|pki:template-in-use|pki:template-get-version|pki:template-get-request|pki:template-get-name|pki:template-get-key-type|pki:template-get-key-options|pki:template-get-id|pki:template-get-description|pki:need-certificate|pki:is-temporary|pki:insert-trusted-certificates|pki:insert-template|pki:insert-signed-certificates|pki:insert-certificate-revocation-list|pki:get-trusted-certificate-ids|pki:get-template-ids|pki:get-template-certificate-authority|pki:get-template-by-name|pki:get-template|pki:get-pending-certificate-requests-xml|pki:get-pending-certificate-requests-pem|pki:get-pending-certificate-request|pki:get-certificates-for-template-xml|pki:get-certificates-for-template|pki:get-certificates|pki:get-certificate-xml|pki:get-certificate-pem|pki:get-certificate|pki:generate-temporary-certificate-if-necessary|pki:generate-temporary-certificate|pki:generate-template-certificate-authority|pki:generate-certificate-request|pki:delete-template|pki:delete-certificate|pki:create-template|pdf:make-toc|pdf:insert-toc-headers|pdf:get-toc|pdf:clean|p:status-transition|p:state-transition|p:remove|p:pipelines|p:insert|p:get-by-id|p:get|p:execute|p:create|p:condition|p:collection|p:action|ooxml:runs-merge|ooxml:package-uris|ooxml:package-parts-insert|ooxml:package-parts|msword:clean|mcgm:polygon|mcgm:point|mcgm:geospatial-query-from-elements|mcgm:geospatial-query|mcgm:circle|math:tanh|math:tan|math:sqrt|math:sinh|math:sin|math:pow|math:modf|math:log10|math:log|math:ldexp|math:frexp|math:fmod|math:floor|math:fabs|math:exp|math:cosh|math:cos|math:ceil|math:atan2|math:atan|math:asin|math:acos|map:put|map:map|map:keys|map:get|map:delete|map:count|map:clear|lnk:to|lnk:remove|lnk:insert|lnk:get|lnk:from|lnk:create|kml:polygon|kml:point|kml:interior-polygon|kml:geospatial-query-from-elements|kml:geospatial-query|kml:circle|kml:box|gml:polygon|gml:point|gml:interior-polygon|gml:geospatial-query-from-elements|gml:geospatial-query|gml:circle|gml:box|georss:point|georss:geospatial-query|georss:circle|geo:polygon|geo:point|geo:interior-polygon|geo:geospatial-query-from-elements|geo:geospatial-query|geo:circle|geo:box|fn:zero-or-one|fn:years-from-duration|fn:year-from-dateTime|fn:year-from-date|fn:upper-case|fn:unordered|fn:true|fn:translate|fn:trace|fn:tokenize|fn:timezone-from-time|fn:timezone-from-dateTime|fn:timezone-from-date|fn:sum|fn:subtract-dateTimes-yielding-yearMonthDuration|fn:subtract-dateTimes-yielding-dayTimeDuration|fn:substring-before|fn:substring-after|fn:substring|fn:subsequence|fn:string-to-codepoints|fn:string-pad|fn:string-length|fn:string-join|fn:string|fn:static-base-uri|fn:starts-with|fn:seconds-from-time|fn:seconds-from-duration|fn:seconds-from-dateTime|fn:round-half-to-even|fn:round|fn:root|fn:reverse|fn:resolve-uri|fn:resolve-QName|fn:replace|fn:remove|fn:QName|fn:prefix-from-QName|fn:position|fn:one-or-more|fn:number|fn:not|fn:normalize-unicode|fn:normalize-space|fn:node-name|fn:node-kind|fn:nilled|fn:namespace-uri-from-QName|fn:namespace-uri-for-prefix|fn:namespace-uri|fn:name|fn:months-from-duration|fn:month-from-dateTime|fn:month-from-date|fn:minutes-from-time|fn:minutes-from-duration|fn:minutes-from-dateTime|fn:min|fn:max|fn:matches|fn:lower-case|fn:local-name-from-QName|fn:local-name|fn:last|fn:lang|fn:iri-to-uri|fn:insert-before|fn:index-of|fn:in-scope-prefixes|fn:implicit-timezone|fn:idref|fn:id|fn:hours-from-time|fn:hours-from-duration|fn:hours-from-dateTime|fn:floor|fn:false|fn:expanded-QName|fn:exists|fn:exactly-one|fn:escape-uri|fn:escape-html-uri|fn:error|fn:ends-with|fn:encode-for-uri|fn:empty|fn:document-uri|fn:doc-available|fn:doc|fn:distinct-values|fn:distinct-nodes|fn:default-collation|fn:deep-equal|fn:days-from-duration|fn:day-from-dateTime|fn:day-from-date|fn:data|fn:current-time|fn:current-dateTime|fn:current-date|fn:count|fn:contains|fn:concat|fn:compare|fn:collection|fn:codepoints-to-string|fn:codepoint-equal|fn:ceiling|fn:boolean|fn:base-uri|fn:avg|fn:adjust-time-to-timezone|fn:adjust-dateTime-to-timezone|fn:adjust-date-to-timezone|fn:abs|feed:unsubscribe|feed:subscription|feed:subscribe|feed:request|feed:item|feed:description|excel:clean|entity:enrich|dom:set-pipelines|dom:set-permissions|dom:set-name|dom:set-evaluation-context|dom:set-domain-scope|dom:set-description|dom:remove-pipeline|dom:remove-permissions|dom:remove|dom:get|dom:evaluation-context|dom:domains|dom:domain-scope|dom:create|dom:configuration-set-restart-user|dom:configuration-set-permissions|dom:configuration-set-evaluation-context|dom:configuration-set-default-domain|dom:configuration-get|dom:configuration-create|dom:collection|dom:add-pipeline|dom:add-permissions|dls:retention-rules|dls:retention-rule-remove|dls:retention-rule-insert|dls:retention-rule|dls:purge|dls:node-expand|dls:link-references|dls:link-expand|dls:documents-query|dls:document-versions-query|dls:document-version-uri|dls:document-version-query|dls:document-version-delete|dls:document-version-as-of|dls:document-version|dls:document-update|dls:document-unmanage|dls:document-set-quality|dls:document-set-property|dls:document-set-properties|dls:document-set-permissions|dls:document-set-collections|dls:document-retention-rules|dls:document-remove-properties|dls:document-remove-permissions|dls:document-remove-collections|dls:document-purge|dls:document-manage|dls:document-is-managed|dls:document-insert-and-manage|dls:document-include-query|dls:document-history|dls:document-get-permissions|dls:document-extract-part|dls:document-delete|dls:document-checkout-status|dls:document-checkout|dls:document-checkin|dls:document-add-properties|dls:document-add-permissions|dls:document-add-collections|dls:break-checkout|dls:author-query|dls:as-of-query|dbk:convert|dbg:wait|dbg:value|dbg:stopped|dbg:stop|dbg:step|dbg:status|dbg:stack|dbg:out|dbg:next|dbg:line|dbg:invoke|dbg:function|dbg:finish|dbg:expr|dbg:eval|dbg:disconnect|dbg:detach|dbg:continue|dbg:connect|dbg:clear|dbg:breakpoints|dbg:break|dbg:attached|dbg:attach|cvt:save-converted-documents|cvt:part-uri|cvt:destination-uri|cvt:basepath|cvt:basename|cts:words|cts:word-query-weight|cts:word-query-text|cts:word-query-options|cts:word-query|cts:word-match|cts:walk|cts:uris|cts:uri-match|cts:train|cts:tokenize|cts:thresholds|cts:stem|cts:similar-query-weight|cts:similar-query-nodes|cts:similar-query|cts:shortest-distance|cts:search|cts:score|cts:reverse-query-weight|cts:reverse-query-nodes|cts:reverse-query|cts:remainder|cts:registered-query-weight|cts:registered-query-options|cts:registered-query-ids|cts:registered-query|cts:register|cts:query|cts:quality|cts:properties-query-query|cts:properties-query|cts:polygon-vertices|cts:polygon|cts:point-longitude|cts:point-latitude|cts:point|cts:or-query-queries|cts:or-query|cts:not-query-weight|cts:not-query-query|cts:not-query|cts:near-query-weight|cts:near-query-queries|cts:near-query-options|cts:near-query-distance|cts:near-query|cts:highlight|cts:geospatial-co-occurrences|cts:frequency|cts:fitness|cts:field-words|cts:field-word-query-weight|cts:field-word-query-text|cts:field-word-query-options|cts:field-word-query-field-name|cts:field-word-query|cts:field-word-match|cts:entity-highlight|cts:element-words|cts:element-word-query-weight|cts:element-word-query-text|cts:element-word-query-options|cts:element-word-query-element-name|cts:element-word-query|cts:element-word-match|cts:element-values|cts:element-value-ranges|cts:element-value-query-weight|cts:element-value-query-text|cts:element-value-query-options|cts:element-value-query-element-name|cts:element-value-query|cts:element-value-match|cts:element-value-geospatial-co-occurrences|cts:element-value-co-occurrences|cts:element-range-query-weight|cts:element-range-query-value|cts:element-range-query-options|cts:element-range-query-operator|cts:element-range-query-element-name|cts:element-range-query|cts:element-query-query|cts:element-query-element-name|cts:element-query|cts:element-pair-geospatial-values|cts:element-pair-geospatial-value-match|cts:element-pair-geospatial-query-weight|cts:element-pair-geospatial-query-region|cts:element-pair-geospatial-query-options|cts:element-pair-geospatial-query-longitude-name|cts:element-pair-geospatial-query-latitude-name|cts:element-pair-geospatial-query-element-name|cts:element-pair-geospatial-query|cts:element-pair-geospatial-boxes|cts:element-geospatial-values|cts:element-geospatial-value-match|cts:element-geospatial-query-weight|cts:element-geospatial-query-region|cts:element-geospatial-query-options|cts:element-geospatial-query-element-name|cts:element-geospatial-query|cts:element-geospatial-boxes|cts:element-child-geospatial-values|cts:element-child-geospatial-value-match|cts:element-child-geospatial-query-weight|cts:element-child-geospatial-query-region|cts:element-child-geospatial-query-options|cts:element-child-geospatial-query-element-name|cts:element-child-geospatial-query-child-name|cts:element-child-geospatial-query|cts:element-child-geospatial-boxes|cts:element-attribute-words|cts:element-attribute-word-query-weight|cts:element-attribute-word-query-text|cts:element-attribute-word-query-options|cts:element-attribute-word-query-element-name|cts:element-attribute-word-query-attribute-name|cts:element-attribute-word-query|cts:element-attribute-word-match|cts:element-attribute-values|cts:element-attribute-value-ranges|cts:element-attribute-value-query-weight|cts:element-attribute-value-query-text|cts:element-attribute-value-query-options|cts:element-attribute-value-query-element-name|cts:element-attribute-value-query-attribute-name|cts:element-attribute-value-query|cts:element-attribute-value-match|cts:element-attribute-value-geospatial-co-occurrences|cts:element-attribute-value-co-occurrences|cts:element-attribute-range-query-weight|cts:element-attribute-range-query-value|cts:element-attribute-range-query-options|cts:element-attribute-range-query-operator|cts:element-attribute-range-query-element-name|cts:element-attribute-range-query-attribute-name|cts:element-attribute-range-query|cts:element-attribute-pair-geospatial-values|cts:element-attribute-pair-geospatial-value-match|cts:element-attribute-pair-geospatial-query-weight|cts:element-attribute-pair-geospatial-query-region|cts:element-attribute-pair-geospatial-query-options|cts:element-attribute-pair-geospatial-query-longitude-name|cts:element-attribute-pair-geospatial-query-latitude-name|cts:element-attribute-pair-geospatial-query-element-name|cts:element-attribute-pair-geospatial-query|cts:element-attribute-pair-geospatial-boxes|cts:document-query-uris|cts:document-query|cts:distance|cts:directory-query-uris|cts:directory-query-depth|cts:directory-query|cts:destination|cts:deregister|cts:contains|cts:confidence|cts:collections|cts:collection-query-uris|cts:collection-query|cts:collection-match|cts:classify|cts:circle-radius|cts:circle-center|cts:circle|cts:box-west|cts:box-south|cts:box-north|cts:box-east|cts:box|cts:bearing|cts:arc-intersection|cts:and-query-queries|cts:and-query-options|cts:and-query|cts:and-not-query-positive-query|cts:and-not-query-negative-query|cts:and-not-query|css:get|css:convert|cpf:success|cpf:failure|cpf:document-set-state|cpf:document-set-processing-status|cpf:document-set-last-updated|cpf:document-set-error|cpf:document-get-state|cpf:document-get-processing-status|cpf:document-get-last-updated|cpf:document-get-error|cpf:check-transition|alert:spawn-matching-actions|alert:rule-user-id-query|alert:rule-set-user-id|alert:rule-set-query|alert:rule-set-options|alert:rule-set-name|alert:rule-set-description|alert:rule-set-action|alert:rule-remove|alert:rule-name-query|alert:rule-insert|alert:rule-id-query|alert:rule-get-user-id|alert:rule-get-query|alert:rule-get-options|alert:rule-get-name|alert:rule-get-id|alert:rule-get-description|alert:rule-get-action|alert:rule-action-query|alert:remove-triggers|alert:make-rule|alert:make-log-action|alert:make-config|alert:make-action|alert:invoke-matching-actions|alert:get-my-rules|alert:get-all-rules|alert:get-actions|alert:find-matching-rules|alert:create-triggers|alert:config-set-uri|alert:config-set-trigger-ids|alert:config-set-options|alert:config-set-name|alert:config-set-description|alert:config-set-cpf-domain-names|alert:config-set-cpf-domain-ids|alert:config-insert|alert:config-get-uri|alert:config-get-trigger-ids|alert:config-get-options|alert:config-get-name|alert:config-get-id|alert:config-get-description|alert:config-get-cpf-domain-names|alert:config-get-cpf-domain-ids|alert:config-get|alert:config-delete|alert:action-set-options|alert:action-set-name|alert:action-set-module-root|alert:action-set-module-db|alert:action-set-module|alert:action-set-description|alert:action-remove|alert:action-insert|alert:action-get-options|alert:action-get-name|alert:action-get-module-root|alert:action-get-module-db|alert:action-get-module|alert:action-get-description|zero-or-one|years-from-duration|year-from-dateTime|year-from-date|upper-case|unordered|true|translate|trace|tokenize|timezone-from-time|timezone-from-dateTime|timezone-from-date|sum|subtract-dateTimes-yielding-yearMonthDuration|subtract-dateTimes-yielding-dayTimeDuration|substring-before|substring-after|substring|subsequence|string-to-codepoints|string-pad|string-length|string-join|string|static-base-uri|starts-with|seconds-from-time|seconds-from-duration|seconds-from-dateTime|round-half-to-even|round|root|reverse|resolve-uri|resolve-QName|replace|remove|QName|prefix-from-QName|position|one-or-more|number|not|normalize-unicode|normalize-space|node-name|node-kind|nilled|namespace-uri-from-QName|namespace-uri-for-prefix|namespace-uri|name|months-from-duration|month-from-dateTime|month-from-date|minutes-from-time|minutes-from-duration|minutes-from-dateTime|min|max|matches|lower-case|local-name-from-QName|local-name|last|lang|iri-to-uri|insert-before|index-of|in-scope-prefixes|implicit-timezone|idref|id|hours-from-time|hours-from-duration|hours-from-dateTime|floor|false|expanded-QName|exists|exactly-one|escape-uri|escape-html-uri|error|ends-with|encode-for-uri|empty|document-uri|doc-available|doc|distinct-values|distinct-nodes|default-collation|deep-equal|days-from-duration|day-from-dateTime|day-from-date|data|current-time|current-dateTime|current-date|count|contains|concat|compare|collection|codepoints-to-string|codepoint-equal|ceiling|boolean|base-uri|avg|adjust-time-to-timezone|adjust-dateTime-to-timezone|adjust-date-to-timezone|abs)\b/],
-["pln",/^[\w:-]+/],["pln",/^[\t\n\r \xa0]+/]]),["xq","xquery"]);
diff --git a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-yaml.js b/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-yaml.js
deleted file mode 100644
index c38729b..0000000
--- a/gerrit-prettify/src/main/resources/com/google/gerrit/prettify/client/lang-yaml.js
+++ /dev/null
@@ -1,2 +0,0 @@
-var a=null;
-PR.registerLangHandler(PR.createSimpleLexer([["pun",/^[:>?|]+/,a,":|>?"],["dec",/^%(?:YAML|TAG)[^\n\r#]+/,a,"%"],["typ",/^&\S+/,a,"&"],["typ",/^!\S*/,a,"!"],["str",/^"(?:[^"\\]|\\.)*(?:"|$)/,a,'"'],["str",/^'(?:[^']|'')*(?:'|$)/,a,"'"],["com",/^#[^\n\r]*/,a,"#"],["pln",/^\s+/,a," \t\r\n"]],[["dec",/^(?:---|\.\.\.)(?:[\n\r]|$)/],["pun",/^-/],["kwd",/^\w+:[\n\r ]/],["pln",/^\w+/]]),["yaml","yml"]);
diff --git a/gerrit-reviewdb/BUCK b/gerrit-reviewdb/BUCK
index 7bed0f3..82e0135 100644
--- a/gerrit-reviewdb/BUCK
+++ b/gerrit-reviewdb/BUCK
@@ -19,6 +19,7 @@
   resources = glob(['src/main/resources/**/*']),
   deps = [
     '//gerrit-extension-api:api',
+    '//lib:guava',
     '//lib:gwtorm',
   ],
   visibility = ['PUBLIC'],
diff --git a/gerrit-reviewdb/BUILD b/gerrit-reviewdb/BUILD
new file mode 100644
index 0000000..a4144ec
--- /dev/null
+++ b/gerrit-reviewdb/BUILD
@@ -0,0 +1,39 @@
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRC = 'src/main/java/com/google/gerrit/reviewdb/'
+TESTS = 'src/test/java/com/google/gerrit/reviewdb/'
+
+gwt_module(
+  name = 'client',
+  srcs = glob([SRC + 'client/**/*.java']),
+  gwt_xml = SRC + 'ReviewDB.gwt.xml',
+  deps = [
+    '//gerrit-extension-api:client',
+    '//lib:gwtorm_client',
+    '//lib:gwtorm_client_src'
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  srcs = glob([SRC + '**/*.java']),
+  resources = glob(['src/main/resources/**/*']),
+  deps = [
+    '//gerrit-extension-api:api',
+    '//lib:guava',
+    '//lib:gwtorm',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'client_tests',
+  srcs = glob([TESTS + 'client/**/*.java']),
+  deps = [
+    ':client',
+    '//lib:gwtorm',
+    '//lib:truth',
+  ],
+)
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index 295239f..1682195 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
 
@@ -48,22 +49,18 @@
  * the internal SSH daemon. One record per SSH key uploaded by the user, keys
  * are checked in random order until a match is found.</li>
  *
- * <li>{@link StarredChange}: user has starred the change, tracking
- * notifications of updates on that change, or just book-marking it for faster
- * future reference. One record per starred change.</li>
- *
  * <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side
  * and unified diff</li>
  *
  * </ul>
  */
 public final class Account {
-  public static enum FieldName {
+  public enum FieldName {
     FULL_NAME, USER_NAME, REGISTER_NEW_EMAIL
   }
 
   public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
-  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._-]";
+  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._@-]";
   public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
 
   /** Regular expression that {@link #userName} must match. */
@@ -125,46 +122,23 @@
      *              We assume that the caller has trimmed any prefix.
      */
     public static Id fromRefPart(String name) {
-      if (name == null) {
-        return null;
-      }
+      Integer id = RefNames.parseShardedRefPart(name);
+      return id != null ? new Account.Id(id) : null;
+    }
 
-      String[] parts = name.split("/");
-      int n = parts.length;
-      if (n < 2) {
-        return null;
-      }
-
-      // Last 2 digits.
-      int le;
-      for (le = 0; le < parts[0].length(); le++) {
-        if (!Character.isDigit(parts[0].charAt(le))) {
-          return null;
-        }
-      }
-      if (le != 2) {
-        return null;
-      }
-
-      // Full ID.
-      int ie;
-      for (ie = 0; ie < parts[1].length(); ie++) {
-        if (!Character.isDigit(parts[1].charAt(ie))) {
-          if (ie == 0) {
-            return null;
-          } else {
-            break;
-          }
-        }
-      }
-
-      int shard = Integer.parseInt(parts[0]);
-      int id = Integer.parseInt(parts[1].substring(0, ie));
-
-      if (id % 100 != shard) {
-        return null;
-      }
-      return new Account.Id(id);
+    /**
+     * Parse an Account.Id out of the last part of a ref name.
+     * <p>
+     * The input is a ref name of the form {@code ".../1234"}, where the suffix
+     * is a non-sharded account ID. Ref names using a sharded ID should use
+     * {@link #fromRefPart(String)} instead for greater safety.
+     *
+     * @param name ref name
+     * @return account ID, or null if not numeric.
+     */
+    public static Id fromRefSuffix(String name) {
+      Integer id = RefNames.parseRefSuffix(name);
+      return id != null ? new Account.Id(id) : null;
     }
   }
 
@@ -185,9 +159,7 @@
 
   // DELETED: id = 5 (contactFiledOn)
 
-  /** This user's preferences */
-  @Column(id = 6, name = Column.NONE)
-  protected AccountGeneralPreferences generalPreferences;
+  // DELETED: id = 6 (generalPreferences)
 
   /** Is this user active */
   @Column(id = 7)
@@ -196,6 +168,9 @@
   /** <i>computed</i> the username selected from the identities. */
   protected String userName;
 
+  /** <i>stored in git, used for caching</i> the user's preferences. */
+  private GeneralPreferencesInfo generalPreferences;
+
   protected Account() {
   }
 
@@ -209,9 +184,6 @@
   public Account(Account.Id newId, Timestamp registeredOn) {
     this.accountId = newId;
     this.registeredOn = registeredOn;
-
-    generalPreferences = new AccountGeneralPreferences();
-    generalPreferences.resetToDefaults();
   }
 
   /** Get local id of this account, to link with in other entities */
@@ -243,16 +215,59 @@
     preferredEmail = addr;
   }
 
+  /**
+   * Formats an account name.
+   * <p>
+   * If the account has a full name, it returns only the full name. Otherwise it
+   * returns a longer form that includes the email address.
+   */
+  public String getName(String anonymousCowardName) {
+    if (fullName != null) {
+      return fullName;
+    }
+    if (preferredEmail != null) {
+      return preferredEmail;
+    }
+    return getNameEmail(anonymousCowardName);
+  }
+
+  /**
+   * Get the name and email address.
+   * <p>
+   * Example output:
+   * <ul>
+   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
+   * <li>{@code A U. Thor (12)}: missing email address</li>
+   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
+   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
+   * </ul>
+   */
+  public String getNameEmail(String anonymousCowardName) {
+    String name = fullName != null ? fullName : anonymousCowardName;
+    StringBuilder b = new StringBuilder();
+    b.append(name);
+    if (preferredEmail != null) {
+      b.append(" <");
+      b.append(preferredEmail);
+      b.append(">");
+    } else if (accountId != null) {
+      b.append(" (");
+      b.append(accountId.get());
+      b.append(")");
+    }
+    return b.toString();
+  }
+
   /** Get the date and time the user first registered. */
   public Timestamp getRegisteredOn() {
     return registeredOn;
   }
 
-  public AccountGeneralPreferences getGeneralPreferences() {
+  public GeneralPreferencesInfo getGeneralPreferencesInfo() {
     return generalPreferences;
   }
 
-  public void setGeneralPreferences(final AccountGeneralPreferences p) {
+  public void setGeneralPreferences(GeneralPreferencesInfo p) {
     generalPreferences = p;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
deleted file mode 100644
index 2e33575..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
+++ /dev/null
@@ -1,351 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-
-/** Preferences about a single user. */
-public final class AccountGeneralPreferences {
-
-  /** Default number of items to display per page. */
-  public static final short DEFAULT_PAGESIZE = 25;
-
-  /** Valid choices for the page size. */
-  public static final short[] PAGESIZE_CHOICES = {10, 25, 50, 100};
-
-  /** Preferred method to download a change. */
-  public static enum DownloadCommand {
-    REPO_DOWNLOAD, PULL, CHECKOUT, CHERRY_PICK, FORMAT_PATCH
-  }
-
-  public static enum DateFormat {
-    /** US style dates: Apr 27, Feb 14, 2010 */
-    STD("MMM d", "MMM d, yyyy"),
-
-    /** US style dates: 04/27, 02/14/10 */
-    US("MM/dd", "MM/dd/yy"),
-
-    /** ISO style dates: 2010-02-14 */
-    ISO("MM-dd", "yyyy-MM-dd"),
-
-    /** European style dates: 27. Apr, 27.04.2010 */
-    EURO("d. MMM", "dd.MM.yyyy"),
-
-    /** UK style dates: 27/04, 27/04/2010 */
-    UK("dd/MM", "dd/MM/yyyy");
-
-    private final String shortFormat;
-    private final String longFormat;
-
-    DateFormat(String shortFormat, String longFormat) {
-      this.shortFormat = shortFormat;
-      this.longFormat = longFormat;
-    }
-
-    public String getShortFormat() {
-      return shortFormat;
-    }
-
-    public String getLongFormat() {
-      return longFormat;
-    }
-  }
-
-  public static enum ReviewCategoryStrategy {
-    NONE,
-    NAME,
-    EMAIL,
-    USERNAME,
-    ABBREV
-  }
-
-  public static enum DiffView {
-    SIDE_BY_SIDE,
-    UNIFIED_DIFF
-  }
-
-  public static enum TimeFormat {
-    /** 12-hour clock: 1:15 am, 2:13 pm */
-    HHMM_12("h:mm a"),
-
-    /** 24-hour clock: 01:15, 14:13 */
-    HHMM_24("HH:mm");
-
-    private final String format;
-
-    TimeFormat(String format) {
-      this.format = format;
-    }
-
-    public String getFormat() {
-      return format;
-    }
-  }
-
-  public static AccountGeneralPreferences createDefault() {
-    AccountGeneralPreferences p = new AccountGeneralPreferences();
-    p.resetToDefaults();
-    return p;
-  }
-
-  /** Number of changes to show in a screen. */
-  @Column(id = 2)
-  protected short maximumPageSize;
-
-  /** Should the site header be displayed when logged in ? */
-  @Column(id = 3)
-  protected boolean showSiteHeader;
-
-  /** Should the Flash helper movie be used to copy text to the clipboard? */
-  @Column(id = 4)
-  protected boolean useFlashClipboard;
-
-  /** Type of download URL the user prefers to use. */
-  @Column(id = 5, length = 20, notNull = false)
-  protected String downloadUrl;
-
-  /** Type of download command the user prefers to use. */
-  @Column(id = 6, length = 20, notNull = false)
-  protected String downloadCommand;
-
-  /** If true we CC the user on their own changes. */
-  @Column(id = 7)
-  protected boolean copySelfOnEmail;
-
-  @Column(id = 8, length = 10, notNull = false)
-  protected String dateFormat;
-
-  @Column(id = 9, length = 10, notNull = false)
-  protected String timeFormat;
-
-  // DELETED: id = 10 (reversePatchSetOrder)
-  // DELETED: id = 11 (showUserInReview)
-
-  @Column(id = 12)
-  protected boolean relativeDateInChangeTable;
-
-  // DELETED: id = 13 (commentVisibilityStrategy)
-
-  @Column(id = 14, length = 20, notNull = false)
-  protected String diffView;
-
-  // DELETED: id = 15 (changeScreen)
-
-  @Column(id = 16)
-  protected boolean sizeBarInChangeTable;
-
-  @Column(id = 17)
-  protected boolean legacycidInChangeTable;
-
-  @Column(id = 18, length = 20, notNull = false)
-  protected String reviewCategoryStrategy;
-
-  @Column(id = 19)
-  protected boolean muteCommonPathPrefixes;
-
-  public AccountGeneralPreferences() {
-  }
-
-  public short getMaximumPageSize() {
-    return maximumPageSize;
-  }
-
-  public void setMaximumPageSize(final short s) {
-    maximumPageSize = s;
-  }
-
-  public boolean isShowSiteHeader() {
-    return showSiteHeader;
-  }
-
-  public void setShowSiteHeader(final boolean b) {
-    showSiteHeader = b;
-  }
-
-  public boolean isUseFlashClipboard() {
-    return useFlashClipboard;
-  }
-
-  public void setUseFlashClipboard(final boolean b) {
-    useFlashClipboard = b;
-  }
-
-  public String getDownloadUrl() {
-    // Translate from legacy enum names to modern display names. (May be removed
-    // if accompanied by a 2-phase schema upgrade.)
-    if (downloadUrl != null) {
-      switch (downloadUrl) {
-        case "ANON_GIT":
-          return CoreDownloadSchemes.ANON_GIT;
-        case "ANON_HTTP":
-          return CoreDownloadSchemes.ANON_HTTP;
-        case "HTTP":
-          return CoreDownloadSchemes.HTTP;
-        case "SSH":
-          return CoreDownloadSchemes.SSH;
-        case "REPO_DOWNLOAD":
-          return CoreDownloadSchemes.REPO_DOWNLOAD;
-      }
-    }
-    return downloadUrl;
-  }
-
-  public void setDownloadUrl(String url) {
-    // Translate from modern display names to legacy enum names. (May be removed
-    // if accompanied by a 2-phase schema upgrade.)
-    if (downloadUrl != null) {
-      switch (url) {
-        case CoreDownloadSchemes.ANON_GIT:
-          url = "ANON_GIT";
-          break;
-        case CoreDownloadSchemes.ANON_HTTP:
-          url = "ANON_HTTP";
-          break;
-        case CoreDownloadSchemes.HTTP:
-          url = "HTTP";
-          break;
-        case CoreDownloadSchemes.SSH:
-          url = "SSH";
-          break;
-        case CoreDownloadSchemes.REPO_DOWNLOAD:
-          url = "REPO_DOWNLOAD";
-          break;
-      }
-    }
-    downloadUrl = url;
-  }
-
-  public DownloadCommand getDownloadCommand() {
-    if (downloadCommand == null) {
-      return null;
-    }
-    return DownloadCommand.valueOf(downloadCommand);
-  }
-
-  public void setDownloadCommand(DownloadCommand cmd) {
-    if (cmd != null) {
-      downloadCommand = cmd.name();
-    } else {
-      downloadCommand = null;
-    }
-  }
-
-  public boolean isCopySelfOnEmails() {
-    return copySelfOnEmail;
-  }
-
-  public void setCopySelfOnEmails(boolean includeSelfOnEmail) {
-    copySelfOnEmail = includeSelfOnEmail;
-  }
-
-  public boolean isShowInfoInReviewCategory() {
-    return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
-  }
-
-  public DateFormat getDateFormat() {
-    if (dateFormat == null) {
-      return DateFormat.STD;
-    }
-    return DateFormat.valueOf(dateFormat);
-  }
-
-  public void setDateFormat(DateFormat fmt) {
-    dateFormat = fmt.name();
-  }
-
-  public TimeFormat getTimeFormat() {
-    if (timeFormat == null) {
-      return TimeFormat.HHMM_12;
-    }
-    return TimeFormat.valueOf(timeFormat);
-  }
-
-  public void setTimeFormat(TimeFormat fmt) {
-    timeFormat = fmt.name();
-  }
-
-  public boolean isRelativeDateInChangeTable() {
-    return relativeDateInChangeTable;
-  }
-
-  public void setRelativeDateInChangeTable(final boolean relativeDateInChangeTable) {
-    this.relativeDateInChangeTable = relativeDateInChangeTable;
-  }
-
-  public ReviewCategoryStrategy getReviewCategoryStrategy() {
-    if (reviewCategoryStrategy == null) {
-      return ReviewCategoryStrategy.NONE;
-    }
-    return ReviewCategoryStrategy.valueOf(reviewCategoryStrategy);
-  }
-
-  public void setReviewCategoryStrategy(
-      ReviewCategoryStrategy strategy) {
-    reviewCategoryStrategy = strategy.name();
-  }
-
-  public DiffView getDiffView() {
-    if (diffView == null) {
-      return DiffView.SIDE_BY_SIDE;
-    }
-    return DiffView.valueOf(diffView);
-  }
-
-  public void setDiffView(DiffView diffView) {
-    this.diffView = diffView.name();
-  }
-
-  public boolean isSizeBarInChangeTable() {
-    return sizeBarInChangeTable;
-  }
-
-  public void setSizeBarInChangeTable(boolean sizeBarInChangeTable) {
-    this.sizeBarInChangeTable = sizeBarInChangeTable;
-  }
-
-  public boolean isLegacycidInChangeTable() {
-    return legacycidInChangeTable;
-  }
-
-  public void setLegacycidInChangeTable(boolean legacycidInChangeTable) {
-    this.legacycidInChangeTable = legacycidInChangeTable;
-  }
-
-  public boolean isMuteCommonPathPrefixes() {
-    return muteCommonPathPrefixes;
-  }
-
-  public void setMuteCommonPathPrefixes(
-      boolean muteCommonPathPrefixes) {
-    this.muteCommonPathPrefixes = muteCommonPathPrefixes;
-  }
-
-  public void resetToDefaults() {
-    maximumPageSize = DEFAULT_PAGESIZE;
-    showSiteHeader = true;
-    useFlashClipboard = true;
-    copySelfOnEmail = false;
-    reviewCategoryStrategy = null;
-    downloadUrl = null;
-    downloadCommand = null;
-    dateFormat = null;
-    timeFormat = null;
-    relativeDateInChangeTable = false;
-    diffView = null;
-    sizeBarInChangeTable = true;
-    legacycidInChangeTable = false;
-    muteCommonPathPrefixes = true;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java
index c7f52b0..a6796e7 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java
@@ -22,8 +22,14 @@
 public final class AccountProjectWatch {
 
   public enum NotifyType {
-    NEW_CHANGES, NEW_PATCHSETS, ALL_COMMENTS, SUBMITTED_CHANGES,
-    ABANDONED_CHANGES, ALL
+    // sort by name, except 'ALL' which should stay last
+    ABANDONED_CHANGES,
+    ALL_COMMENTS,
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    SUBMITTED_CHANGES,
+
+    ALL
   }
 
   public static final String FILTER_ALL = "*";
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
index 8c4ac82..78aef91 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
 
+import java.util.Objects;
+
 /** An SSH key approved for use by an {@link Account}. */
 public final class AccountSshKey {
   public static class Id extends IntKey<Account.Id> {
     private static final long serialVersionUID = 1L;
 
-    @Column(id = 1)
     protected Account.Id accountId;
 
-    @Column(id = 2)
     protected int seq;
 
     protected Id() {
@@ -51,15 +50,16 @@
     protected void set(int newValue) {
       seq = newValue;
     }
+
+    public boolean isValid() {
+      return seq > 0;
+    }
   }
 
-  @Column(id = 1, name = Column.NONE)
   protected AccountSshKey.Id id;
 
-  @Column(id = 2, length = Integer.MAX_VALUE)
   protected String sshPublicKey;
 
-  @Column(id = 3)
   protected boolean valid;
 
   protected AccountSshKey() {
@@ -67,8 +67,8 @@
 
   public AccountSshKey(final AccountSshKey.Id i, final String pub) {
     id = i;
-    sshPublicKey = pub;
-    valid = true; // We can assume it is fine.
+    sshPublicKey = pub.replace("\n", "").replace("\r", "");
+    valid = id.isValid();
   }
 
   public Account.Id getAccount() {
@@ -83,50 +83,50 @@
     return sshPublicKey;
   }
 
-  public String getAlgorithm() {
-    final String s = getSshPublicKey();
-    if (s == null || s.length() == 0) {
-      return "none";
+  private String getPublicKeyPart(int index, String defaultValue) {
+    String s = getSshPublicKey();
+    if (s != null && s.length() > 0) {
+      String[] parts = s.split(" ");
+      if (parts.length > index) {
+        return parts[index];
+      }
     }
+    return defaultValue;
+  }
 
-    final String[] parts = s.split(" ");
-    if (parts.length < 1) {
-      return "none";
-    }
-    return parts[0];
+  public String getAlgorithm() {
+    return getPublicKeyPart(0, "none");
   }
 
   public String getEncodedKey() {
-    final String s = getSshPublicKey();
-    if (s == null || s.length() == 0) {
-      return null;
-    }
-
-    final String[] parts = s.split(" ");
-    if (parts.length < 2) {
-      return null;
-    }
-    return parts[1];
+    return getPublicKeyPart(1, null);
   }
 
   public String getComment() {
-    final String s = getSshPublicKey();
-    if (s == null || s.length() == 0) {
-      return "";
-    }
-
-    final String[] parts = s.split(" ", 3);
-    if (parts.length < 3) {
-      return "";
-    }
-    return parts[2];
+    return getPublicKeyPart(2, "");
   }
 
   public boolean isValid() {
-    return valid;
+    return valid && id.isValid();
   }
 
   public void setInvalid() {
     valid = false;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountSshKey) {
+      AccountSshKey other = (AccountSshKey) o;
+      return Objects.equals(id, other.id)
+          && Objects.equals(sshPublicKey, other.sshPublicKey)
+          && Objects.equals(valid, other.valid);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, sshPublicKey, valid);
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
index 3f04306..23685c5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
@@ -38,6 +38,10 @@
       set(branchName);
     }
 
+    public NameKey(String proj, final String branchName) {
+      this(new Project.NameKey(proj), branchName);
+    }
+
     @Override
     public String get() {
       return branchName;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index b1816a8..1864c56 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -166,6 +166,11 @@
       return null;
     }
 
+    public static Id fromRefPart(String ref) {
+      Integer id = RefNames.parseShardedRefPart(ref);
+      return id != null ? new Change.Id(id) : null;
+    }
+
     static int startIndex(String ref) {
       if (ref == null || !ref.startsWith(REFS_CHANGES)) {
         return -1;
@@ -275,6 +280,9 @@
   /** ID number of the first patch set in a change. */
   public static final int INITIAL_PATCH_SET_ID = 1;
 
+  /** Change-Id pattern. */
+  public static final String CHANGE_ID_PATTERN = "^[iI][0-9a-f]{4,}.*$";
+
   /**
    * Current state within the basic workflow of the change.
    *
@@ -284,7 +292,7 @@
    * codes ('A'..'Z') indicate a change that is closed and cannot be further
    * modified.
    * */
-  public static enum Status {
+  public enum Status {
     /**
      * Change is open and pending review, or review is in progress.
      *
@@ -359,7 +367,7 @@
     private final boolean closed;
     private final ChangeStatus changeStatus;
 
-    private Status(char c, ChangeStatus cs) {
+    Status(char c, ChangeStatus cs) {
       code = c;
       closed = !(MIN_OPEN <= c && c <= MAX_OPEN);
       changeStatus = cs;
@@ -473,6 +481,10 @@
   @Column(id = 18, notNull = false)
   protected String submissionId;
 
+  /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
+  @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
+  protected String noteDbState;
+
   protected Change() {
   }
 
@@ -501,6 +513,7 @@
     originalSubject = other.originalSubject;
     submissionId = other.submissionId;
     topic = other.topic;
+    noteDbState = other.noteDbState;
   }
 
   /** Legacy 32 bit integer identity for a change. */
@@ -526,6 +539,10 @@
     return createdOn;
   }
 
+  public void setCreatedOn(Timestamp ts) {
+    createdOn = ts;
+  }
+
   public Timestamp getLastUpdatedOn() {
     return lastUpdatedOn;
   }
@@ -542,10 +559,18 @@
     return owner;
   }
 
+  public void setOwner(Account.Id owner) {
+    this.owner = owner;
+  }
+
   public Branch.NameKey getDest() {
     return dest;
   }
 
+  public void setDest(Branch.NameKey dest) {
+    this.dest = dest;
+  }
+
   public Project.NameKey getProject() {
     return dest.getParentKey();
   }
@@ -558,6 +583,10 @@
     return originalSubject != null ? originalSubject : subject;
   }
 
+  public String getOriginalSubjectOrNull() {
+    return originalSubject;
+  }
+
   /** Get the id of the most current {@link PatchSet} in this change. */
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
@@ -583,6 +612,23 @@
     }
   }
 
+  public void setCurrentPatchSet(PatchSet.Id psId, String subject,
+      String originalSubject) {
+    if (!psId.getParentKey().equals(changeId)) {
+      throw new IllegalArgumentException(
+          "patch set ID " + psId + " is not for change " + changeId);
+    }
+    currentPatchSetId = psId.get();
+    this.subject = subject;
+    this.originalSubject = originalSubject;
+  }
+
+  public void clearCurrentPatchSet() {
+    currentPatchSetId = 0;
+    subject = null;
+    originalSubject = null;
+  }
+
   public String getSubmissionId() {
     return submissionId;
   }
@@ -607,6 +653,14 @@
     this.topic = topic;
   }
 
+  public String getNoteDbState() {
+    return noteDbState;
+  }
+
+  public void setNoteDbState(String state) {
+    noteDbState = state;
+  }
+
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index b98104a..898dc94 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -50,7 +50,7 @@
     }
 
     @Override
-    protected void set(String newValue) {
+    public void set(String newValue) {
       uuid = newValue;
     }
   }
@@ -74,6 +74,10 @@
   @Column(id = 5, notNull = false)
   protected PatchSet.Id patchset;
 
+  /** Tag associated with change message */
+  @Column(id = 6, notNull = false)
+  protected String tag;
+
   protected ChangeMessage() {
   }
 
@@ -105,6 +109,10 @@
     return writtenOn;
   }
 
+  public void setWrittenOn(Timestamp ts) {
+    writtenOn = ts;
+  }
+
   public String getMessage() {
     return message;
   }
@@ -113,6 +121,14 @@
     message = s;
   }
 
+  public String getTag() {
+    return tag;
+  }
+
+  public void setTag(String tag) {
+    this.tag = tag;
+  }
+
   public PatchSet.Id getPatchSetId() {
     return patchset;
   }
@@ -120,4 +136,16 @@
   public void setPatchSetId(PatchSet.Id id) {
     patchset = id;
   }
-}
\ No newline at end of file
+
+  @Override
+  public String toString() {
+    return "ChangeMessage{"
+        + "key=" + key
+        + ", author=" + author
+        + ", writtenOn=" + writtenOn
+        + ", patchset=" + patchset
+        + ", tag=" + tag
+        + ", message=[" + message
+        + "]}";
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
index b1b2615..5a98d94 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
@@ -96,4 +96,4 @@
     return "Range[startLine=" + startLine + ", startCharacter=" + startCharacter
         + ", endLine=" + endLine + ", endCharacter=" + endCharacter + "]";
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
index f2af5fa..5239447 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
@@ -20,7 +20,11 @@
 public class LabelId extends StringKey<com.google.gwtorm.client.Key<?>> {
   private static final long serialVersionUID = 1L;
 
-  public static final LabelId SUBMIT = new LabelId("SUBM");
+  static final String LEGACY_SUBMIT_NAME = "SUBM";
+
+  public static LabelId legacySubmit() {
+    return new LabelId(LEGACY_SUBMIT_NAME);
+  }
 
   @Column(id = 1)
   public String id;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
index 3637914..6a55965 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -68,7 +68,7 @@
   }
 
   /** Type of modification made to the file path. */
-  public static enum ChangeType implements CodedEnum {
+  public enum ChangeType implements CodedEnum {
     /** Path is being created/introduced by this patch. */
     ADDED('A'),
 
@@ -89,7 +89,7 @@
 
     private final char code;
 
-    private ChangeType(final char c) {
+    ChangeType(final char c) {
       code = c;
     }
 
@@ -113,7 +113,7 @@
   }
 
   /** Type of formatting for this patch. */
-  public static enum PatchType implements CodedEnum {
+  public enum PatchType implements CodedEnum {
     /**
      * A textual difference between two versions.
      *
@@ -148,7 +148,7 @@
 
     private final char code;
 
-    private PatchType(final char c) {
+    PatchType(final char c) {
       code = c;
     }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index 3ecd539..16b2d61 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -55,34 +55,19 @@
     public void set(String newValue) {
       uuid = newValue;
     }
-
-    @Override
-    public String toString() {
-      StringBuilder builder = new StringBuilder();
-      builder.append("PatchLineComment.Key{");
-      builder.append("Change.Id=")
-        .append(getParentKey().getParentKey().getParentKey().get()).append(',');
-      builder.append("PatchSet.Id=")
-        .append(getParentKey().getParentKey().get()).append(',');
-      builder.append("filename=")
-        .append(getParentKey().getFileName()).append(',');
-      builder.append("uuid=").append(get());
-      builder.append("}");
-      return builder.toString();
-    }
   }
 
   public static final char STATUS_DRAFT = 'd';
   public static final char STATUS_PUBLISHED = 'P';
 
-  public static enum Status {
+  public enum Status {
     DRAFT(STATUS_DRAFT),
 
     PUBLISHED(STATUS_PUBLISHED);
 
     private final char code;
 
-    private Status(final char c) {
+    Status(final char c) {
       code = c;
     }
 
@@ -137,6 +122,9 @@
   @Column(id = 9, notNull = false)
   protected CommentRange range;
 
+  @Column(id = 10, notNull = false)
+  protected String tag;
+
   /**
    * The RevId for the commit to which this comment is referring.
    *
@@ -159,10 +147,33 @@
     setWrittenOn(when);
   }
 
+  public PatchLineComment(PatchLineComment o) {
+    key = o.key;
+    lineNbr = o.lineNbr;
+    author = o.author;
+    writtenOn = o.writtenOn;
+    status = o.status;
+    side = o.side;
+    message = o.message;
+    parentUuid = o.parentUuid;
+    revId = o.revId;
+    if (o.range != null) {
+      range = new CommentRange(
+          o.range.getStartLine(),
+          o.range.getStartCharacter(),
+          o.range.getEndLine(),
+          o.range.getEndCharacter());
+    }
+  }
+
   public PatchLineComment.Key getKey() {
     return key;
   }
 
+  public PatchSet.Id getPatchSetId() {
+    return key.getParentKey().getParentKey();
+  }
+
   public int getLine() {
     return lineNbr;
   }
@@ -241,6 +252,14 @@
     return revId;
   }
 
+  public void setTag(String tag) {
+    this.tag = tag;
+  }
+
+  public String getTag() {
+    return tag;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof PatchLineComment) {
@@ -254,7 +273,8 @@
           && Objects.equals(message, c.getMessage())
           && Objects.equals(parentUuid, c.getParentUuid())
           && Objects.equals(range, c.getRange())
-          && Objects.equals(revId, c.getRevId());
+          && Objects.equals(revId, c.getRevId())
+          && Objects.equals(tag, c.getTag());
     }
     return false;
   }
@@ -281,6 +301,7 @@
     builder.append("range=").append(Objects.toString(range, ""))
       .append(',');
     builder.append("revId=").append(revId != null ? revId.get() : "");
+    builder.append("tag=").append(Objects.toString(tag, ""));
     builder.append('}');
     return builder.toString();
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 4f2ed31..a8bf07b 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -19,6 +19,7 @@
 
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 /** A single revision of a {@link Change}. */
@@ -38,9 +39,9 @@
     return isChangeRef(name);
   }
 
-  public static String joinGroups(Iterable<String> groups) {
+  static String joinGroups(List<String> groups) {
     if (groups == null) {
-      return null;
+      throw new IllegalArgumentException("groups may not be null");
     }
     StringBuilder sb = new StringBuilder();
     boolean first = true;
@@ -57,7 +58,7 @@
 
   public static List<String> splitGroups(String joinedGroups) {
     if (joinedGroups == null) {
-      return null;
+      throw new IllegalArgumentException("groups may not be null");
     }
     List<String> groups = new ArrayList<>();
     int i = 0;
@@ -184,12 +185,14 @@
    * Changes on the same branch having patch sets with intersecting groups are
    * considered related, as in the "Related Changes" tab.
    */
-  @Column(id = 6, notNull = false)
+  @Column(id = 6, notNull = false, length = Integer.MAX_VALUE)
   protected String groups;
 
+  //DELETED id = 7 (pushCertficate)
+
   /** Certificate sent with a push that created this patch set. */
-  @Column(id = 7, notNull = false, length = Integer.MAX_VALUE)
-  protected String pushCertficate;
+  @Column(id = 8, notNull = false, length = Integer.MAX_VALUE)
+  protected String pushCertificate;
 
   protected PatchSet() {
   }
@@ -239,10 +242,16 @@
   }
 
   public List<String> getGroups() {
+    if (groups == null) {
+      return Collections.emptyList();
+    }
     return splitGroups(groups);
   }
 
-  public void setGroups(Iterable<String> groups) {
+  public void setGroups(List<String> groups) {
+    if (groups == null) {
+      groups = Collections.emptyList();
+    }
     this.groups = joinGroups(groups);
   }
 
@@ -251,11 +260,11 @@
   }
 
   public String getPushCertificate() {
-    return pushCertficate;
+    return pushCertificate;
   }
 
   public void setPushCertificate(String cert) {
-    pushCertficate = cert;
+    pushCertificate = cert;
   }
 
   @Override
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index ddfc8c6..b9cd813 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -18,6 +18,7 @@
 import com.google.gwtorm.client.CompoundKey;
 
 import java.sql.Timestamp;
+import java.util.Date;
 import java.util.Objects;
 
 /** An approval (or negative approval) on a patch set. */
@@ -89,13 +90,16 @@
   @Column(id = 3)
   protected Timestamp granted;
 
+  @Column(id = 6, notNull = false)
+  protected String tag;
+
   // DELETED: id = 4 (changeOpen)
   // DELETED: id = 5 (changeSortKey)
 
   protected PatchSetApproval() {
   }
 
-  public PatchSetApproval(PatchSetApproval.Key k, short v, Timestamp ts) {
+  public PatchSetApproval(PatchSetApproval.Key k, short v, Date ts) {
     key = k;
     setValue(v);
     setGranted(ts);
@@ -106,6 +110,7 @@
         new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
+    tag = src.tag;
   }
 
   public PatchSetApproval.Key getKey() {
@@ -136,22 +141,34 @@
     return granted;
   }
 
-  public void setGranted(Timestamp ts) {
-    granted = ts;
+  public void setGranted(Date when) {
+    if (when instanceof Timestamp) {
+      granted = (Timestamp) when;
+    } else {
+      granted = new Timestamp(when.getTime());
+    }
+  }
+
+  public void setTag(String t) {
+    tag = t;
   }
 
   public String getLabel() {
     return getLabelId().get();
   }
 
-  public boolean isSubmit() {
-    return LabelId.SUBMIT.get().equals(getLabel());
+  public boolean isLegacySubmit() {
+    return LabelId.LEGACY_SUBMIT_NAME.equals(getLabel());
+  }
+
+  public String getTag() {
+    return tag;
   }
 
   @Override
   public String toString() {
     return new StringBuilder().append('[').append(key).append(": ")
-        .append(value).append(']').toString();
+        .append(value).append(",tag:").append(tag).append(']').toString();
   }
 
   @Override
@@ -160,13 +177,14 @@
       PatchSetApproval p = (PatchSetApproval) o;
       return Objects.equals(key, p.key)
           && Objects.equals(value, p.value)
-          && Objects.equals(granted, p.granted);
+          && Objects.equals(granted, p.granted)
+          && Objects.equals(tag, p.tag);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(key, value, granted);
+    return Objects.hash(key, value, granted, tag);
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index af9e75c..74ebb25 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -24,7 +24,7 @@
 public final class Project {
   /** Project name key */
   public static class NameKey extends
-      StringKey<com.google.gwtorm.client.Key<?>>{
+      StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     @Column(id = 1)
@@ -99,6 +99,8 @@
   protected InheritableBoolean enableSignedPush;
   protected InheritableBoolean requireSignedPush;
 
+  protected InheritableBoolean rejectImplicitMerges;
+
   protected Project() {
   }
 
@@ -151,6 +153,10 @@
     return maxObjectSizeLimit;
   }
 
+  public InheritableBoolean getRejectImplicitMerges() {
+    return rejectImplicitMerges;
+  }
+
   public void setUseContributorAgreements(final InheritableBoolean u) {
     useContributorAgreements = u;
   }
@@ -196,6 +202,10 @@
     maxObjectSizeLimit = limit;
   }
 
+  public void setRejectImplicitMerges(InheritableBoolean check) {
+    rejectImplicitMerges = check;
+  }
+
   public SubmitType getSubmitType() {
     return submitType;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index 0940a7f..95f4f8e 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.reviewdb.client;
 
-
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
   public static final String HEAD = "HEAD";
@@ -27,6 +26,8 @@
 
   public static final String REFS_CHANGES = "refs/changes/";
 
+  public static final String REFS_META = "refs/meta/";
+
   /** Note tree listing commits we refuse {@code refs/meta/reject-commits} */
   public static final String REFS_REJECT_COMMITS = "refs/meta/reject-commits";
 
@@ -36,14 +37,24 @@
   /** Preference settings for a user {@code refs/users} */
   public static final String REFS_USERS = "refs/users/";
 
+  /** Magic user branch in All-Users {@code refs/users/self} */
+  public static final String REFS_USERS_SELF = "refs/users/self";
+
   /** Default user preference settings */
   public static final String REFS_USERS_DEFAULT = RefNames.REFS_USERS + "default";
 
   /** Configurations of project-specific dashboards (canned search queries). */
   public static final String REFS_DASHBOARDS = "refs/meta/dashboards/";
 
+  /** Draft inline comments of a user on a change */
   public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
 
+  /** A change starred by a user */
+  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
+
+  /** Sequence counters in NoteDb. */
+  public static final String REFS_SEQUENCES = "refs/sequences/";
+
   /**
    * Prefix applied to merge commit base nodes.
    * <p>
@@ -56,7 +67,7 @@
    */
   public static final String REFS_CACHE_AUTOMERGE = "refs/cache-automerge/";
 
-  /** Suffix of a meta ref in the notedb. */
+  /** Suffix of a meta ref in the NoteDb. */
   public static final String META_SUFFIX = "/meta";
 
   public static final String EDIT_PREFIX = "edit-";
@@ -75,33 +86,63 @@
     return ref;
   }
 
-  public static String refsUsers(Account.Id accountId) {
+  public static String changeMetaRef(Change.Id id) {
     StringBuilder r = new StringBuilder();
-    r.append(REFS_USERS);
-    int account = accountId.get();
-    int m = account % 100;
-    if (m < 10) {
-      r.append('0');
-    }
-    r.append(m);
-    r.append('/');
-    r.append(account);
+    r.append(REFS_CHANGES);
+    r.append(shard(id.get()));
+    r.append(META_SUFFIX);
     return r.toString();
   }
 
-  public static String refsDraftComments(Account.Id accountId,
-      Change.Id changeId) {
+  public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
-    r.append(REFS_DRAFT_COMMENTS);
-    int n = accountId.get() % 100;
+    r.append(REFS_USERS);
+    r.append(shard(accountId.get()));
+    return r.toString();
+  }
+
+  public static String refsDraftComments(Change.Id changeId,
+      Account.Id accountId) {
+    StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get());
+    r.append(accountId.get());
+    return r.toString();
+  }
+
+  public static String refsDraftCommentsPrefix(Change.Id changeId) {
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).toString();
+  }
+
+  public static String refsStarredChanges(Change.Id changeId,
+      Account.Id accountId) {
+    StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get());
+    r.append(accountId.get());
+    return r.toString();
+  }
+
+  public static String refsStarredChangesPrefix(Change.Id changeId) {
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).toString();
+  }
+
+  private static StringBuilder buildRefsPrefix(String prefix, int id) {
+    StringBuilder r = new StringBuilder();
+    r.append(prefix);
+    r.append(shard(id));
+    r.append('/');
+    return r;
+  }
+
+  public static String shard(int id) {
+    if (id < 0) {
+      return null;
+    }
+    StringBuilder r = new StringBuilder();
+    int n = id % 100;
     if (n < 10) {
       r.append('0');
     }
     r.append(n);
     r.append('/');
-    r.append(accountId.get());
-    r.append('-');
-    r.append(changeId.get());
+    r.append(id);
     return r.toString();
   }
 
@@ -128,12 +169,81 @@
    * @return reference prefix for this change edit
    */
   public static String refsEditPrefix(Account.Id accountId, Change.Id changeId) {
-    return new StringBuilder(refsUsers(accountId))
-      .append('/')
-      .append(EDIT_PREFIX)
-      .append(changeId.get())
-      .append('/')
-      .toString();
+    return refsEditPrefix(accountId) + changeId.get() + '/';
+  }
+
+  public static String refsEditPrefix(Account.Id accountId) {
+    return refsUsers(accountId) + '/' + EDIT_PREFIX;
+  }
+
+  public static boolean isRefsEdit(String ref) {
+    return ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
+  }
+
+  public static boolean isRefsUsers(String ref) {
+    return ref.startsWith(REFS_USERS);
+  }
+
+  static Integer parseShardedRefPart(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String[] parts = name.split("/");
+    int n = parts.length;
+    if (n < 2) {
+      return null;
+    }
+
+    // Last 2 digits.
+    int le;
+    for (le = 0; le < parts[0].length(); le++) {
+      if (!Character.isDigit(parts[0].charAt(le))) {
+        return null;
+      }
+    }
+    if (le != 2) {
+      return null;
+    }
+
+    // Full ID.
+    int ie;
+    for (ie = 0; ie < parts[1].length(); ie++) {
+      if (!Character.isDigit(parts[1].charAt(ie))) {
+        if (ie == 0) {
+          return null;
+        }
+        break;
+      }
+    }
+
+    int shard = Integer.parseInt(parts[0]);
+    int id = Integer.parseInt(parts[1].substring(0, ie));
+
+    if (id % 100 != shard) {
+      return null;
+    }
+    return id;
+  }
+
+  static Integer parseRefSuffix(String name) {
+    if (name == null) {
+      return null;
+    }
+    int i = name.length();
+    while (i > 0) {
+      char c = name.charAt(i - 1);
+      if (c == '/') {
+        break;
+      } else if (!Character.isDigit(c)) {
+        return null;
+      }
+      i--;
+    }
+    if (i == 0) {
+      return null;
+    }
+    return Integer.valueOf(name.substring(i, name.length()));
   }
 
   private RefNames() {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
index a73fe5d..42f3017 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
@@ -69,4 +69,8 @@
   public String toString() {
     return getClass().getSimpleName() + "{" + id + "}";
   }
+
+  public boolean matches(String str) {
+    return id.startsWith(str.toLowerCase());
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/StarredChange.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/StarredChange.java
deleted file mode 100644
index d427b09..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/StarredChange.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-
-/** A {@link Change} starred by an {@link Account}. */
-public class StarredChange {
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected Change.Id changeId;
-
-    protected Key() {
-      accountId = new Account.Id();
-      changeId = new Change.Id();
-    }
-
-    public Key(final Account.Id a, final Change.Id g) {
-      accountId = a;
-      changeId = g;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Change.Id getChangeId() {
-      return changeId;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {changeId};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  protected StarredChange() {
-  }
-
-  public StarredChange(final StarredChange.Key k) {
-    key = k;
-  }
-
-  public StarredChange.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public Change.Id getChangeId() {
-    return key.changeId;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountPatchReviewAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountPatchReviewAccess.java
deleted file mode 100644
index 6b9e160..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountPatchReviewAccess.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountPatchReviewAccess
-    extends Access<AccountPatchReview, AccountPatchReview.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountPatchReview get(AccountPatchReview.Key id) throws OrmException;
-
-  @Query("WHERE key.accountId = ? AND key.patchKey.patchSetId = ?")
-  ResultSet<AccountPatchReview> byReviewer(Account.Id who, PatchSet.Id ps) throws OrmException;
-
-  @Query("WHERE key.patchKey.patchSetId = ?")
-  ResultSet<AccountPatchReview> byPatchSet(PatchSet.Id ps) throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountSshKeyAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountSshKeyAccess.java
deleted file mode 100644
index 6f71ba4..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountSshKeyAccess.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountSshKeyAccess extends
-    Access<AccountSshKey, AccountSshKey.Id> {
-  @Override
-  @PrimaryKey("id")
-  AccountSshKey get(AccountSshKey.Id id) throws OrmException;
-
-  @Query("WHERE id.accountId = ?")
-  ResultSet<AccountSshKey> byAccount(Account.Id id) throws OrmException;
-
-  @Query("WHERE id.accountId = ? ORDER BY id.seq DESC LIMIT 1")
-  ResultSet<AccountSshKey> byAccountLast(Account.Id id) throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
new file mode 100644
index 0000000..b70778e
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
@@ -0,0 +1,289 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public class DisabledChangesReviewDbWrapper extends ReviewDbWrapper {
+  private static final String MSG = "This table has been migrated to NoteDb";
+
+  private final DisabledChangeAccess changes;
+  private final DisabledPatchSetApprovalAccess patchSetApprovals;
+  private final DisabledChangeMessageAccess changeMessages;
+  private final DisabledPatchSetAccess patchSets;
+  private final DisabledPatchLineCommentAccess patchComments;
+
+  public DisabledChangesReviewDbWrapper(ReviewDb db) {
+    super(db);
+    changes = new DisabledChangeAccess(delegate.changes());
+    patchSetApprovals =
+        new DisabledPatchSetApprovalAccess(delegate.patchSetApprovals());
+    changeMessages = new DisabledChangeMessageAccess(delegate.changeMessages());
+    patchSets = new DisabledPatchSetAccess(delegate.patchSets());
+    patchComments =
+        new DisabledPatchLineCommentAccess(delegate.patchComments());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changes;
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return patchSetApprovals;
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return changeMessages;
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return patchSets;
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return patchComments;
+  }
+
+  private static class DisabledChangeAccess extends ChangeAccessWrapper {
+
+    protected DisabledChangeAccess(ChangeAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<Change> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public CheckedFuture<Change, OrmException> getAsync(Change.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<Change> get(Iterable<Change.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public Change get(Change.Id id) throws OrmException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<Change> all() throws OrmException {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class DisabledPatchSetApprovalAccess
+      extends PatchSetApprovalAccessWrapper {
+    DisabledPatchSetApprovalAccess(PatchSetApprovalAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public CheckedFuture<PatchSetApproval, OrmException> getAsync(
+        PatchSetApproval.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> get(
+        Iterable<PatchSetApproval.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchSetApproval get(PatchSetApproval.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class DisabledChangeMessageAccess
+      extends ChangeMessageAccessWrapper {
+    DisabledChangeMessageAccess(ChangeMessageAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public CheckedFuture<ChangeMessage, OrmException> getAsync(
+        ChangeMessage.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ChangeMessage get(ChangeMessage.Key id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class DisabledPatchSetAccess extends PatchSetAccessWrapper {
+    DisabledPatchSetAccess(PatchSetAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchSet> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public CheckedFuture<PatchSet, OrmException> getAsync(PatchSet.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchSet get(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class DisabledPatchLineCommentAccess
+      extends PatchLineCommentAccessWrapper {
+    DisabledPatchLineCommentAccess(PatchLineCommentAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public CheckedFuture<PatchLineComment, OrmException> getAsync(
+        PatchLineComment.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> get(
+        Iterable<PatchLineComment.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchLineComment get(PatchLineComment.Key id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id,
+        String file) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(
+        PatchSet.Id patchset) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id,
+        String file, Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+}
+
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
index 36e969e..f7452c5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gwtorm.server.Access;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.PrimaryKey;
@@ -30,11 +29,4 @@
 
   @Query("WHERE id.changeId = ? ORDER BY id.patchSetId")
   ResultSet<PatchSet> byChange(Change.Id id) throws OrmException;
-
-  @Query("WHERE revision = ?")
-  ResultSet<PatchSet> byRevision(RevId rev) throws OrmException;
-
-  @Query("WHERE revision >= ? AND revision <= ?")
-  ResultSet<PatchSet> byRevisionRange(RevId reva, RevId revb)
-      throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index a63e638..c585ca5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -53,8 +53,7 @@
   @Relation(id = 7)
   AccountExternalIdAccess accountExternalIds();
 
-  @Relation(id = 8)
-  AccountSshKeyAccess accountSshKeys();
+  // Deleted @Relation(id = 8)
 
   @Relation(id = 10)
   AccountGroupAccess accountGroups();
@@ -68,16 +67,14 @@
   @Relation(id = 13)
   AccountGroupMemberAuditAccess accountGroupMembersAudit();
 
-  //Deleted @Relation(id = 17)
+  // Deleted @Relation(id = 17)
 
-  @Relation(id = 18)
-  StarredChangeAccess starredChanges();
+  // Deleted @Relation(id = 18)
 
   @Relation(id = 19)
   AccountProjectWatchAccess accountProjectWatches();
 
-  @Relation(id = 20)
-  AccountPatchReviewAccess accountPatchReviews();
+  // Deleted @Relation(id = 20)
 
   @Relation(id = 21)
   ChangeAccess changes();
@@ -96,8 +93,7 @@
   @Relation(id = 26)
   PatchLineCommentAccess patchComments();
 
-  @Relation(id = 28)
-  SubmoduleSubscriptionAccess submoduleSubscriptions();
+  // Deleted @Relation(id = 28)
 
   @Relation(id = 29)
   AccountGroupByIdAccess accountGroupById();
@@ -113,8 +109,15 @@
   @Sequence
   int nextAccountGroupId() throws OrmException;
 
-  /** Next unique id for a {@link Change}. */
-  @Sequence
+  int FIRST_CHANGE_ID = 1;
+
+  /**
+   * Next unique id for a {@link Change}.
+   *
+   * @deprecated use {@link com.google.gerrit.server.Sequences#nextChangeId()}.
+   */
+  @Sequence(startWith = FIRST_CHANGE_ID)
+  @Deprecated
   int nextChangeId() throws OrmException;
 
   /**
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
new file mode 100644
index 0000000..42d0993
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.client.IntKey;
+
+/** Static utilities for ReviewDb types. */
+public class ReviewDbUtil {
+  public static final Function<IntKey<?>, Integer> INT_KEY_FUNCTION =
+      new Function<IntKey<?>, Integer>() {
+        @Override
+        public Integer apply(IntKey<?> in) {
+          return in.get();
+        }
+      };
+
+  private static final Function<Change, Change.Id> CHANGE_ID_FUNCTION =
+      new Function<Change, Change.Id>() {
+        @Override
+        public Change.Id apply(Change in) {
+          return in.getId();
+        }
+      };
+
+  private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
+      Ordering.natural().nullsFirst().onResultOf(INT_KEY_FUNCTION).nullsFirst();
+
+  @SuppressWarnings("unchecked")
+  public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
+    return (Ordering<K>) INT_KEY_ORDERING;
+  }
+
+  public static Function<Change, Change.Id> changeIdFunction() {
+    return CHANGE_ID_FUNCTION;
+  }
+
+  public static ReviewDb unwrapDb(ReviewDb db) {
+    if (db instanceof DisabledChangesReviewDbWrapper) {
+      return ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
+    }
+    return db;
+  }
+
+  private ReviewDbUtil() {
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
new file mode 100644
index 0000000..6b25378
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -0,0 +1,704 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.server;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.gwtorm.server.StatementExecutor;
+
+import java.util.Map;
+
+public class ReviewDbWrapper implements ReviewDb {
+  protected final ReviewDb delegate;
+
+  protected ReviewDbWrapper(ReviewDb delegate) {
+    this.delegate = checkNotNull(delegate);
+  }
+
+  @Override
+  public void commit() throws OrmException {
+    delegate.commit();
+  }
+
+  @Override
+  public void rollback() throws OrmException {
+    delegate.rollback();
+  }
+
+  @Override
+  public void updateSchema(StatementExecutor e) throws OrmException {
+    delegate.updateSchema(e);
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e) throws OrmException {
+    delegate.pruneSchema(e);
+  }
+
+  @Override
+  public Access<?, ?>[] allRelations() {
+    return delegate.allRelations();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public SchemaVersionAccess schemaVersion() {
+    return delegate.schemaVersion();
+  }
+
+  @Override
+  public SystemConfigAccess systemConfig() {
+    return delegate.systemConfig();
+  }
+
+  @Override
+  public AccountAccess accounts() {
+    return delegate.accounts();
+  }
+
+  @Override
+  public AccountExternalIdAccess accountExternalIds() {
+    return delegate.accountExternalIds();
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return delegate.accountGroups();
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return delegate.accountGroupNames();
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return delegate.accountGroupMembers();
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return delegate.accountGroupMembersAudit();
+  }
+
+  @Override
+  public AccountProjectWatchAccess accountProjectWatches() {
+    return delegate.accountProjectWatches();
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return delegate.changes();
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return delegate.patchSetApprovals();
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return delegate.changeMessages();
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return delegate.patchSets();
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return delegate.patchComments();
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return delegate.accountGroupById();
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return delegate.accountGroupByIdAud();
+  }
+
+  @Override
+  public int nextAccountId() throws OrmException {
+    return delegate.nextAccountId();
+  }
+
+  @Override
+  public int nextAccountGroupId() throws OrmException {
+    return delegate.nextAccountGroupId();
+  }
+
+  @Override
+  @SuppressWarnings("deprecation")
+  public int nextChangeId() throws OrmException {
+    return delegate.nextChangeId();
+  }
+
+  @Override
+  public int nextChangeMessageId() throws OrmException {
+    return delegate.nextChangeMessageId();
+  }
+
+  public static class ChangeAccessWrapper implements ChangeAccess {
+    protected final ChangeAccess delegate;
+
+    protected ChangeAccessWrapper(ChangeAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<Change> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public Change.Id primaryKey(Change entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<Change.Id, Change> toMap(Iterable<Change> c) {
+      return delegate.toMap(c);
+    }
+
+    @Override
+    public CheckedFuture<Change, OrmException> getAsync(Change.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<Change> get(Iterable<Change.Id> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<Change> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<Change> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<Change> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<Change.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<Change> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(Change.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public Change get(Change.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<Change> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class PatchSetApprovalAccessWrapper
+      implements PatchSetApprovalAccess {
+    protected final PatchSetApprovalAccess delegate;
+
+    protected PatchSetApprovalAccessWrapper(PatchSetApprovalAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> iterateAllEntities()
+        throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchSetApproval.Key primaryKey(PatchSetApproval entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(
+        Iterable<PatchSetApproval> c) {
+      return delegate.toMap(c);
+    }
+
+    @Override
+    public CheckedFuture<PatchSetApproval, OrmException> getAsync(
+        PatchSetApproval.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchSetApproval> instances)
+        throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchSetApproval> instances)
+        throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchSetApproval> instances)
+        throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchSetApproval.Key> keys)
+        throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchSetApproval> instances)
+        throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchSetApproval.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchSetApproval atomicUpdate(PatchSetApproval.Key key,
+        AtomicUpdate<PatchSetApproval> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchSetApproval get(PatchSetApproval.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id)
+        throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id)
+        throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet,
+        Account.Id account) throws OrmException {
+      return delegate.byPatchSetUser(patchSet, account);
+    }
+  }
+
+  public static class ChangeMessageAccessWrapper
+      implements ChangeMessageAccess {
+    protected final ChangeMessageAccess delegate;
+
+    protected ChangeMessageAccessWrapper(ChangeMessageAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public ChangeMessage.Key primaryKey(ChangeMessage entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<ChangeMessage.Key, ChangeMessage> toMap(
+        Iterable<ChangeMessage> c) {
+      return delegate.toMap(c);
+    }
+
+    @Override
+    public CheckedFuture<ChangeMessage, OrmException> getAsync(
+        ChangeMessage.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<ChangeMessage.Key> keys)
+        throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<ChangeMessage> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(ChangeMessage.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public ChangeMessage atomicUpdate(ChangeMessage.Key key,
+        AtomicUpdate<ChangeMessage> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public ChangeMessage get(ChangeMessage.Key id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id)
+        throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() throws OrmException {
+      return delegate.all();
+    }
+
+  }
+
+  public static class PatchSetAccessWrapper implements PatchSetAccess {
+    protected final PatchSetAccess delegate;
+
+    protected PatchSetAccessWrapper(PatchSetAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchSet> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchSet.Id primaryKey(PatchSet entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchSet.Id, PatchSet> toMap(Iterable<PatchSet> c) {
+      return delegate.toMap(c);
+    }
+
+    @Override
+    public CheckedFuture<PatchSet, OrmException> getAsync(PatchSet.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchSet> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchSet> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchSet> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchSet.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchSet> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchSet.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchSet atomicUpdate(PatchSet.Id key, AtomicUpdate<PatchSet> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchSet get(PatchSet.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) throws OrmException {
+      return delegate.byChange(id);
+    }
+
+  }
+
+  public static class PatchLineCommentAccessWrapper
+      implements PatchLineCommentAccess {
+    protected PatchLineCommentAccess delegate;
+
+    protected PatchLineCommentAccessWrapper(PatchLineCommentAccess delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> iterateAllEntities()
+        throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public PatchLineComment.Key primaryKey(PatchLineComment entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<PatchLineComment.Key, PatchLineComment> toMap(
+        Iterable<PatchLineComment> c) {
+      return delegate.toMap(c);
+    }
+
+    @Override
+    public CheckedFuture<PatchLineComment, OrmException> getAsync(
+        PatchLineComment.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<PatchLineComment> instances)
+        throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<PatchLineComment> instances)
+        throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<PatchLineComment> instances)
+        throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<PatchLineComment.Key> keys)
+        throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<PatchLineComment> instances)
+        throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(PatchLineComment.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public PatchLineComment atomicUpdate(PatchLineComment.Key key,
+        AtomicUpdate<PatchLineComment> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public PatchLineComment get(PatchLineComment.Key id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id)
+        throws OrmException {
+      return delegate.byChange(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id)
+        throws OrmException {
+      return delegate.byPatchSet(id);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id,
+        String file) throws OrmException {
+      return delegate.publishedByChangeFile(id, file);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset)
+        throws OrmException {
+      return delegate.publishedByPatchSet(patchset);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) throws OrmException {
+      return delegate.draftByPatchSetAuthor(patchset, author);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id,
+        String file, Account.Id author) throws OrmException {
+      return delegate.draftByChangeFileAuthor(id, file, author);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author)
+        throws OrmException {
+      return delegate.draftByAuthor(author);
+    }
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/StarredChangeAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/StarredChangeAccess.java
deleted file mode 100644
index 5f57fe7..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/StarredChangeAccess.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface StarredChangeAccess extends
-    Access<StarredChange, StarredChange.Key> {
-  @Override
-  @PrimaryKey("key")
-  StarredChange get(StarredChange.Key key) throws OrmException;
-
-  @Query("WHERE key.accountId = ?")
-  ResultSet<StarredChange> byAccount(Account.Id id) throws OrmException;
-
-  @Query("WHERE key.changeId = ?")
-  ResultSet<StarredChange> byChange(Change.Id id) throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SubmoduleSubscriptionAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SubmoduleSubscriptionAccess.java
deleted file mode 100644
index b25e406..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SubmoduleSubscriptionAccess.java
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface SubmoduleSubscriptionAccess extends
-    Access<SubmoduleSubscription, SubmoduleSubscription.Key> {
-  @Override
-  @PrimaryKey("key")
-  SubmoduleSubscription get(SubmoduleSubscription.Key key) throws OrmException;
-
-  @Query("WHERE key.superProject = ?")
-  ResultSet<SubmoduleSubscription> bySuperProject(Branch.NameKey superProject)
-      throws OrmException;
-
-  /**
-   * Fetches all {@code SubmoduleSubscription}s in which some branch of
-   * {@code superProject} subscribes a branch.
-   *
-   * Use {@link #bySuperProject(Branch.NameKey)} to fetch for a branch instead
-   * of a project.
-   *
-   * @param superProject the project to fetch subscriptions for
-   * @return {@code SubmoduleSubscription}s that are subscribed by some
-   * branch of {@code superProject}.
-   * @throws OrmException
-   */
-  @Query("WHERE key.superProject.projectName = ?")
-  ResultSet<SubmoduleSubscription> bySuperProjectProject(Project.NameKey superProject)
-      throws OrmException;
-
-  @Query("WHERE submodule = ?")
-  ResultSet<SubmoduleSubscription> bySubmodule(Branch.NameKey submodule)
-      throws OrmException;
-
-  /**
-   * Fetches all {@code SubmoduleSubscription}s in which some branch of
-   * {@code submodule} is subscribed.
-   *
-   * Use {@link #bySubmodule(Branch.NameKey)} to fetch for a branch instead of
-   * a project.
-   *
-   * @param submodule the project to fetch subscriptions for.
-   * @return {@code SubmoduleSubscription}s that subscribe some branch of
-   * {@code submodule}.
-   * @throws OrmException
-   */
-  @Query("WHERE submodule.projectName = ?")
-  ResultSet<SubmoduleSubscription> bySubmoduleProject(Project.NameKey submodule)
-      throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index 1162a5f..2110295 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -48,11 +48,6 @@
 
 
 -- *********************************************************************
--- AccountSshKeyAccess
---    @PrimaryKey covers: byAccount, valid
-
-
--- *********************************************************************
 -- ApprovalCategoryAccess
 --    too small to bother indexing
 
@@ -86,16 +81,3 @@
 -- PatchSetAccess
 CREATE INDEX patch_sets_byRevision
 ON patch_sets (revision);
-
--- *********************************************************************
--- StarredChangeAccess
---    @PrimaryKey covers: byAccount
-
-CREATE INDEX starred_changes_byChange
-ON starred_changes (change_id);
-
--- *********************************************************************
--- SubmoduleSubscriptionAccess
-
-CREATE INDEX submodule_subscr_acc_byS
-ON submodule_subscriptions (submodule_project_name, submodule_branch_name);
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 258b7be..334b6c4 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -55,11 +55,6 @@
 #
 
 -- *********************************************************************
--- AccountSshKeyAccess
---    @PrimaryKey covers: byAccount, valid
-
-
--- *********************************************************************
 -- ApprovalCategoryAccess
 --    too small to bother indexing
 
@@ -95,19 +90,3 @@
 CREATE INDEX patch_sets_byRevision
 ON patch_sets (revision)
 #
-
--- *********************************************************************
--- StarredChangeAccess
---    @PrimaryKey covers: byAccount
-
-CREATE INDEX starred_changes_byChange
-ON starred_changes (change_id)
-#
-
--- *********************************************************************
--- SubmoduleSubscriptionAccess
-
-CREATE INDEX submod_subscr_ac_bySubscription
-ON submodule_subscriptions (submodule_project_name, submodule_branch_name)
-#
-
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index 1fe7dce..bdceb7b 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -9,7 +9,6 @@
 ALTER TABLE patch_set_approvals CLUSTER ON patch_set_approvals_pkey;
 
 ALTER TABLE account_group_members CLUSTER ON account_group_members_pkey;
-ALTER TABLE starred_changes CLUSTER ON starred_changes_pkey;
 CLUSTER;
 
 
@@ -96,11 +95,6 @@
 
 
 -- *********************************************************************
--- AccountSshKeyAccess
---    @PrimaryKey covers: byAccount, valid
-
-
--- *********************************************************************
 -- ApprovalCategoryAccess
 --    too small to bother indexing
 
@@ -135,16 +129,3 @@
 -- PatchSetAccess
 CREATE INDEX patch_sets_byRevision
 ON patch_sets (revision);
-
--- *********************************************************************
--- StarredChangeAccess
---    @PrimaryKey covers: byAccount
-
-CREATE INDEX starred_changes_byChange
-ON starred_changes (change_id);
-
--- *********************************************************************
--- SubmoduleSubscriptionAccess
-
-CREATE INDEX submodule_subscr_acc_byS
-ON submodule_subscriptions (submodule_project_name, submodule_branch_name);
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
new file mode 100644
index 0000000..07c00b9
--- /dev/null
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class AccountSshKeyTest {
+  private static final String KEY =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
+      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+      + "w== john.doe@example.com";
+
+  private static final String KEY_WITH_NEWLINES =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS\n"
+      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28\n"
+      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T\n"
+      + "w== john.doe@example.com";
+
+  private final Account.Id accountId = new Account.Id(1);
+
+  @Test
+  public void testValidity() throws Exception {
+    AccountSshKey key = new AccountSshKey(
+        new AccountSshKey.Id(accountId, -1), KEY);
+    assertThat(key.isValid()).isFalse();
+    key = new AccountSshKey(new AccountSshKey.Id(accountId, 0), KEY);
+    assertThat(key.isValid()).isFalse();
+    key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY);
+    assertThat(key.isValid()).isTrue();
+  }
+
+  @Test
+  public void testGetters() throws Exception {
+    AccountSshKey key = new AccountSshKey(
+        new AccountSshKey.Id(accountId, 1), KEY);
+    assertThat(key.getSshPublicKey()).isEqualTo(KEY);
+    assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
+    assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
+    assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
+  }
+
+  @Test
+  public void testKeyWithNewLines() throws Exception {
+    AccountSshKey key = new AccountSshKey(
+        new AccountSshKey.Id(accountId, 1), KEY_WITH_NEWLINES);
+    assertThat(key.getSshPublicKey()).isEqualTo(KEY);
+    assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
+    assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
+    assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
+  }
+}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
index e8dc5e0..00bf44e 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
@@ -14,69 +14,48 @@
 
 package com.google.gerrit.reviewdb.client;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.Account.Id.fromRef;
+import static com.google.gerrit.reviewdb.client.Account.Id.fromRefPart;
+import static com.google.gerrit.reviewdb.client.Account.Id.fromRefSuffix;
 
 import org.junit.Test;
 
 public class AccountTest {
   @Test
   public void parseRefName() {
-    assertRef(1, "refs/users/01/1");
-    assertRef(1, "refs/users/01/1-drafts");
-    assertRef(1, "refs/users/01/1-drafts/2");
-    assertRef(1, "refs/users/01/1/edit/2");
+    assertThat(fromRef("refs/users/01/1")).isEqualTo(id(1));
+    assertThat(fromRef("refs/users/01/1-drafts")).isEqualTo(id(1));
+    assertThat(fromRef("refs/users/01/1-drafts/2")).isEqualTo(id(1));
+    assertThat(fromRef("refs/users/01/1/edit/2")).isEqualTo(id(1));
 
-    assertNotRef(null);
-    assertNotRef("");
+    assertThat(fromRef(null)).isNull();
+    assertThat(fromRef("")).isNull();
 
     // Invalid characters.
-    assertNotRef("refs/users/01a/1");
-    assertNotRef("refs/users/01/a1");
+    assertThat(fromRef("refs/users/01a/1")).isNull();
+    assertThat(fromRef("refs/users/01/a1")).isNull();
 
     // Mismatched shard.
-    assertNotRef("refs/users/01/23");
+    assertThat(fromRef("refs/users/01/23")).isNull();
 
     // Shard too short.
-    assertNotRef("refs/users/1/1");
+    assertThat(fromRef("refs/users/1/1")).isNull();
   }
 
   @Test
   public void parseRefNameParts() {
-    assertRefPart(1, "01/1");
-    assertRefPart(1, "01/1-drafts");
-    assertRefPart(1, "01/1-drafts/2");
-
-    assertNotRefPart(null);
-    assertNotRefPart("");
-
-    // This method assumes that the common prefix "refs/users/" will be removed.
-    assertNotRefPart("refs/users/01/1");
-
-    // Invalid characters.
-    assertNotRefPart("01a/1");
-    assertNotRefPart("01/a1");
-
-    // Mismatched shard.
-    assertNotRefPart("01/23");
-
-    // Shard too short.
-    assertNotRefPart("1/1");
+    assertThat(fromRefPart("01/1")).isEqualTo(id(1));
+    assertThat(fromRefPart("ab/cd")).isNull();
   }
 
-  private static void assertRef(int accountId, String refName) {
-    assertEquals(new Account.Id(accountId), Account.Id.fromRef(refName));
+  @Test
+  public void parseRefSuffix() {
+    assertThat(fromRefSuffix("12/34")).isEqualTo(id(34));
+    assertThat(fromRefSuffix("ab/cd")).isNull();
   }
 
-  private static void assertNotRef(String refName) {
-    assertNull(Account.Id.fromRef(refName));
-  }
-
-  private static void assertRefPart(int accountId, String refName) {
-    assertEquals(new Account.Id(accountId), Account.Id.fromRefPart(refName));
-  }
-
-  private static void assertNotRefPart(String refName) {
-    assertNull(Account.Id.fromRefPart(refName));
+  private Account.Id id(int n) {
+    return new Account.Id(n);
   }
 }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
index 47f409a..cf2d289 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.reviewdb.client;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 
 import org.junit.Test;
 
@@ -79,6 +81,27 @@
         .isEqualTo("refs/changes/34/1234/");
   }
 
+  @Test
+  public void parseRefNameParts() {
+    assertRefPart(1, "01/1");
+
+    assertNotRefPart(null);
+    assertNotRefPart("");
+
+    // This method assumes that the common prefix "refs/changes/" was removed.
+    assertNotRefPart("refs/changes/01/1");
+
+    // Invalid characters.
+    assertNotRefPart("01a/1");
+    assertNotRefPart("01/a1");
+
+    // Mismatched shard.
+    assertNotRefPart("01/23");
+
+    // Shard too short.
+    assertNotRefPart("1/1");
+  }
+
   private static void assertRef(int changeId, String refName) {
     assertThat(Change.Id.fromRef(refName)).isEqualTo(new Change.Id(changeId));
   }
@@ -86,4 +109,12 @@
   private static void assertNotRef(String refName) {
     assertThat(Change.Id.fromRef(refName)).isNull();
   }
+
+  private static void assertRefPart(int changeId, String refName) {
+    assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
+  }
+
+  private static void assertNotRefPart(String refName) {
+    assertNull(Change.Id.fromRefPart(refName));
+  }
 }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
index 7a6be87..0f8aba6 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
@@ -65,7 +65,6 @@
 
   @Test
   public void testSplitGroups() {
-    assertThat(splitGroups(null)).isNull();
     assertThat(splitGroups("")).containsExactly("");
     assertThat(splitGroups("abcd")).containsExactly("abcd");
     assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
@@ -75,7 +74,6 @@
 
   @Test
   public void testJoinGroups() {
-    assertThat(joinGroups(null)).isNull();
     assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
     assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
     assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
index bd8b8e0..57cedd5 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.reviewdb.client;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.parseRefSuffix;
+import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
 
 import org.junit.Test;
 
@@ -25,7 +27,7 @@
 
   @Test
   public void fullName() throws Exception {
-    assertThat(RefNames.fullName("refs/meta/config")).isEqualTo("refs/meta/config");
+    assertThat(RefNames.fullName(RefNames.REFS_CONFIG)).isEqualTo(RefNames.REFS_CONFIG);
     assertThat(RefNames.fullName("refs/heads/master")).isEqualTo("refs/heads/master");
     assertThat(RefNames.fullName("master")).isEqualTo("refs/heads/master");
     assertThat(RefNames.fullName("refs/tags/v1.0")).isEqualTo("refs/tags/v1.0");
@@ -39,8 +41,26 @@
 
   @Test
   public void refsDraftComments() throws Exception {
-    assertThat(RefNames.refsDraftComments(accountId, changeId))
-      .isEqualTo("refs/draft-comments/23/1011123-67473");
+    assertThat(RefNames.refsDraftComments(changeId, accountId))
+      .isEqualTo("refs/draft-comments/73/67473/1011123");
+  }
+
+  @Test
+  public void refsDraftCommentsPrefix() throws Exception {
+    assertThat(RefNames.refsDraftCommentsPrefix(changeId))
+      .isEqualTo("refs/draft-comments/73/67473/");
+  }
+
+  @Test
+  public void refsStarredChanges() throws Exception {
+    assertThat(RefNames.refsStarredChanges(changeId, accountId))
+      .isEqualTo("refs/starred-changes/73/67473/1011123");
+  }
+
+  @Test
+  public void refsStarredChangesPrefix() throws Exception {
+    assertThat(RefNames.refsStarredChangesPrefix(changeId))
+      .isEqualTo("refs/starred-changes/73/67473/");
   }
 
   @Test
@@ -48,4 +68,74 @@
     assertThat(RefNames.refsEdit(accountId, changeId, psId))
       .isEqualTo("refs/users/23/1011123/edit-67473/42");
   }
+
+  @Test
+  public void isRefsEdit() throws Exception {
+    assertThat(RefNames.isRefsEdit("refs/users/23/1011123/edit-67473/42"))
+        .isTrue();
+
+    // user ref, but no edit ref
+    assertThat(RefNames.isRefsEdit("refs/users/23/1011123")).isFalse();
+
+    // other ref
+    assertThat(RefNames.isRefsEdit("refs/heads/master")).isFalse();
+  }
+
+  @Test
+  public void isRefsUsers() throws Exception {
+    assertThat(RefNames.isRefsUsers("refs/users/23/1011123")).isTrue();
+    assertThat(RefNames.isRefsUsers("refs/users/default")).isTrue();
+    assertThat(RefNames.isRefsUsers("refs/users/23/1011123/edit-67473/42"))
+        .isTrue();
+
+    assertThat(RefNames.isRefsUsers("refs/heads/master")).isFalse();
+  }
+
+  @Test
+  public void testParseShardedRefsPart() throws Exception {
+    assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
+    assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
+    assertThat(parseShardedRefPart("01/1-drafts/2")).isEqualTo(1);
+
+    assertThat(parseShardedRefPart(null)).isNull();
+    assertThat(parseShardedRefPart("")).isNull();
+
+    // Prefix not stripped.
+    assertThat(parseShardedRefPart("refs/users/01/1")).isNull();
+
+    // Invalid characters.
+    assertThat(parseShardedRefPart("01a/1")).isNull();
+    assertThat(parseShardedRefPart("01/a1")).isNull();
+
+    // Mismatched shard.
+    assertThat(parseShardedRefPart("01/23")).isNull();
+
+    // Shard too short.
+    assertThat(parseShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
+  public void testParseRefSuffix() throws Exception {
+    assertThat(parseRefSuffix("1/2/34")).isEqualTo(34);
+    assertThat(parseRefSuffix("/34")).isEqualTo(34);
+
+    assertThat(parseRefSuffix(null)).isNull();
+    assertThat(parseRefSuffix("")).isNull();
+    assertThat(parseRefSuffix("34")).isNull();
+    assertThat(parseRefSuffix("12/ab")).isNull();
+    assertThat(parseRefSuffix("12/a4")).isNull();
+    assertThat(parseRefSuffix("12/4a")).isNull();
+    assertThat(parseRefSuffix("a4")).isNull();
+    assertThat(parseRefSuffix("4a")).isNull();
+  }
+
+  @Test
+  public void shard() throws Exception {
+    assertThat(RefNames.shard(1011123)).isEqualTo("23/1011123");
+    assertThat(RefNames.shard(537)).isEqualTo("37/537");
+    assertThat(RefNames.shard(12)).isEqualTo("12/12");
+    assertThat(RefNames.shard(0)).isEqualTo("00/0");
+    assertThat(RefNames.shard(1)).isEqualTo("01/1");
+    assertThat(RefNames.shard(-1)).isNull();
+  }
 }
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index cfe116a..4fc578c 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -34,9 +34,11 @@
     '//gerrit-util-ssl:ssl',
     '//lib:args4j',
     '//lib:automaton',
+    '//lib:blame-cache',
     '//lib:grappa',
     '//lib:gson',
     '//lib:guava',
+    '//lib:guava-retrying',
     '//lib:gwtjsonrpc',
     '//lib:gwtorm',
     '//lib:jsch',
@@ -44,6 +46,7 @@
     '//lib:mime-util',
     '//lib:pegdown',
     '//lib:protobuf',
+    '//lib:tukaani-xz',
     '//lib:velocity',
     '//lib/antlr:java_runtime',
     '//lib/auto:auto-value',
@@ -53,18 +56,19 @@
     '//lib/commons:lang',
     '//lib/commons:net',
     '//lib/commons:validator',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
-    '//lib/jgit:jgit-archive',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
     '//lib/joda:joda-time',
     '//lib/log:api',
     '//lib/log:jsonevent-layout',
     '//lib/log:log4j',
-    '//lib/lucene:analyzers-common',
-    '//lib/lucene:core-and-backward-codecs',
-    '//lib/lucene:queryparser',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+    '//lib/lucene:lucene-queryparser',
     '//lib/ow2:ow2-asm',
     '//lib/ow2:ow2-asm-tree',
     '//lib/ow2:ow2-asm-util',
@@ -95,8 +99,8 @@
   '//lib:truth',
   '//lib/guice:guice',
   '//lib/guice:guice-servlet',
-  '//lib/jgit:jgit',
-  '//lib/jgit:junit',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/jgit/org.eclipse.jgit.junit:junit',
   '//lib/joda:joda-time',
   '//lib/log:api',
   '//lib/log:impl_log4j',
@@ -138,6 +142,7 @@
   srcs = PROLOG_TEST_CASE,
   deps = [
     ':server',
+    ':testutil',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
     '//lib:guava',
@@ -190,11 +195,16 @@
     ':testutil',
     '//gerrit-antlr:query_exception',
     '//gerrit-common:annotations',
+    '//gerrit-patch-jgit:server',
     '//gerrit-server/src/main/prolog:common',
     '//lib:args4j',
     '//lib:grappa',
+    '//lib:gson',
     '//lib:guava',
+    '//lib:guava-retrying',
+    '//lib:protobuf',
     '//lib/commons:validator',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice-assistedinject',
     '//lib/prolog:runtime',
   ],
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
new file mode 100644
index 0000000..5a6b50f
--- /dev/null
+++ b/gerrit-server/BUILD
@@ -0,0 +1,208 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+CONSTANTS_SRC = [
+  'src/main/java/com/google/gerrit/server/documentation/Constants.java',
+]
+
+SRCS = glob(
+  ['src/main/java/**/*.java'],
+  exclude = CONSTANTS_SRC,
+)
+RESOURCES =  glob(['src/main/resources/**/*'])
+
+java_library(
+  name = 'constants',
+  srcs = CONSTANTS_SRC,
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  srcs = SRCS,
+  resources = RESOURCES,
+  deps = [
+    ':constants',
+    '//gerrit-antlr:query_exception',
+    '//gerrit-antlr:query_parser',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//gerrit-patch-commonsnet:commons-net',
+    '//gerrit-patch-jgit:server',
+    '//gerrit-prettify:server',
+    '//gerrit-reviewdb:server',
+    '//gerrit-util-cli:cli',
+    '//gerrit-util-ssl:ssl',
+    '//lib:args4j',
+    '//lib:automaton',
+    '//lib:blame-cache',
+    '//lib:grappa',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:guava-retrying',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm',
+    '//lib:jsch',
+    '//lib:juniversalchardet',
+    '//lib:mime-util',
+    '//lib:pegdown',
+    '//lib:protobuf',
+    '//lib:servlet-api-3_1',
+    '//lib:tukaani-xz',
+    '//lib:velocity',
+    '//lib/antlr:java_runtime',
+    '//lib/auto:auto-value',
+    '//lib/commons:codec',
+    '//lib/commons:compress',
+    '//lib/commons:dbcp',
+    '//lib/commons:lang',
+    '//lib/commons:net',
+    '//lib/commons:validator',
+    '//lib/dropwizard:dropwizard-core',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+    '//lib/log:jsonevent-layout',
+    '//lib/log:log4j',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core-and-backward-codecs',
+    '//lib/lucene:lucene-queryparser',
+    '//lib/ow2:ow2-asm',
+    '//lib/ow2:ow2-asm-tree',
+    '//lib/ow2:ow2-asm-util',
+    '//lib/prolog:runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+TESTUTIL_DEPS = [
+  ':server',
+  '//gerrit-common:server',
+  '//gerrit-cache-h2:cache-h2',
+  '//gerrit-extension-api:api',
+  '//gerrit-gpg:gpg',
+  '//gerrit-lucene:lucene',
+  '//gerrit-reviewdb:server',
+  '//lib:gwtorm',
+  '//lib:h2',
+  '//lib:truth',
+  '//lib/guice:guice',
+  '//lib/guice:guice-servlet',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/jgit/org.eclipse.jgit.junit:junit',
+  '//lib/joda:joda-time',
+  '//lib/log:api',
+  '//lib/log:impl_log4j',
+  '//lib/log:log4j',
+]
+
+TESTUTIL = glob([
+  'src/test/java/com/google/gerrit/testutil/**/*.java',
+  'src/test/java/com/google/gerrit/server/project/Util.java',
+])
+
+java_library(
+  name = 'testutil',
+  srcs = TESTUTIL,
+  deps = TESTUTIL_DEPS + [
+    '//lib/auto:auto-value',
+    '//lib/easymock:easymock',
+    '//lib/powermock:powermock-api-easymock',
+    '//lib/powermock:powermock-api-support',
+    '//lib/powermock:powermock-core',
+    '//lib/powermock:powermock-module-junit4',
+    '//lib/powermock:powermock-module-junit4-common',
+  ],
+  exports = [
+    '//lib/easymock:easymock',
+    '//lib/powermock:powermock-api-easymock',
+    '//lib/powermock:powermock-api-support',
+    '//lib/powermock:powermock-core',
+    '//lib/powermock:powermock-module-junit4',
+    '//lib/powermock:powermock-module-junit4-common',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+PROLOG_TEST_CASE = [
+  'src/test/java/com/google/gerrit/rules/PrologTestCase.java',
+]
+PROLOG_TESTS = glob(
+  ['src/test/java/com/google/gerrit/rules/**/*.java'],
+  exclude = PROLOG_TEST_CASE,
+)
+
+java_library(
+  name = 'prolog_test_case',
+  srcs = PROLOG_TEST_CASE,
+  deps = [
+    ':server',
+    ':testutil',
+    '//gerrit-common:server',
+    '//gerrit-extension-api:api',
+    '//lib:guava',
+    '//lib:junit',
+    '//lib:truth',
+    '//lib/guice:guice',
+    '//lib/prolog:runtime',
+  ],
+)
+
+junit_tests(
+  name = 'prolog_tests',
+  srcs = PROLOG_TESTS,
+  resources = glob(['src/test/resources/com/google/gerrit/rules/**/*']),
+  deps = TESTUTIL_DEPS + [
+    ':prolog_test_case',
+    ':testutil',
+    '//gerrit-server/src/main/prolog:common',
+    '//lib/prolog:runtime',
+  ],
+)
+
+QUERY_TESTS = glob(
+  ['src/test/java/com/google/gerrit/server/query/**/*.java'],
+)
+
+junit_tests(
+  name = 'query_tests',
+  srcs = QUERY_TESTS,
+  deps = TESTUTIL_DEPS + [
+    ':testutil',
+    '//gerrit-antlr:query_exception',
+    '//gerrit-antlr:query_parser',
+    '//gerrit-common:annotations',
+    '//gerrit-server/src/main/prolog:common',
+    '//lib/antlr:java_runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'server_tests',
+  srcs = glob(
+    ['src/test/java/**/*.java'],
+    exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS
+  ),
+  deps = TESTUTIL_DEPS + [
+    ':testutil',
+    '//gerrit-antlr:query_exception',
+    '//gerrit-common:annotations',
+    '//gerrit-patch-jgit:server',
+    '//gerrit-server/src/main/prolog:common',
+    '//lib:args4j',
+    '//lib:grappa',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:guava-retrying',
+    '//lib:protobuf',
+    '//lib/dropwizard:dropwizard-core',
+    '//lib/guice:guice-assistedinject',
+    '//lib/prolog:runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
index 78fe38c..90cddeeb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
@@ -38,6 +38,8 @@
    * @param httpRequest the HttpServletRequest
    * @param when time-stamp of when the event started
    * @param params parameters of the event
+   * @param input input
+   * @param status HTTP status
    * @param result result of the event
    * @param resource REST resource data
    * @param view view rendering object
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
index 4bf9723..805e050 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
@@ -29,6 +29,9 @@
    * @param what object of the event
    * @param when time-stamp of when the event started
    * @param params parameters of the event
+   * @param httpMethod HTTP method
+   * @param input input
+   * @param status HTTP status
    * @param result result of the event
    */
   public HttpAuditEvent(String sessionId, CurrentUser who, String what, long when,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
index c41ab3a..157b72d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
@@ -26,6 +26,9 @@
    * @param what object of the event
    * @param when time-stamp of when the event started
    * @param params parameters of the event
+   * @param httpMethod HTTP method
+   * @param input input
+   * @param status HTTP status
    * @param result result of the event
    */
   public RpcAuditEvent(String sessionId, CurrentUser who, String what,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
deleted file mode 100644
index 889f008..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
+++ /dev/null
@@ -1,1056 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.data.ApprovalAttribute;
-import com.google.gerrit.server.data.PatchSetAttribute;
-import com.google.gerrit.server.events.ChangeAbandonedEvent;
-import com.google.gerrit.server.events.ChangeMergedEvent;
-import com.google.gerrit.server.events.ChangeRestoredEvent;
-import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.DraftPublishedEvent;
-import com.google.gerrit.server.events.EventFactory;
-import com.google.gerrit.server.events.HashtagsChangedEvent;
-import com.google.gerrit.server.events.MergeFailedEvent;
-import com.google.gerrit.server.events.PatchSetCreatedEvent;
-import com.google.gerrit.server.events.ProjectCreatedEvent;
-import com.google.gerrit.server.events.RefUpdatedEvent;
-import com.google.gerrit.server.events.ReviewerAddedEvent;
-import com.google.gerrit.server.events.TopicChangedEvent;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.StringReader;
-import java.io.StringWriter;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.FutureTask;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-/** Spawns local executables when a hook action occurs. */
-@Singleton
-public class ChangeHookRunner implements ChangeHooks, EventDispatcher,
-  EventSource, LifecycleListener, NewProjectCreatedListener {
-    /** A logger for this class. */
-    private static final Logger log = LoggerFactory.getLogger(ChangeHookRunner.class);
-
-    public static class Module extends LifecycleModule {
-      @Override
-      protected void configure() {
-        bind(ChangeHookRunner.class);
-        bind(ChangeHooks.class).to(ChangeHookRunner.class);
-        bind(EventDispatcher.class).to(ChangeHookRunner.class);
-        bind(EventSource.class).to(ChangeHookRunner.class);
-        DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(ChangeHookRunner.class);
-        listener().to(ChangeHookRunner.class);
-      }
-    }
-
-    private static class EventListenerHolder {
-      final EventListener listener;
-      final CurrentUser user;
-
-      EventListenerHolder(EventListener l, CurrentUser u) {
-        listener = l;
-        user = u;
-      }
-    }
-
-    /** Container class used to hold the return code and output of script hook execution */
-    public static class HookResult {
-      private int exitValue = -1;
-      private String output;
-      private String executionError;
-
-      private HookResult(int exitValue, String output) {
-        this.exitValue = exitValue;
-        this.output = output;
-      }
-
-      private HookResult(String output, String executionError) {
-        this.output = output;
-        this.executionError = executionError;
-      }
-
-      public int getExitValue() {
-        return exitValue;
-      }
-
-      public void setExitValue(int exitValue) {
-        this.exitValue = exitValue;
-      }
-
-      public String getOutput() {
-        return output;
-      }
-
-      @Override
-      public String toString() {
-        StringBuilder sb = new StringBuilder();
-
-        if (output != null && output.length() != 0) {
-          sb.append(output);
-
-          if (executionError != null) {
-            sb.append(" - ");
-          }
-        }
-
-        if (executionError != null ) {
-          sb.append(executionError);
-        }
-
-        return sb.toString();
-      }
-    }
-
-    /** Listeners to receive changes as they happen (limited by visibility
-     *  of holder's user). */
-    private final Map<EventListener, EventListenerHolder> listeners =
-        new ConcurrentHashMap<>();
-
-    /** Listeners to receive all changes as they happen. */
-    private final DynamicSet<EventListener> unrestrictedListeners;
-
-    /** Path of the new patchset hook. */
-    private final Path patchsetCreatedHook;
-
-    /** Path of the draft published hook. */
-    private final Path draftPublishedHook;
-
-    /** Path of the new comments hook. */
-    private final Path commentAddedHook;
-
-    /** Path of the change merged hook. */
-    private final Path changeMergedHook;
-
-    /** Path of the merge failed hook. */
-    private final Path mergeFailedHook;
-
-    /** Path of the change abandoned hook. */
-    private final Path changeAbandonedHook;
-
-    /** Path of the change restored hook. */
-    private final Path changeRestoredHook;
-
-    /** Path of the ref updated hook. */
-    private final Path refUpdatedHook;
-
-    /** Path of the reviewer added hook. */
-    private final Path reviewerAddedHook;
-
-    /** Path of the topic changed hook. */
-    private final Path topicChangedHook;
-
-    /** Path of the cla signed hook. */
-    private final Path claSignedHook;
-
-    /** Path of the update hook. */
-    private final Path refUpdateHook;
-
-    /** Path of the hashtags changed hook */
-    private final Path hashtagsChangedHook;
-
-    /** Path of the project created hook. */
-    private final Path projectCreatedHook;
-
-    private final String anonymousCowardName;
-
-    /** Repository Manager. */
-    private final GitRepositoryManager repoManager;
-
-    /** Queue of hooks that need to run. */
-    private final WorkQueue.Executor hookQueue;
-
-    private final ProjectCache projectCache;
-
-    private final AccountCache accountCache;
-
-    private final EventFactory eventFactory;
-
-    private final SitePaths sitePaths;
-
-    /** Thread pool used to monitor sync hooks */
-    private final ExecutorService syncHookThreadPool;
-
-    /** Timeout value for synchronous hooks */
-    private final int syncHookTimeout;
-
-    /**
-     * Create a new ChangeHookRunner.
-     *
-     * @param queue Queue to use when processing hooks.
-     * @param repoManager The repository manager.
-     * @param config Config file to use.
-     * @param sitePath The sitepath of this gerrit install.
-     * @param projectCache the project cache instance for the server.
-     */
-    @Inject
-    public ChangeHookRunner(WorkQueue queue,
-      GitRepositoryManager repoManager,
-      @GerritServerConfig Config config,
-      @AnonymousCowardName String anonymousCowardName,
-      SitePaths sitePath,
-      ProjectCache projectCache,
-      AccountCache accountCache,
-      EventFactory eventFactory,
-      DynamicSet<EventListener> unrestrictedListeners) {
-        this.anonymousCowardName = anonymousCowardName;
-        this.repoManager = repoManager;
-        this.hookQueue = queue.createQueue(1, "hook");
-        this.projectCache = projectCache;
-        this.accountCache = accountCache;
-        this.eventFactory = eventFactory;
-        this.sitePaths = sitePath;
-        this.unrestrictedListeners = unrestrictedListeners;
-
-        Path hooksPath;
-        String hooksPathConfig = config.getString("hooks", null, "path");
-        if (hooksPathConfig != null) {
-          hooksPath = Paths.get(hooksPathConfig);
-        } else {
-          hooksPath = sitePath.hooks_dir;
-        }
-
-        // When adding a new hook, make sure to check that the setting name
-        // canonicalizes correctly in hook() below.
-        patchsetCreatedHook = hook(config, hooksPath, "patchset-created");
-        draftPublishedHook = hook(config, hooksPath, "draft-published");
-        commentAddedHook = hook(config, hooksPath, "comment-added");
-        changeMergedHook = hook(config, hooksPath, "change-merged");
-        mergeFailedHook = hook(config, hooksPath, "merge-failed");
-        changeAbandonedHook = hook(config, hooksPath, "change-abandoned");
-        changeRestoredHook = hook(config, hooksPath, "change-restored");
-        refUpdatedHook = hook(config, hooksPath, "ref-updated");
-        reviewerAddedHook = hook(config, hooksPath, "reviewer-added");
-        topicChangedHook = hook(config, hooksPath, "topic-changed");
-        claSignedHook = hook(config, hooksPath, "cla-signed");
-        refUpdateHook = hook(config, hooksPath, "ref-update");
-        hashtagsChangedHook = hook(config, hooksPath, "hashtags-changed");
-        projectCreatedHook = hook(config, hooksPath, "project-created");
-
-        syncHookTimeout = config.getInt("hooks", "syncHookTimeout", 30);
-        syncHookThreadPool = Executors.newCachedThreadPool(
-            new ThreadFactoryBuilder()
-              .setNameFormat("SyncHook-%d")
-              .build());
-    }
-
-    private static Path hook(Config config, Path path, String name) {
-      String setting = name.replace("-", "") + "hook";
-      String value = config.getString("hooks", null, setting);
-      return path.resolve(value != null ? value : name);
-    }
-
-    @Override
-    public void addEventListener(EventListener listener, CurrentUser user) {
-      listeners.put(listener, new EventListenerHolder(listener, user));
-    }
-
-    @Override
-    public void removeEventListener(EventListener listener) {
-      listeners.remove(listener);
-    }
-
-    /**
-     * Get the Repository for the given project name, or null on error.
-     *
-     * @param name Project to get repo for,
-     * @return Repository or null.
-     */
-    private Repository openRepository(Project.NameKey name) {
-      try {
-        return repoManager.openRepository(name);
-      } catch (IOException err) {
-        log.warn("Cannot open repository " + name.get(), err);
-        return null;
-      }
-    }
-
-    private void addArg(List<String> args, String name, String value) {
-      if (value != null) {
-        args.add(name);
-        args.add(value);
-      }
-    }
-
-    private PatchSetAttribute asPatchSetAttribute(Change change,
-        PatchSet patchSet, ReviewDb db) throws OrmException {
-      try (Repository repo = repoManager.openRepository(change.getProject());
-          RevWalk revWalk = new RevWalk(repo)) {
-        return eventFactory.asPatchSetAttribute(db, revWalk, patchSet);
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    /**
-     * Fire the update hook
-     *
-     */
-    @Override
-    public HookResult doRefUpdateHook(Project project, String refname,
-        Account uploader, ObjectId oldId, ObjectId newId) {
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--project", project.getName());
-      addArg(args, "--refname", refname);
-      addArg(args, "--uploader", getDisplayName(uploader));
-      addArg(args, "--oldrev", oldId.getName());
-      addArg(args, "--newrev", newId.getName());
-
-      return runSyncHook(project.getNameKey(), refUpdateHook, args);
-    }
-
-    @Override
-    public void doProjectCreatedHook(Project.NameKey project, String headName) {
-      ProjectCreatedEvent event = new ProjectCreatedEvent();
-      event.projectName = project.get();
-      event.headName = headName;
-      fireEvent(project, event);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--project", project.get());
-      addArg(args, "--head", headName);
-
-      runHook(project, projectCreatedHook, args);
-    }
-
-    /**
-     * Fire the Patchset Created Hook.
-     *
-     * @param change The change itself.
-     * @param patchSet The Patchset that was created.
-     * @throws OrmException
-     */
-    @Override
-    public void doPatchsetCreatedHook(Change change, PatchSet patchSet,
-          ReviewDb db) throws OrmException {
-      PatchSetCreatedEvent event = new PatchSetCreatedEvent();
-      AccountState uploader = accountCache.get(patchSet.getUploader());
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.uploader = eventFactory.asAccountAttribute(uploader.getAccount());
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--is-draft", String.valueOf(patchSet.isDraft()));
-      addArg(args, "--kind", String.valueOf(event.patchSet.kind));
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--uploader", getDisplayName(uploader.getAccount()));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--patchset", event.patchSet.number);
-
-      runHook(change.getProject(), patchsetCreatedHook, args);
-    }
-
-    @Override
-    public void doDraftPublishedHook(Change change, PatchSet patchSet,
-          ReviewDb db) throws OrmException {
-      DraftPublishedEvent event = new DraftPublishedEvent();
-      AccountState uploader = accountCache.get(patchSet.getUploader());
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.uploader = eventFactory.asAccountAttribute(uploader.getAccount());
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--uploader", getDisplayName(uploader.getAccount()));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--patchset", event.patchSet.number);
-
-      runHook(change.getProject(), draftPublishedHook, args);
-    }
-
-    @Override
-    public void doCommentAddedHook(Change change, Account account,
-          PatchSet patchSet, String comment, Map<String, Short> approvals,
-          ReviewDb db) throws OrmException {
-      CommentAddedEvent event = new CommentAddedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.author =  eventFactory.asAccountAttribute(account);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.comment = comment;
-
-      LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
-      if (approvals.size() > 0) {
-        event.approvals = new ApprovalAttribute[approvals.size()];
-        int i = 0;
-        for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-          event.approvals[i++] = getApprovalAttribute(labelTypes, approval);
-        }
-      }
-
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--is-draft", patchSet.isDraft() ? "true" : "false");
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--author", getDisplayName(account));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--comment", comment == null ? "" : comment);
-      for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-        LabelType lt = labelTypes.byLabel(approval.getKey());
-        if (lt != null) {
-          addArg(args, "--" + lt.getName(), Short.toString(approval.getValue()));
-        }
-      }
-
-      runHook(change.getProject(), commentAddedHook, args);
-    }
-
-    @Override
-    public void doChangeMergedHook(Change change, Account account,
-        PatchSet patchSet, ReviewDb db, String mergeResultRev)
-        throws OrmException {
-      ChangeMergedEvent event = new ChangeMergedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.submitter = eventFactory.asAccountAttribute(account);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.newRev = mergeResultRev;
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--submitter", getDisplayName(account));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--newrev", mergeResultRev);
-
-      runHook(change.getProject(), changeMergedHook, args);
-    }
-
-    @Override
-    public void doMergeFailedHook(Change change, Account account,
-          PatchSet patchSet, String reason,
-          ReviewDb db) throws OrmException {
-      MergeFailedEvent event = new MergeFailedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.submitter = eventFactory.asAccountAttribute(account);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.reason = reason;
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--submitter", getDisplayName(account));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--reason",  reason == null ? "" : reason);
-
-      runHook(change.getProject(), mergeFailedHook, args);
-    }
-
-    @Override
-    public void doChangeAbandonedHook(Change change, Account account,
-          PatchSet patchSet, String reason, ReviewDb db)
-          throws OrmException {
-      ChangeAbandonedEvent event = new ChangeAbandonedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.abandoner = eventFactory.asAccountAttribute(account);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.reason = reason;
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--abandoner", getDisplayName(account));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--reason", reason == null ? "" : reason);
-
-      runHook(change.getProject(), changeAbandonedHook, args);
-    }
-
-    @Override
-    public void doChangeRestoredHook(Change change, Account account,
-          PatchSet patchSet, String reason, ReviewDb db)
-          throws OrmException {
-      ChangeRestoredEvent event = new ChangeRestoredEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.restorer = eventFactory.asAccountAttribute(account);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.reason = reason;
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--topic", event.change.topic);
-      addArg(args, "--restorer", getDisplayName(account));
-      addArg(args, "--commit", event.patchSet.revision);
-      addArg(args, "--reason", reason == null ? "" : reason);
-
-      runHook(change.getProject(), changeRestoredHook, args);
-    }
-
-    @Override
-    public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-        Account account) {
-      doRefUpdatedHook(refName, refUpdate.getOldObjectId(),
-          refUpdate.getNewObjectId(), account);
-    }
-
-    @Override
-    public void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
-        ObjectId newId, Account account) {
-      RefUpdatedEvent event = new RefUpdatedEvent();
-
-      if (account != null) {
-        event.submitter = eventFactory.asAccountAttribute(account);
-      }
-      event.refUpdate = eventFactory.asRefUpdateAttribute(oldId, newId, refName);
-      fireEvent(refName, event);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--oldrev", event.refUpdate.oldRev);
-      addArg(args, "--newrev", event.refUpdate.newRev);
-      addArg(args, "--refname", event.refUpdate.refName);
-      addArg(args, "--project", event.refUpdate.project);
-      if (account != null) {
-        addArg(args, "--submitter", getDisplayName(account));
-      }
-
-      runHook(refName.getParentKey(), refUpdatedHook, args);
-    }
-
-    @Override
-    public void doReviewerAddedHook(Change change, Account account,
-        PatchSet patchSet, ReviewDb db) throws OrmException {
-      ReviewerAddedEvent event = new ReviewerAddedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.patchSet = asPatchSetAttribute(change, patchSet, db);
-      event.reviewer = eventFactory.asAccountAttribute(account);
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-url", event.change.url);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--reviewer", getDisplayName(account));
-
-      runHook(change.getProject(), reviewerAddedHook, args);
-    }
-
-    @Override
-    public void doTopicChangedHook(Change change, Account account,
-        String oldTopic, ReviewDb db)
-            throws OrmException {
-      TopicChangedEvent event = new TopicChangedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.changer = eventFactory.asAccountAttribute(account);
-      event.oldTopic = oldTopic;
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--changer", getDisplayName(account));
-      addArg(args, "--old-topic", oldTopic);
-      addArg(args, "--new-topic", event.change.topic);
-
-      runHook(change.getProject(), topicChangedHook, args);
-    }
-
-    String[] hashtagArray(Set<String> hashtags) {
-      if (hashtags != null && hashtags.size() > 0) {
-        return Sets.newHashSet(hashtags).toArray(
-            new String[hashtags.size()]);
-      }
-      return null;
-    }
-
-    @Override
-    public void doHashtagsChangedHook(Change change, Account account,
-        Set<String> added, Set<String> removed, Set<String> hashtags, ReviewDb db)
-            throws OrmException {
-      HashtagsChangedEvent event = new HashtagsChangedEvent();
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = eventFactory.asChangeAttribute(db, change);
-      event.editor = eventFactory.asAccountAttribute(account);
-      event.hashtags = hashtagArray(hashtags);
-      event.added = hashtagArray(added);
-      event.removed = hashtagArray(removed);
-
-      fireEvent(change, event, db);
-
-      List<String> args = new ArrayList<>();
-      addArg(args, "--change", event.change.id);
-      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
-      addArg(args, "--project", event.change.project);
-      addArg(args, "--branch", event.change.branch);
-      addArg(args, "--editor", getDisplayName(account));
-      if (hashtags != null) {
-        for (String hashtag : hashtags) {
-          addArg(args, "--hashtag", hashtag);
-        }
-      }
-      if (added != null) {
-        for (String hashtag : added) {
-          addArg(args, "--added", hashtag);
-        }
-      }
-      if (removed != null) {
-        for (String hashtag : removed) {
-          addArg(args, "--removed", hashtag);
-        }
-      }
-      runHook(change.getProject(), hashtagsChangedHook, args);
-    }
-
-    @Override
-    public void doClaSignupHook(Account account, ContributorAgreement cla) {
-      if (account != null) {
-        List<String> args = new ArrayList<>();
-        addArg(args, "--submitter", getDisplayName(account));
-        addArg(args, "--user-id", account.getId().toString());
-        addArg(args, "--cla-name", cla.getName());
-
-        runHook(claSignedHook, args);
-      }
-    }
-
-    @Override
-    public void postEvent(Change change, com.google.gerrit.server.events.Event event,
-        ReviewDb db) throws OrmException {
-      fireEvent(change, event, db);
-    }
-
-    @Override
-    public void postEvent(Branch.NameKey branchName, com.google.gerrit.server.events.Event event) {
-      fireEvent(branchName, event);
-    }
-
-    private void fireEventForUnrestrictedListeners(com.google.gerrit.server.events.Event event) {
-      for (EventListener listener : unrestrictedListeners) {
-        listener.onEvent(event);
-      }
-    }
-
-    private void fireEvent(Change change, com.google.gerrit.server.events.Event event,
-        ReviewDb db) throws OrmException {
-      for (EventListenerHolder holder : listeners.values()) {
-        if (isVisibleTo(change, holder.user, db)) {
-          holder.listener.onEvent(event);
-        }
-      }
-
-      fireEventForUnrestrictedListeners( event );
-    }
-
-    private void fireEvent(Project.NameKey project, ProjectCreatedEvent event) {
-      for (EventListenerHolder holder : listeners.values()) {
-        if (isVisibleTo(project, event, holder.user)) {
-          holder.listener.onEvent(event);
-        }
-      }
-
-      fireEventForUnrestrictedListeners(event);
-    }
-
-    private void fireEventForUnrestrictedListeners(ProjectCreatedEvent event) {
-      for (EventListener listener : unrestrictedListeners) {
-        listener.onEvent(event);
-      }
-    }
-
-    private boolean isVisibleTo(Project.NameKey project, ProjectCreatedEvent event, CurrentUser user) {
-      ProjectState pe = projectCache.get(project);
-      if (pe == null) {
-        return false;
-      }
-      ProjectControl pc = pe.controlFor(user);
-      return pc.controlForRef(event.getHeadName()).isVisible();
-    }
-
-    private void fireEvent(Branch.NameKey branchName, com.google.gerrit.server.events.Event event) {
-      for (EventListenerHolder holder : listeners.values()) {
-        if (isVisibleTo(branchName, holder.user)) {
-          holder.listener.onEvent(event);
-        }
-      }
-
-      fireEventForUnrestrictedListeners(event);
-    }
-
-    private boolean isVisibleTo(Change change, CurrentUser user, ReviewDb db)
-        throws OrmException {
-      ProjectState pe = projectCache.get(change.getProject());
-      if (pe == null) {
-        return false;
-      }
-      ProjectControl pc = pe.controlFor(user);
-      return pc.controlFor(change).isVisible(db);
-    }
-
-    private boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user) {
-      ProjectState pe = projectCache.get(branchName.getParentKey());
-      if (pe == null) {
-        return false;
-      }
-      ProjectControl pc = pe.controlFor(user);
-      return pc.controlForRef(branchName).isVisible();
-    }
-
-    /**
-     * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
-     * @param approval
-     * @return object suitable for serialization to JSON
-     */
-    private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
-            Entry<String, Short> approval) {
-      ApprovalAttribute a = new ApprovalAttribute();
-      a.type = approval.getKey();
-      LabelType lt = labelTypes.byLabel(approval.getKey());
-      if (lt != null) {
-        a.description = lt.getName();
-      }
-      a.value = Short.toString(approval.getValue());
-      return a;
-    }
-
-    /**
-     * Get the display name for the given account.
-     *
-     * @param account Account to get name for.
-     * @return Name for this account.
-     */
-    private String getDisplayName(Account account) {
-      if (account != null) {
-        String result = (account.getFullName() == null)
-            ? anonymousCowardName
-            : account.getFullName();
-        if (account.getPreferredEmail() != null) {
-          result += " (" + account.getPreferredEmail() + ")";
-        }
-        return result;
-      }
-
-      return anonymousCowardName;
-    }
-
-  /**
-   * Run a hook.
-   *
-   * @param project used to open repository to run the hook for.
-   * @param hook the hook to execute.
-   * @param args Arguments to use to run the hook.
-   */
-  private synchronized void runHook(Project.NameKey project, Path hook,
-      List<String> args) {
-    if (project != null && Files.exists(hook)) {
-      hookQueue.execute(new AsyncHookTask(project, hook, args));
-    }
-  }
-
-  private synchronized void runHook(Path hook, List<String> args) {
-    if (Files.exists(hook)) {
-      hookQueue.execute(new AsyncHookTask(null, hook, args));
-    }
-  }
-
-  private HookResult runSyncHook(Project.NameKey project,
-      Path hook, List<String> args) {
-
-    if (!Files.exists(hook)) {
-      return null;
-    }
-
-    SyncHookTask syncHook = new SyncHookTask(project, hook, args);
-    FutureTask<HookResult> task = new FutureTask<>(syncHook);
-
-    syncHookThreadPool.execute(task);
-
-    String message;
-
-    try {
-      return task.get(syncHookTimeout, TimeUnit.SECONDS);
-    } catch (TimeoutException e) {
-      message = "Synchronous hook timed out "  + hook.toAbsolutePath();
-      log.error(message);
-    } catch (Exception e) {
-      message = "Error running hook " + hook.toAbsolutePath();
-      log.error(message, e);
-    }
-
-    task.cancel(true);
-    syncHook.cancel();
-    return  new HookResult(syncHook.getOutput(), message);
-  }
-
-  @Override
-  public void start() {
-  }
-
-  @Override
-  public void stop() {
-    syncHookThreadPool.shutdown();
-    boolean isTerminated;
-    do {
-      try {
-        isTerminated = syncHookThreadPool.awaitTermination(10, TimeUnit.SECONDS);
-      } catch (InterruptedException ie) {
-        isTerminated = false;
-      }
-    } while (!isTerminated);
-  }
-
-  private class HookTask {
-    private final Project.NameKey project;
-    private final Path hook;
-    private final List<String> args;
-    private StringWriter output;
-    private Process ps;
-
-    protected HookTask(Project.NameKey project, Path hook, List<String> args) {
-      this.project = project;
-      this.hook = hook;
-      this.args = args;
-    }
-
-    public String getOutput() {
-      return output != null ? output.toString() : null;
-    }
-
-    protected HookResult runHook() {
-      Repository repo = null;
-      HookResult result = null;
-      try {
-
-        List<String> argv = new ArrayList<>(1 + args.size());
-        argv.add(hook.toAbsolutePath().toString());
-        argv.addAll(args);
-
-        ProcessBuilder pb = new ProcessBuilder(argv);
-        pb.redirectErrorStream(true);
-
-        if (project != null) {
-          repo = openRepository(project);
-        }
-
-        Map<String, String> env = pb.environment();
-        env.put("GERRIT_SITE", sitePaths.site_path.toAbsolutePath().toString());
-
-        if (repo != null) {
-          pb.directory(repo.getDirectory());
-
-          env.put("GIT_DIR", repo.getDirectory().getAbsolutePath());
-        }
-
-        ps = pb.start();
-        ps.getOutputStream().close();
-        String output = null;
-        try (InputStream is = ps.getInputStream()) {
-          output = readOutput(is);
-        } finally {
-          ps.waitFor();
-          result = new HookResult(ps.exitValue(), output);
-        }
-      } catch (InterruptedException iex) {
-        // InterruptedExeception - timeout or cancel
-      } catch (Throwable err) {
-        log.error("Error running hook " + hook.toAbsolutePath(), err);
-      } finally {
-        if (repo != null) {
-          repo.close();
-        }
-      }
-
-      if (result != null) {
-        int exitValue = result.getExitValue();
-        if (exitValue == 0) {
-          log.debug("hook[" + getName() + "] exitValue:" + exitValue);
-        } else {
-          log.info("hook[" + getName() + "] exitValue:" + exitValue);
-        }
-
-        BufferedReader br =
-            new BufferedReader(new StringReader(result.getOutput()));
-        try {
-          String line;
-          while ((line = br.readLine()) != null) {
-            log.info("hook[" + getName() + "] output: " + line);
-          }
-        } catch (IOException iox) {
-          log.error("Error writing hook output", iox);
-        }
-      }
-
-      return result;
-    }
-
-    private String readOutput(InputStream is) throws IOException {
-      output = new StringWriter();
-      InputStreamReader input = new InputStreamReader(is);
-      char[] buffer = new char[4096];
-      int n;
-      while ((n = input.read(buffer)) != -1) {
-        output.write(buffer, 0, n);
-      }
-
-      return output.toString();
-    }
-
-    protected String getName() {
-      return hook.getFileName().toString();
-    }
-
-    @Override
-    public String toString() {
-      return "hook " + hook.getFileName();
-    }
-
-    public void cancel() {
-      ps.destroy();
-    }
-  }
-
-  /** Callable type used to run synchronous hooks */
-  private final class SyncHookTask extends HookTask
-      implements Callable<HookResult> {
-
-    private SyncHookTask(Project.NameKey project, Path hook, List<String> args) {
-      super(project, hook, args);
-    }
-
-    @Override
-    public HookResult call() throws Exception {
-      return super.runHook();
-    }
-  }
-
-  /** Runnable type used to run asynchronous hooks */
-  private final class AsyncHookTask extends HookTask implements Runnable {
-
-    private AsyncHookTask(Project.NameKey project, Path hook, List<String> args) {
-      super(project, hook, args);
-    }
-
-    @Override
-    public void run() {
-      super.runHook();
-    }
-  }
-
-  @Override
-  public void onNewProjectCreated(NewProjectCreatedListener.Event event) {
-    Project.NameKey project = new Project.NameKey(event.getProjectName());
-    doProjectCreatedHook(project, event.getHeadName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
deleted file mode 100644
index b16a8a5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
+++ /dev/null
@@ -1,192 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-
-import java.util.Map;
-import java.util.Set;
-
-/** Invokes hooks on server actions. */
-public interface ChangeHooks {
-  /**
-   * Fire the Patchset Created Hook.
-   *
-   * @param change The change itself.
-   * @param patchSet The Patchset that was created.
-   * @throws OrmException
-   */
-  public void doPatchsetCreatedHook(Change change, PatchSet patchSet,
-      ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Draft Published Hook.
-   *
-   * @param change The change itself.
-   * @param patchSet The Patchset that was published.
-   * @throws OrmException
-   */
-  public void doDraftPublishedHook(Change change, PatchSet patchSet,
-      ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Comment Added Hook.
-   *
-   * @param change The change itself.
-   * @param patchSet The patchset this comment is related to.
-   * @param account The gerrit user who added the comment.
-   * @param comment The comment given.
-   * @param approvals Map of label IDs to scores
-   * @throws OrmException
-   */
-  public void doCommentAddedHook(Change change, Account account,
-      PatchSet patchSet, String comment,
-      Map<String, Short> approvals, ReviewDb db)
-      throws OrmException;
-
-  /**
-   * Fire the Change Merged Hook.
-   *
-   * @param change The change itself.
-   * @param account The gerrit user who submitted the change.
-   * @param patchSet The patchset that was merged.
-   * @param mergeResultRev The SHA-1 of the merge result revision.
-   * @throws OrmException
-   */
-  public void doChangeMergedHook(Change change, Account account,
-      PatchSet patchSet, ReviewDb db, String mergeResultRev) throws OrmException;
-
-  /**
-   * Fire the Merge Failed Hook.
-   *
-   * @param change The change itself.
-   * @param account The gerrit user who attempted to submit the change.
-   * @param patchSet The patchset that failed to merge.
-   * @param reason The reason that the change failed to merge.
-   * @throws OrmException
-   */
-  public void doMergeFailedHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Change Abandoned Hook.
-   *
-   * @param change The change itself.
-   * @param account The gerrit user who abandoned the change.
-   * @param reason Reason for abandoning the change.
-   * @throws OrmException
-   */
-  public void doChangeAbandonedHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Change Restored Hook.
-   *
-   * @param change The change itself.
-   * @param account The gerrit user who restored the change.
-   * @param reason Reason for restoring the change.
-   * @throws OrmException
-   */
-  public void doChangeRestoredHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Ref Updated Hook
-   *
-   * @param refName The updated project and branch.
-   * @param refUpdate An actual RefUpdate object
-   * @param account The gerrit user who moved the ref
-   */
-  public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-      Account account);
-
-  /**
-   * Fire the Ref Updated Hook
-   *
-   * @param refName The Branch.NameKey of the ref that was updated
-   * @param oldId The ref's old id
-   * @param newId The ref's new id
-   * @param account The gerrit user who moved the ref
-   */
-  public void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
-      ObjectId newId, Account account);
-
-  /**
-   * Fire the Reviewer Added Hook
-   *
-   * @param change The change itself.
-   * @param patchSet The patchset that the reviewer was added on.
-   * @param account The gerrit user who was added as reviewer.
-   */
-  public void doReviewerAddedHook(Change change, Account account,
-      PatchSet patchSet, ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the Topic Changed Hook
-   *
-   * @param change The change itself.
-   * @param account The gerrit user who changed the topic.
-   * @param oldTopic The old topic name.
-   */
-  public void doTopicChangedHook(Change change, Account account,
-      String oldTopic, ReviewDb db) throws OrmException;
-
-  public void doClaSignupHook(Account account, ContributorAgreement cla);
-
-  /**
-   * Fire the Ref update Hook
-   *
-   * @param project The target project
-   * @param refName The Branch.NameKey of the ref provided by client
-   * @param uploader The gerrit user running the command
-   * @param oldId The ref's old id
-   * @param newId The ref's new id
-   */
-  public HookResult doRefUpdateHook(Project project,  String refName,
-       Account uploader, ObjectId oldId, ObjectId newId);
-
-  /**
-   * Fire the hashtags changed Hook.
-   * @param change The change
-   * @param account The gerrit user changing the hashtags
-   * @param added List of hashtags that were added to the change
-   * @param removed List of hashtags that were removed from the change
-   * @param hashtags List of hashtags on the change after adding or removing
-   * @param db The database
-   * @throws OrmException
-   */
-  public void doHashtagsChangedHook(Change change, Account account,
-      Set<String>added, Set<String> removed, Set<String> hashtags,
-      ReviewDb db) throws OrmException;
-
-  /**
-   * Fire the project created hook
-   *
-   * @param project The project that was created
-   * @param headName The head name of the created project
-   */
-  public void doProjectCreatedHook(Project.NameKey project, String headName);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
deleted file mode 100644
index bed77a7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.events.Event;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-
-import java.util.Map;
-import java.util.Set;
-
-/** Does not invoke hooks. */
-public final class DisabledChangeHooks implements ChangeHooks, EventDispatcher,
-    EventSource {
-  @Override
-  public void addEventListener(EventListener listener, CurrentUser user) {
-  }
-
-  @Override
-  public void doChangeAbandonedHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) {
-  }
-
-  @Override
-  public void doChangeMergedHook(Change change, Account account,
-      PatchSet patchSet, ReviewDb db, String mergeResultRev) {
-  }
-
-  @Override
-  public void doMergeFailedHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) {
-  }
-
-  @Override
-  public void doChangeRestoredHook(Change change, Account account,
-      PatchSet patchSet, String reason, ReviewDb db) {
-  }
-
-  @Override
-  public void doClaSignupHook(Account account, ContributorAgreement cla) {
-  }
-
-  @Override
-  public void doCommentAddedHook(Change change, Account account,
-      PatchSet patchSet, String comment,
-      Map<String, Short> approvals, ReviewDb db) {
-  }
-
-  @Override
-  public void doPatchsetCreatedHook(Change change, PatchSet patchSet,
-      ReviewDb db) {
-  }
-
-  @Override
-  public void doDraftPublishedHook(Change change, PatchSet patchSet,
-      ReviewDb db) {
-  }
-
-  @Override
-  public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-      Account account) {
-  }
-
-  @Override
-  public void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
-      ObjectId newId, Account account) {
-  }
-
-  @Override
-  public void doReviewerAddedHook(Change change, Account account, PatchSet patchSet,
-      ReviewDb db) {
-  }
-
-  @Override
-  public void doTopicChangedHook(Change change, Account account, String oldTopic,
-      ReviewDb db) {
-  }
-
-  @Override
-  public void doHashtagsChangedHook(Change change, Account account, Set<String> added,
-      Set<String> removed, Set<String> hashtags, ReviewDb db) {
-  }
-
-  @Override
-  public void removeEventListener(EventListener listener) {
-  }
-
-  @Override
-  public HookResult doRefUpdateHook(Project project, String refName,
-      Account uploader, ObjectId oldId, ObjectId newId) {
-    return null;
-  }
-
-  @Override
-  public void doProjectCreatedHook(Project.NameKey project, String headName) {
-  }
-
-  @Override
-  public void postEvent(Change change, Event event, ReviewDb db) {
-  }
-
-  @Override
-  public void postEvent(Branch.NameKey branchName, Event event) {
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
new file mode 100644
index 0000000..0029768
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.events.ChangeEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+/** Distributes Events to listeners if they are allowed to see them */
+@Singleton
+public class EventBroker implements EventDispatcher {
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicItem.itemOf(binder(), EventDispatcher.class);
+      DynamicItem.bind(binder(), EventDispatcher.class).to(EventBroker.class);
+    }
+  }
+
+  /**
+   * Listeners to receive changes as they happen (limited by visibility of
+   * user).
+   */
+  protected final DynamicSet<UserScopedEventListener> listeners;
+
+  /** Listeners to receive all changes as they happen. */
+  protected final DynamicSet<EventListener> unrestrictedListeners;
+
+  protected final ProjectCache projectCache;
+
+  protected final ChangeNotes.Factory notesFactory;
+
+  protected final Provider<ReviewDb> dbProvider;
+
+  @Inject
+  public EventBroker(DynamicSet<UserScopedEventListener> listeners,
+      DynamicSet<EventListener> unrestrictedListeners,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider) {
+    this.listeners = listeners;
+    this.unrestrictedListeners = unrestrictedListeners;
+    this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+  }
+
+  @Override
+  public void postEvent(Change change, ChangeEvent event)
+      throws OrmException {
+    fireEvent(change, event);
+  }
+
+  @Override
+  public void postEvent(Branch.NameKey branchName, RefEvent event) {
+    fireEvent(branchName, event);
+  }
+
+  @Override
+  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
+    fireEvent(projectName, event);
+  }
+
+  @Override
+  public void postEvent(Event event) throws OrmException {
+    fireEvent(event);
+  }
+
+  protected void fireEventForUnrestrictedListeners(Event event) {
+    for (EventListener listener : unrestrictedListeners) {
+      listener.onEvent(event);
+    }
+  }
+
+  protected void fireEvent(Change change, ChangeEvent event)
+      throws OrmException {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(change, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Project.NameKey project, ProjectEvent event) {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(project, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Branch.NameKey branchName, RefEvent event) {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(branchName, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected void fireEvent(Event event) throws OrmException {
+    for (UserScopedEventListener listener : listeners) {
+      if (isVisibleTo(event, listener.getUser())) {
+        listener.onEvent(event);
+      }
+    }
+    fireEventForUnrestrictedListeners(event);
+  }
+
+  protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
+    ProjectState pe = projectCache.get(project);
+    if (pe == null) {
+      return false;
+    }
+    return pe.controlFor(user).isVisible();
+  }
+
+  protected boolean isVisibleTo(Change change, CurrentUser user)
+      throws OrmException {
+    if (change == null) {
+      return false;
+    }
+    ProjectState pe = projectCache.get(change.getProject());
+    if (pe == null) {
+      return false;
+    }
+    ProjectControl pc = pe.controlFor(user);
+    ReviewDb db = dbProvider.get();
+    return pc.controlFor(db, change).isVisible(db);
+  }
+
+  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user) {
+    ProjectState pe = projectCache.get(branchName.getParentKey());
+    if (pe == null) {
+      return false;
+    }
+    ProjectControl pc = pe.controlFor(user);
+    return pc.controlForRef(branchName).isVisible();
+  }
+
+  protected boolean isVisibleTo(Event event, CurrentUser user)
+      throws OrmException {
+    if (event instanceof RefEvent) {
+      RefEvent refEvent = (RefEvent) event;
+      String ref = refEvent.getRefName();
+      if (PatchSet.isChangeRef(ref)) {
+        Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
+        Change change = notesFactory.create(
+            dbProvider.get(), refEvent.getProjectNameKey(), cid).getChange();
+        return isVisibleTo(change, user);
+      }
+      return isVisibleTo(refEvent.getBranchNameKey(), user);
+    } else if (event instanceof ProjectEvent) {
+      return isVisibleTo(((ProjectEvent) event).getProjectNameKey(), user);
+    }
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
index b74771f8..09fa581 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
@@ -16,8 +16,11 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.events.ChangeEvent;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.gwtorm.server.OrmException;
 
 
@@ -28,11 +31,9 @@
    *
    * @param change The change that the event is related to
    * @param event The event to post
-   * @param db The database
-   * @throws OrmException
+   * @throws OrmException on failure to post the event due to DB error
    */
-  public void postEvent(Change change, Event event, ReviewDb db)
-      throws OrmException;
+  void postEvent(Change change, ChangeEvent event) throws OrmException;
 
   /**
    * Post a stream event that is related to a branch
@@ -40,5 +41,25 @@
    * @param branchName The branch that the event is related to
    * @param event The event to post
    */
-  public void postEvent(Branch.NameKey branchName, Event event);
+  void postEvent(Branch.NameKey branchName, RefEvent event);
+
+  /**
+   * Post a stream event that is related to a project.
+   *
+   * @param projectName The project that the event is related to.
+   * @param event The event to post.
+   */
+  void postEvent(Project.NameKey projectName, ProjectEvent event);
+
+  /**
+   * Post a stream event generically.
+   * <p>
+   * If you are creating a RefEvent or ChangeEvent from scratch,
+   * it is more efficient to use the specific postEvent methods
+   * for those use cases.
+   *
+   * @param event The event to post.
+   * @throws OrmException on failure to post the event due to DB error
+   */
+  void postEvent(Event event) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
index 7e8a794..b2d5680 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
@@ -17,7 +17,11 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.events.Event;
 
+/**
+ * Allows to listen to events without user visibility restrictions. To listen to
+ * events visible to a specific user, use {@link UserScopedEventListener}.
+ */
 @ExtensionPoint
 public interface EventListener {
-  public void onEvent(Event event);
+  void onEvent(Event event);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java
deleted file mode 100644
index e2c4b34..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common;
-
-import com.google.gerrit.server.CurrentUser;
-
-/** Distributes Events to ChangeListeners.  Register listeners here. */
-public interface EventSource {
-  public void addEventListener(EventListener listener, CurrentUser user);
-
-  public void removeEventListener(EventListener listener);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
new file mode 100644
index 0000000..22435ba
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.common;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.CurrentUser;
+
+/**
+ * Allows to listen to events visible to the specified user. To listen to events
+ * without user visibility restrictions, use {@link EventListener}.
+ */
+@ExtensionPoint
+public interface UserScopedEventListener extends EventListener {
+  CurrentUser getUser();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
index 1a3ad9b..db6faa2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
@@ -36,22 +36,34 @@
   /** Index of the last listener to start successfully; -1 when not started. */
   private int startedIndex = -1;
 
-  /** Add a handle that must be cleared during stop. */
+  /** Add a handle that must be cleared during stop.
+   *
+   * @param handle the handle to add.
+   **/
   public void add(RegistrationHandle handle) {
     handles.add(handle);
   }
 
-  /** Add a single listener. */
+  /** Add a single listener.
+   *
+   * @param listener the listener to add.
+   **/
   public void add(LifecycleListener listener) {
     listeners.add(Providers.of(listener));
   }
 
-  /** Add a single listener. */
+  /** Add a single listener.
+   *
+   * @param listener the listener to add.
+   **/
   public void add(Provider<LifecycleListener> listener) {
     listeners.add(listener);
   }
 
-  /** Add all {@link LifecycleListener}s registered in the Injector. */
+  /** Add all {@link LifecycleListener}s registered in the Injector.
+   *
+   * @param injector the injector to add.
+   **/
   public void add(Injector injector) {
     Preconditions.checkState(startedIndex < 0, "Already started");
     for (Binding<LifecycleListener> binding : get(injector)) {
@@ -59,7 +71,10 @@
     }
   }
 
-  /** Add all {@link LifecycleListener}s registered in the Injectors. */
+  /** Add all {@link LifecycleListener}s registered in the Injectors.
+   *
+   * @param injectors the injectors to add.
+   **/
   public void add(Injector... injectors) {
     for (Injector i : injectors) {
       add(i);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
index 24df965..c22f2ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
@@ -11,7 +11,7 @@
 /** Module to support registering a unique LifecyleListener. */
 public abstract class LifecycleModule extends FactoryModule {
   /**
-   * Create a unique listener binding.
+   * @return a unique listener binding.
    * <p>
    * To create a listener binding use:
    *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
new file mode 100644
index 0000000..1714c7a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * @see CallbackMetric0
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public interface CallbackMetric<V> extends RegistrationHandle {
+  void prune();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java
new file mode 100644
index 0000000..829dd22
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * <pre>
+ *   CallbackMetric0&lt;Long&gt; hits = metricMaker.newCallbackMetric("hits", ...);
+ *   CallbackMetric0&lt;Long&gt; total = metricMaker.newCallbackMetric("total", ...);
+ *   metricMaker.newTrigger(hits, total, new Runnable() {
+ *     public void run() {
+ *       hits.set(1);
+ *       total.set(5);
+ *     }
+ *   });
+ * </pre>
+ *
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public abstract class CallbackMetric0<V> implements CallbackMetric<V> {
+  /**
+   * Supply the current value of the metric.
+   *
+   * @param value current value.
+   */
+  public abstract void set(V value);
+
+  @Override
+  public void prune() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
new file mode 100644
index 0000000..864a0ea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * @param <F1> type of the field.
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public abstract class CallbackMetric1<F1, V> implements CallbackMetric<V> {
+  /**
+   * Supply the current value of the metric.
+   *
+   * @param field1 bucket to increment.
+   * @param value current value.
+   */
+  public abstract void set(F1 field1, V value);
+
+  /**
+   * Ensure a zeroed metric is created for the field value.
+   *
+   * @param field1 bucket to create.
+   */
+  public abstract void forceCreate(F1 field1);
+
+  /** Prune any submetrics that were not assigned during this trigger. */
+  @Override
+  public void prune() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
new file mode 100644
index 0000000..a2af7e4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ */
+public abstract class Counter0 implements RegistrationHandle {
+  /** Increment the counter by one event. */
+  public void increment() {
+    incrementBy(1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
new file mode 100644
index 0000000..1b8c833
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Counter1<F1> implements RegistrationHandle {
+  /**
+   * Increment the counter by one event.
+   *
+   * @param field1 bucket to increment.
+   */
+  public void increment(F1 field1) {
+    incrementBy(field1, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
new file mode 100644
index 0000000..a24b46d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Counter2<F1, F2> implements RegistrationHandle {
+  /**
+   * Increment the counter by one event.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   */
+  public void increment(F1 field1, F2 field2) {
+    incrementBy(field1, field2, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, F2 field2, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
new file mode 100644
index 0000000..e0ac5be
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Counter3<F1, F2, F3> implements RegistrationHandle {
+  /**
+   * Increment the counter by one event.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   * @param field3 bucket to increment.
+   */
+  public void increment(F1 field1, F2 field2, F3 field3) {
+    incrementBy(field1, field2, field3, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   * @param field3 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, F2 field2, F3 field3, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
new file mode 100644
index 0000000..b1579f8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
@@ -0,0 +1,205 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/** Describes a metric created by {@link MetricMaker}. */
+public class Description {
+  public static final String DESCRIPTION = "DESCRIPTION";
+  public static final String UNIT = "UNIT";
+  public static final String CUMULATIVE = "CUMULATIVE";
+  public static final String RATE = "RATE";
+  public static final String GAUGE = "GAUGE";
+  public static final String CONSTANT = "CONSTANT";
+  public static final String FIELD_ORDERING = "FIELD_ORDERING";
+  public static final String TRUE_VALUE = "1";
+
+  public static class Units {
+    public static final String SECONDS = "seconds";
+    public static final String MILLISECONDS = "milliseconds";
+    public static final String MICROSECONDS = "microseconds";
+    public static final String NANOSECONDS = "nanoseconds";
+
+    public static final String BYTES = "bytes";
+
+    private Units() {
+    }
+  }
+
+  public enum FieldOrdering {
+    /** Default ordering places fields at end of the parent metric name. */
+    AT_END,
+
+    /**
+     * Splits the metric name by inserting field values before the last '/' in
+     * the metric name. For example {@code "plugins/replication/push_latency"}
+     * with a {@code Field.ofString("remote")} will create submetrics named
+     * {@code "plugins/replication/some-server/push_latency"}.
+     */
+    PREFIX_FIELDS_BASENAME;
+  }
+
+  private final Map<String, String> annotations;
+
+  /**
+   * Describe a metric.
+   *
+   * @param helpText a short one-sentence string explaining the values captured
+   *        by the metric. This may be made available to administrators as
+   *        documentation in the reporting tools.
+   */
+  public Description(String helpText) {
+    annotations = Maps.newLinkedHashMapWithExpectedSize(4);
+    annotations.put(DESCRIPTION, helpText);
+  }
+
+  /** Set unit used to describe the value.
+   *
+   * @param unitName name of the unit, e.g. "requests", "seconds", etc.
+   * @return this
+   */
+  public Description setUnit(String unitName) {
+    annotations.put(UNIT, unitName);
+    return this;
+  }
+
+  /**
+   * Mark the value as constant for the life of this process. Typically used for
+   * software versions, command line arguments, etc. that cannot change without
+   * a process restart.
+   *
+   * @return this
+   */
+  public Description setConstant() {
+    annotations.put(CONSTANT, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Indicates the metric may be usefully interpreted as a count over short
+   * periods of time, such as request arrival rate. May only be applied to a
+   * {@link Counter0}.
+   *
+   * @return this
+   */
+  public Description setRate() {
+    annotations.put(RATE, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Instantaneously sampled value that may increase or decrease at a later
+   * time. Memory allocated or open network connections are examples of gauges.
+   *
+   * @return this
+   */
+  public Description setGauge() {
+    annotations.put(GAUGE, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Indicates the metric accumulates over the lifespan of the process. A
+   * {@link Counter0} like total requests handled accumulates over the process
+   * and should be {@code setCumulative()}.
+   *
+   * @return this
+   */
+  public Description setCumulative() {
+    annotations.put(CUMULATIVE, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Configure how fields are ordered into submetric names.
+   *
+   * @param ordering field ordering
+   * @return this
+   */
+  public Description setFieldOrdering(FieldOrdering ordering) {
+    annotations.put(FIELD_ORDERING, ordering.name());
+    return this;
+  }
+
+  /** @return true if the metric value never changes after startup. */
+  public boolean isConstant() {
+    return TRUE_VALUE.equals(annotations.get(CONSTANT));
+  }
+
+  /** @return true if the metric may be interpreted as a rate over time. */
+  public boolean isRate() {
+    return TRUE_VALUE.equals(annotations.get(RATE));
+  }
+
+  /** @return true if the metric is an instantaneous sample. */
+  public boolean isGauge() {
+    return TRUE_VALUE.equals(annotations.get(GAUGE));
+  }
+
+  /** @return true if the metric accumulates over the lifespan of the process. */
+  public boolean isCumulative() {
+    return TRUE_VALUE.equals(annotations.get(CUMULATIVE));
+  }
+
+  /** @return the suggested field ordering. */
+  public FieldOrdering getFieldOrdering() {
+    String o = annotations.get(FIELD_ORDERING);
+    return o != null ? FieldOrdering.valueOf(o) : FieldOrdering.AT_END;
+  }
+
+  /**
+   * Decode the unit as a unit of time.
+   *
+   * @return valid time unit.
+   * @throws IllegalArgumentException if the unit is not a valid unit of time.
+   */
+  public TimeUnit getTimeUnit() {
+    return getTimeUnit(annotations.get(UNIT));
+  }
+
+  private static final ImmutableMap<String, TimeUnit> TIME_UNITS = ImmutableMap.of(
+      Units.NANOSECONDS, TimeUnit.NANOSECONDS,
+      Units.MICROSECONDS, TimeUnit.MICROSECONDS,
+      Units.MILLISECONDS, TimeUnit.MILLISECONDS,
+      Units.SECONDS, TimeUnit.SECONDS);
+
+  public static TimeUnit getTimeUnit(String unit) {
+    if (Strings.isNullOrEmpty(unit)) {
+      throw new IllegalArgumentException("no unit configured");
+    }
+    TimeUnit u = TIME_UNITS.get(unit);
+    if (u == null) {
+      throw new IllegalArgumentException(String.format(
+          "unit %s not TimeUnit", unit));
+    }
+    return u;
+  }
+
+  /** @return immutable copy of all annotations (configurable properties). */
+  public ImmutableMap<String, String> getAnnotations() {
+    return ImmutableMap.copyOf(annotations);
+  }
+
+  @Override
+  public String toString() {
+    return annotations.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
new file mode 100644
index 0000000..1b05e2c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/** Exports no metrics, useful for running batch programs. */
+public class DisabledMetricMaker extends MetricMaker {
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    return new Counter0() {
+      @Override public void incrementBy(long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(String name, Description desc,
+      Field<F1> field1) {
+    return new Counter1<F1>() {
+      @Override public void incrementBy(F1 field1, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Counter2<F1, F2>() {
+      @Override public void incrementBy(F1 field1, F2 field2, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Counter3<F1, F2, F3>() {
+      @Override public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public Timer0 newTimer(String name, Description desc) {
+    return new Timer0() {
+      @Override public void record(long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Timer1<F1> newTimer(String name, Description desc,
+      Field<F1> field1) {
+    return new Timer1<F1>() {
+      @Override public void record(F1 field1, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Timer2<F1, F2>() {
+      @Override public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Timer3<F1, F2, F3>() {
+      @Override public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public Histogram0 newHistogram(String name, Description desc) {
+    return new Histogram0() {
+      @Override public void record(long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Histogram1<F1> newHistogram(String name, Description desc,
+      Field<F1> field1) {
+    return new Histogram1<F1>() {
+      @Override public void record(F1 field1, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Histogram2<F1, F2> newHistogram(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Histogram2<F1, F2>() {
+      @Override public void record(F1 field1, F2 field2, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Histogram3<F1, F2, F3>() {
+      @Override public void record(F1 field1, F2 field2, F3 field3, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc) {
+    return new CallbackMetric0<V>() {
+      @Override public void set(V value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, Field<F1> field1) {
+    return new CallbackMetric1<F1, V>() {
+      @Override public void set(F1 field1, V value) {}
+      @Override public void forceCreate(F1 field1) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger) {
+    return new RegistrationHandle() {
+      @Override public void remove() {}
+    };
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
new file mode 100644
index 0000000..364f4f8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Function;
+import com.google.common.base.Functions;
+
+/**
+ * Describes a bucketing field used by a metric.
+ *
+ * @param <T> type of field
+ */
+public class Field<T> {
+  /**
+   * Break down metrics by boolean true/false.
+   *
+   * @param name field name
+   * @return boolean field
+   */
+  public static Field<Boolean> ofBoolean(String name) {
+    return ofBoolean(name, null);
+  }
+
+  /**
+   * Break down metrics by boolean true/false.
+   *
+   * @param name field name
+   * @param description field description
+   * @return boolean field
+   */
+  public static Field<Boolean> ofBoolean(String name, String description) {
+    return new Field<>(name, Boolean.class, description);
+  }
+
+  /**
+   * Break down metrics by cases of an enum.
+   *
+   * @param enumType type of enum
+   * @param name field name
+   * @return enum field
+   */
+  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
+      String name) {
+    return ofEnum(enumType, name, null);
+  }
+
+  /**
+   * Break down metrics by cases of an enum.
+   *
+   * @param enumType type of enum
+   * @param name field name
+   * @param description field description
+   * @return enum field
+   */
+  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
+      String name, String description) {
+    return new Field<>(name, enumType, description);
+  }
+
+  /**
+   * Break down metrics by string.
+   * <p>
+   * Each unique string will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   *
+   * @param name field name
+   * @return string field
+   */
+  public static Field<String> ofString(String name) {
+    return ofString(name, null);
+  }
+
+  /**
+   * Break down metrics by string.
+   * <p>
+   * Each unique string will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   *
+   * @param name field name
+   * @param description field description
+   * @return string field
+   */
+  public static Field<String> ofString(String name, String description) {
+    return new Field<>(name, String.class, description);
+  }
+
+  /**
+   * Break down metrics by integer.
+   * <p>
+   * Each unique integer will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   *
+   * @param name field name
+   * @return integer field
+   */
+  public static Field<Integer> ofInteger(String name) {
+    return ofInteger(name, null);
+  }
+
+  /**
+   * Break down metrics by integer.
+   * <p>
+   * Each unique integer will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   *
+   * @param name field name
+   * @param description field description
+   * @return integer field
+   */
+  public static Field<Integer> ofInteger(String name, String description) {
+    return new Field<>(name, Integer.class, description);
+  }
+
+  private final String name;
+  private final Class<T> keyType;
+  private final Function<T, String> formatter;
+  private final String description;
+
+  private Field(String name, Class<T> keyType, String description) {
+    checkArgument(name.matches("^[a-z_]+$"), "name must match [a-z_]");
+    this.name = name;
+    this.keyType = keyType;
+    this.formatter = initFormatter(keyType);
+    this.description = description;
+  }
+
+  /** @return name of this field within the metric. */
+  public String getName() {
+    return name;
+  }
+
+  /** @return type of value used within the field. */
+  public Class<T> getType() {
+    return keyType;
+  }
+
+  /** @return description text for the field explaining its range of values. */
+  public String getDescription() {
+    return description;
+  }
+
+  public Function<T, String> formatter() {
+    return formatter;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> Function<T, String> initFormatter(Class<T> keyType) {
+    if (keyType == String.class) {
+      return (Function<T, String>) Functions.<String> identity();
+
+    } else if (keyType == Integer.class || keyType == Boolean.class) {
+      return (Function<T, String>) Functions.toStringFunction();
+
+    } else if (Enum.class.isAssignableFrom(keyType)) {
+      return new Function<T, String>() {
+        @Override
+        public String apply(T in) {
+          return ((Enum<?>) in).name();
+        }
+      };
+    }
+    throw new IllegalStateException("unsupported type " + keyType.getName());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
new file mode 100644
index 0000000..fa614d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ */
+public abstract class Histogram0 implements RegistrationHandle {
+  /**
+   * Record a sample of a specified amount.
+   *
+   * @param value to record
+   */
+  public abstract void record(long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
new file mode 100644
index 0000000..dd1ed0a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Histogram1<F1> implements RegistrationHandle {
+  /**
+   * Record a sample of a specified amount.
+   *
+   * @param field1 bucket to record sample
+   * @param value value to record
+   */
+  public abstract void record(F1 field1, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
new file mode 100644
index 0000000..b1c4482
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Histogram2<F1, F2> implements RegistrationHandle {
+  /**
+   * Record a sample of a specified amount.
+   *
+   * @param field1 bucket to record sample
+   * @param field2 bucket to record sample
+   * @param value value to record
+   */
+  public abstract void record(F1 field1, F2 field2, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
new file mode 100644
index 0000000..0c50e118
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Histogram3<F1, F2, F3> implements RegistrationHandle {
+  /**
+   * Record a sample of a specified amount.
+   *
+   * @param field1 bucket to record sample
+   * @param field2 bucket to record sample
+   * @param field3 bucket to record sample
+   * @param value value to record
+   */
+  public abstract void record(F1 field1, F2 field2, F3 field3, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
new file mode 100644
index 0000000..461c6b6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.Set;
+
+/** Factory to create metrics for monitoring. */
+public abstract class MetricMaker {
+  /**
+   * Metric whose value increments during the life of the process.
+   *
+   * @param name field name
+   * @param desc field description
+   * @return counter
+   */
+  public abstract Counter0 newCounter(String name, Description desc);
+  public abstract <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /**
+   * Metric recording time spent on an operation.
+   *
+   * @param name field name
+   * @param desc field description
+   * @return timer
+   */
+  public abstract Timer0 newTimer(String name, Description desc);
+  public abstract <F1> Timer1<F1> newTimer(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /**
+   * Metric recording statistical distribution of values.
+   *
+   * @param name field name
+   * @param desc field description
+   * @return histogram
+   */
+  public abstract Histogram0 newHistogram(String name, Description desc);
+  public abstract <F1> Histogram1<F1> newHistogram(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /**
+   * Constant value that does not change.
+   *
+   * @param name unique name of the metric.
+   * @param value only value of the metric.
+   * @param desc description of the metric.
+   */
+  public <V> void newConstantMetric(String name, final V value, Description desc) {
+    desc.setConstant();
+
+    @SuppressWarnings("unchecked")
+    Class<V> type = (Class<V>) value.getClass();
+    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    newTrigger(metric, new Runnable() {
+      @Override
+      public void run() {
+        metric.set(value);
+      }
+    });
+  }
+
+  /**
+   * Instantaneous reading of a value.
+   *
+   * <pre>
+   * metricMaker.newCallbackMetric(&quot;memory&quot;,
+   *     new Description(&quot;Total bytes of memory used&quot;)
+   *        .setGauge()
+   *        .setUnit(Units.BYTES),
+   *     new Supplier&lt;Long&gt;() {
+   *       public Long get() {
+   *         return Runtime.getRuntime().totalMemory();
+   *       }
+   *     });
+   * </pre>
+   *
+   * @param name unique name of the metric.
+   * @param valueClass type of value recorded by the metric.
+   * @param desc description of the metric.
+   * @param trigger function to compute the value of the metric.
+   */
+  public <V> void newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, final Supplier<V> trigger) {
+    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+    newTrigger(metric, new Runnable() {
+      @Override
+      public void run() {
+        metric.set(trigger.get());
+      }
+    });
+  }
+
+  /**
+   *  Instantaneous reading of a single value.
+   *
+   * @param name field name
+   * @param valueClass field type
+   * @param desc field description
+   * @return callback
+   */
+  public abstract <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc);
+  public abstract <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc,
+      Field<F1> field1);
+
+  /**
+   * Connect logic to populate a previously created {@link CallbackMetric}.
+   *
+   * @param metric1 previously created callback
+   * @param trigger trigger to connect
+   * @return registration handle
+   */
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1, Runnable trigger) {
+    return newTrigger(ImmutableSet.<CallbackMetric<?>>of(metric1), trigger);
+  }
+
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
+      CallbackMetric<?> metric2, Runnable trigger) {
+    return newTrigger(ImmutableSet.of(metric1, metric2), trigger);
+  }
+
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
+      CallbackMetric<?> metric2, CallbackMetric<?> metric3, Runnable trigger) {
+    return newTrigger(ImmutableSet.of(metric1, metric2, metric3), trigger);
+  }
+
+  public abstract RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
new file mode 100644
index 0000000..d2c9a52
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer0.Context ctx = timer.start()) {
+ * }
+ * </pre>
+ */
+public abstract class Timer0 implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer0 timer;
+
+    Context(Timer0 timer) {
+      this.timer = timer;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(elapsed, NANOSECONDS);
+    }
+  }
+
+  /**
+   * Begin a timer for the current block, value will be recorded when closed.
+   *
+   * @return timer context
+   */
+  public Context start() {
+    return new Context(this);
+  }
+
+  /** Record a value in the distribution.
+   *
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  public abstract void record(long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
new file mode 100644
index 0000000..be6931d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer1.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Timer1<F1> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer1<Object> timer;
+    private final Object field1;
+
+    @SuppressWarnings("unchecked")
+    <F1> Context(Timer1<F1> timer, F1 field1) {
+      this.timer = (Timer1<Object>) timer;
+      this.field1 = field1;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, elapsed, NANOSECONDS);
+    }
+  }
+
+  /**
+   * Begin a timer for the current block, value will be recorded when closed.
+   *
+   * @param field1 bucket to record the timer
+   * @return timer context
+   */
+  public Context start(F1 field1) {
+    return new Context(this, field1);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param field1 bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  public abstract void record(F1 field1, long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
new file mode 100644
index 0000000..0ace4c3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer2.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Timer2<F1, F2> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer2<Object, Object> timer;
+    private final Object field1;
+    private final Object field2;
+
+    @SuppressWarnings("unchecked")
+    <F1, F2> Context(Timer2<F1, F2> timer, F1 field1, F2 field2) {
+      this.timer = (Timer2<Object, Object>) timer;
+      this.field1 = field1;
+      this.field2 = field2;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, field2, elapsed, NANOSECONDS);
+    }
+  }
+
+  /**
+   * Begin a timer for the current block, value will be recorded when closed.
+   *
+   * @param field1 bucket to record the timer
+   * @param field2 bucket to record the timer
+   * @return timer context
+   */
+  public Context start(F1 field1, F2 field2) {
+    return new Context(this, field1, field2);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param field1 bucket to record the timer
+   * @param field2 bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  public abstract void record(F1 field1, F2 field2, long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
new file mode 100644
index 0000000..09e899d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer3.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer3<Object, Object, Object> timer;
+    private final Object field1;
+    private final Object field2;
+    private final Object field3;
+
+    @SuppressWarnings("unchecked")
+    <F1, F2, F3> Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
+      this.timer = (Timer3<Object, Object, Object>) timer;
+      this.field1 = f1;
+      this.field2 = f2;
+      this.field3 = f3;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, field2, field3, elapsed, NANOSECONDS);
+    }
+  }
+
+  /**
+   * Begin a timer for the current block, value will be recorded when closed.
+   *
+   * @param field1 bucket to record the timer
+   * @param field2 bucket to record the timer
+   * @param field3 bucket to record the timer
+   * @return timer context
+   */
+  public Context start(F1 field1, F2 field2, F3 field3) {
+    return new Context(this, field1, field2, field3);
+  }
+
+  /**
+   * Record a value in the distribution.
+   *
+   * @param field1 bucket to record the timer
+   * @param field2 bucket to record the timer
+   * @param field3 bucket to record the timer
+   * @param value value to record
+   * @param unit time unit of the value
+   */
+  public abstract void record(F1 field1, F2 field2, F3 field3,
+      long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java
new file mode 100644
index 0000000..62eb030
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+abstract class TimerContext implements AutoCloseable {
+  private final long startNanos;
+  private boolean stopped;
+
+  TimerContext() {
+    this.startNanos = System.nanoTime();
+  }
+
+  /**
+   * Record the elapsed time to the timer.
+   *
+   * @param elapsed Elapsed time in nanoseconds.
+   */
+  public abstract void record(long elapsed);
+
+  /** @return the start time in system time nanoseconds. */
+  public long getStartTime() {
+    return startNanos;
+  }
+
+  /**
+   * Stop the timer and record the elapsed time.
+   *
+   * @return the elapsed time in nanoseconds.
+   * @throws IllegalStateException if the timer is already stopped.
+   */
+  public long stop() {
+    if (!stopped) {
+      stopped = true;
+      long elapsed = System.nanoTime() - startNanos;
+      record(elapsed);
+      return elapsed;
+    }
+    throw new IllegalStateException("Already stopped");
+  }
+
+  @Override
+  public void close() {
+    if (!stopped) {
+      stop();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
new file mode 100644
index 0000000..e7ab75c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract callback metric broken down into buckets. */
+abstract class BucketedCallback<V> implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final MetricRegistry registry;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  private final V zero;
+  private final Map<Object, ValueGauge> cells;
+  protected volatile Runnable trigger;
+  private final Object lock = new Object();
+
+  BucketedCallback(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueType, Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.registry = registry;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.zero = CallbackMetricImpl0.zeroFor(valueType);
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (Object key : cells.keySet()) {
+      registry.remove(submetric(key));
+    }
+    metrics.remove(name);
+  }
+
+  void doBeginSet() {
+    for (ValueGauge g : cells.values()) {
+      g.set = false;
+    }
+  }
+
+  void doPrune() {
+    Iterator<Map.Entry<Object, ValueGauge>> i = cells.entrySet().iterator();
+    while (i.hasNext()) {
+      if (!i.next().getValue().set) {
+        i.remove();
+      }
+    }
+  }
+
+  void doEndSet() {
+    for (ValueGauge g : cells.values()) {
+      if (!g.set) {
+        g.value = zero;
+      }
+    }
+  }
+
+  ValueGauge getOrCreate(Object f1, Object f2) {
+    return getOrCreate(ImmutableList.of(f1, f2));
+  }
+
+  ValueGauge getOrCreate(Object f1, Object f2, Object f3) {
+    return getOrCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  ValueGauge getOrCreate(Object key) {
+    ValueGauge c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (lock) {
+      c = cells.get(key);
+      if (c == null) {
+        c = new ValueGauge();
+        registry.register(submetric(key), c);
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return null;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<ValueGauge, Metric> () {
+          @Override
+          public Metric apply(ValueGauge in) {
+            return in;
+          }
+        });
+  }
+
+  final class ValueGauge implements Gauge<V> {
+    volatile V value = zero;
+    boolean set;
+
+    @Override
+    public V getValue() {
+      Runnable t = trigger;
+      if (t != null) {
+        t.run();
+      }
+      return value;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
new file mode 100644
index 0000000..10b92e6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.CounterImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract counter broken down into buckets by {@link Field} values. */
+abstract class BucketedCounter implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final boolean isRate;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final CounterImpl total;
+  private final Map<Object, CounterImpl> cells;
+  private final Object lock = new Object();
+
+  BucketedCounter(DropWizardMetricMaker metrics,
+      String name, Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.isRate = desc.isRate();
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newCounterImpl(name + "_total", isRate);
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (CounterImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  CounterImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  CounterImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  CounterImpl forceCreate(Object key) {
+    CounterImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (lock) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newCounterImpl(submetric(key), isRate);
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<CounterImpl, Metric> () {
+          @Override
+          public Metric apply(CounterImpl in) {
+            return in.metric;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
new file mode 100644
index 0000000..071c678
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.HistogramImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract histogram broken down into buckets by {@link Field} values. */
+abstract class BucketedHistogram implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final HistogramImpl total;
+  private final Map<Object, HistogramImpl> cells;
+  private final Object lock = new Object();
+
+  BucketedHistogram(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newHistogramImpl(name + "_total");
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (HistogramImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  HistogramImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  HistogramImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  HistogramImpl forceCreate(Object key) {
+    HistogramImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (lock) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newHistogramImpl(submetric(key));
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<HistogramImpl, Metric> () {
+          @Override
+          public Metric apply(HistogramImpl in) {
+            return in.metric;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
new file mode 100644
index 0000000..799e594
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+
+/** Metric broken down into buckets by {@link Field} values. */
+interface BucketedMetric extends Metric {
+  @Nullable Metric getTotal();
+  Field<?>[] getFields();
+  Map<?, Metric> getCells();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
new file mode 100644
index 0000000..6981ef1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.TimerImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract timer broken down into buckets by {@link Field} values. */
+abstract class BucketedTimer implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final TimerImpl total;
+  private final Map<Object, TimerImpl> cells;
+  private final Object lock = new Object();
+
+  BucketedTimer(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newTimerImpl(name + "_total");
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (TimerImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  TimerImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  TimerImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  TimerImpl forceCreate(Object key) {
+    TimerImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (lock) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newTimerImpl(submetric(key));
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<TimerImpl, Metric> () {
+          @Override
+          public Metric apply(TimerImpl in) {
+            return in.metric;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
new file mode 100644
index 0000000..372bdcb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Run a user specified trigger only once every 2 seconds.
+ * <p>
+ * This allows the same Runnable trigger to be applied to several metrics. When
+ * a recorder is sampling the related metrics only the first access will perform
+ * recomputation. Reading other related metrics will rely on the already set
+ * values for the next several seconds.
+ */
+class CallbackGroup implements Runnable {
+  private static final long PERIOD = TimeUnit.SECONDS.toNanos(2);
+
+  private final AtomicLong reloadAt;
+  private final Runnable trigger;
+  private final ImmutableSet<CallbackMetricGlue> metrics;
+  private final Object reloadLock = new Object();
+
+  CallbackGroup(Runnable trigger, ImmutableSet<CallbackMetricGlue> metrics) {
+    this.reloadAt = new AtomicLong(0);
+    this.trigger = trigger;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public void run() {
+    if (reload()) {
+      synchronized (reloadLock) {
+        for (CallbackMetricGlue m : metrics) {
+          m.beginSet();
+        }
+        trigger.run();
+        for (CallbackMetricGlue m : metrics) {
+          m.endSet();
+        }
+      }
+    }
+  }
+
+  private boolean reload() {
+    for (;;) {
+      long now = System.nanoTime();
+      long next = reloadAt.get();
+      if (next > now) {
+        return false;
+      } else if (reloadAt.compareAndSet(next, now + PERIOD)) {
+        return true;
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
new file mode 100644
index 0000000..4f5b7ad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+interface CallbackMetricGlue {
+  void beginSet();
+  void endSet();
+  void register(Runnable trigger);
+  void remove();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
new file mode 100644
index 0000000..dcab692
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.metrics.CallbackMetric0;
+
+import com.codahale.metrics.MetricRegistry;
+
+class CallbackMetricImpl0<V>
+    extends CallbackMetric0<V>
+    implements CallbackMetricGlue {
+  @SuppressWarnings("unchecked")
+  static <V> V zeroFor(Class<V> valueClass) {
+    if (valueClass == Integer.class) {
+      return (V) Integer.valueOf(0);
+    } else if (valueClass == Long.class) {
+      return (V) Long.valueOf(0);
+    } else if (valueClass == Double.class) {
+      return (V) Double.valueOf(0);
+    } else if (valueClass == Float.class) {
+      return (V) Float.valueOf(0);
+    } else if (valueClass == String.class) {
+      return (V) "";
+    } else if (valueClass == Boolean.class) {
+      return (V) Boolean.FALSE;
+    } else {
+      throw new IllegalArgumentException("unsupported value type "
+          + valueClass.getName());
+    }
+  }
+
+  private final DropWizardMetricMaker metrics;
+  private final MetricRegistry registry;
+  private final String name;
+  private volatile V value;
+
+  CallbackMetricImpl0(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueType) {
+    this.metrics = metrics;
+    this.registry = registry;
+    this.name = name;
+    this.value = zeroFor(valueType);
+  }
+
+  @Override
+  public void beginSet() {
+  }
+
+  @Override
+  public void endSet() {
+  }
+
+  @Override
+  public void set(V value) {
+    this.value = value;
+  }
+
+  @Override
+  public void remove() {
+    metrics.remove(name);
+    registry.remove(name);
+  }
+
+  @Override
+  public void register(final Runnable trigger) {
+    registry.register(name, new com.codahale.metrics.Gauge<V>() {
+      @Override
+      public V getValue() {
+        trigger.run();
+        return value;
+      }
+    });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
new file mode 100644
index 0000000..81d5ff5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.MetricRegistry;
+
+/** Optimized version of {@link BucketedCallback} for single dimension. */
+class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
+  CallbackMetricImpl1(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+    super(metrics, registry, name, valueClass, desc, field1);
+  }
+
+  CallbackMetric1<F1, V> create() {
+    return new Impl1();
+  }
+
+  private final class Impl1
+      extends CallbackMetric1<F1, V>
+      implements CallbackMetricGlue {
+    @Override
+    public void beginSet() {
+      doBeginSet();
+    }
+
+    @Override
+    public void set(F1 field1, V value) {
+      BucketedCallback<V>.ValueGauge cell = getOrCreate(field1);
+      cell.value = value;
+      cell.set = true;
+    }
+
+    @Override
+    public void prune() {
+      doPrune();
+    }
+
+    @Override
+    public void endSet() {
+      doEndSet();
+    }
+
+    @Override
+    public void forceCreate(F1 field1) {
+      getOrCreate(field1);
+    }
+
+    @Override
+    public void register(Runnable t) {
+      trigger = t;
+    }
+
+    @Override
+    public void remove() {
+      doRemove();
+    }
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
new file mode 100644
index 0000000..25647ef
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+/** Optimized version of {@link BucketedCounter} for single dimension. */
+class CounterImpl1<F1> extends BucketedCounter {
+  CounterImpl1(DropWizardMetricMaker metrics, String name, Description desc,
+      Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Counter1<F1> counter() {
+    return new Counter1<F1>() {
+      @Override
+      public void incrementBy(F1 field1, long value) {
+        total.incrementBy(value);
+        forceCreate(field1).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
new file mode 100644
index 0000000..a2f1f84
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+/** Generalized implementation of N-dimensional counter metrics. */
+class CounterImplN extends BucketedCounter implements BucketedMetric {
+  CounterImplN(DropWizardMetricMaker metrics, String name, Description desc,
+      Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Counter2<F1, F2> counter2() {
+    return new Counter2<F1, F2>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, long value) {
+        total.incrementBy(value);
+        forceCreate(field1, field2).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Counter3<F1, F2, F3> counter3() {
+    return new Counter3<F1, F2, F3>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
+        total.incrementBy(value);
+        forceCreate(field1, field2, field3).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
new file mode 100644
index 0000000..6359bb5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -0,0 +1,432 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.metrics.dropwizard.MetricResource.METRIC_KIND;
+import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.FieldOrdering;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.metrics.proc.JGitMetricModule;
+import com.google.gerrit.metrics.proc.ProcMetricModule;
+import com.google.gerrit.server.cache.CacheMetrics;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+/**
+ * Connects Gerrit metric package onto DropWizard.
+ *
+ * @see <a href="http://www.dropwizard.io/">DropWizard</a>
+ */
+@Singleton
+public class DropWizardMetricMaker extends MetricMaker {
+  public static class ApiModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      bind(MetricRegistry.class).in(Scopes.SINGLETON);
+      bind(DropWizardMetricMaker.class).in(Scopes.SINGLETON);
+      bind(MetricMaker.class).to(DropWizardMetricMaker.class);
+
+      install(new ProcMetricModule());
+      install(new JGitMetricModule());
+    }
+  }
+
+  public static class RestModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      DynamicMap.mapOf(binder(), METRIC_KIND);
+      child(CONFIG_KIND, "metrics").to(MetricsCollection.class);
+      get(METRIC_KIND).to(GetMetric.class);
+      bind(CacheMetrics.class);
+    }
+  }
+
+  private final MetricRegistry registry;
+  private final Map<String, BucketedMetric> bucketed;
+  private final Map<String, ImmutableMap<String, String>> descriptions;
+
+  @Inject
+  DropWizardMetricMaker(MetricRegistry registry) {
+    this.registry = registry;
+    this.bucketed = new ConcurrentHashMap<>();
+    this.descriptions = new ConcurrentHashMap<>();
+  }
+
+  Iterable<String> getMetricNames() {
+    return descriptions.keySet();
+  }
+
+  /** Get the underlying metric implementation. */
+  public Metric getMetric(String name) {
+    Metric m = bucketed.get(name);
+    return m != null ? m : registry.getMetrics().get(name);
+  }
+
+  /** Lookup annotations from a metric's {@link Description}. */
+  public ImmutableMap<String, String> getAnnotations(String name) {
+    return descriptions.get(name);
+  }
+
+  @Override
+  public synchronized Counter0 newCounter(String name, Description desc) {
+    checkCounterDescription(name, desc);
+    define(name, desc);
+    return newCounterImpl(name, desc.isRate());
+  }
+
+  @Override
+  public synchronized <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1) {
+    checkCounterDescription(name, desc);
+    CounterImpl1<F1> m = new CounterImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter();
+  }
+
+  @Override
+  public synchronized <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    checkCounterDescription(name, desc);
+    CounterImplN m = new CounterImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkCounterDescription(name, desc);
+    CounterImplN m = new CounterImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter3();
+  }
+
+  private static void checkCounterDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "counters must not be constant");
+    checkArgument(!desc.isGauge(), "counters must not be gauge");
+  }
+
+  CounterImpl newCounterImpl(String name, boolean isRate) {
+    if (isRate) {
+      final com.codahale.metrics.Meter m = registry.meter(name);
+      return new CounterImpl(name, m) {
+        @Override
+        public void incrementBy(long delta) {
+          checkArgument(delta >= 0, "counter delta must be >= 0");
+          m.mark(delta);
+        }
+      };
+    }
+    final com.codahale.metrics.Counter m = registry.counter(name);
+    return new CounterImpl(name, m) {
+      @Override
+      public void incrementBy(long delta) {
+        checkArgument(delta >= 0, "counter delta must be >= 0");
+        m.inc(delta);
+      }
+    };
+  }
+
+  @Override
+  public synchronized Timer0 newTimer(String name, Description desc) {
+    checkTimerDescription(name, desc);
+    define(name, desc);
+    return newTimerImpl(name);
+  }
+
+  @Override
+  public synchronized <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
+    checkTimerDescription(name, desc);
+    TimerImpl1<F1> m = new TimerImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer();
+  }
+
+  @Override
+  public synchronized <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    checkTimerDescription(name, desc);
+    TimerImplN m = new TimerImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkTimerDescription(name, desc);
+    TimerImplN m = new TimerImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer3();
+  }
+
+  private static void checkTimerDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "timer must not be constant");
+    checkArgument(!desc.isGauge(), "timer must not be a gauge");
+    checkArgument(!desc.isRate(), "timer must not be a rate");
+    checkArgument(desc.isCumulative(), "timer must be cumulative");
+    checkArgument(desc.getTimeUnit() != null, "timer must have a unit");
+  }
+
+  TimerImpl newTimerImpl(String name) {
+    return new TimerImpl(name, registry.timer(name));
+  }
+
+  @Override
+  public synchronized Histogram0 newHistogram(String name, Description desc) {
+    checkHistogramDescription(name, desc);
+    define(name, desc);
+    return newHistogramImpl(name);
+  }
+
+  @Override
+  public synchronized <F1> Histogram1<F1> newHistogram(String name,
+      Description desc, Field<F1> field1) {
+    checkHistogramDescription(name, desc);
+    HistogramImpl1<F1> m = new HistogramImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram1();
+  }
+
+  @Override
+  public synchronized <F1, F2> Histogram2<F1, F2> newHistogram(String name,
+      Description desc, Field<F1> field1, Field<F2> field2) {
+    checkHistogramDescription(name, desc);
+    HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkHistogramDescription(name, desc);
+    HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram3();
+  }
+
+  private static void checkHistogramDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "histogram must not be constant");
+    checkArgument(!desc.isGauge(), "histogram must not be a gauge");
+    checkArgument(!desc.isRate(), "histogram must not be a rate");
+    checkArgument(desc.isCumulative(), "histogram must be cumulative");
+  }
+
+  HistogramImpl newHistogramImpl(String name) {
+    return new HistogramImpl(name, registry.histogram(name));
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc) {
+    checkMetricName(name);
+    define(name, desc);
+    return new CallbackMetricImpl0<>(this, registry, name, valueClass);
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+    checkMetricName(name);
+    CallbackMetricImpl1<F1, V> m = new CallbackMetricImpl1<>(this, registry,
+        name, valueClass, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.create();
+  }
+
+  @Override
+  public synchronized RegistrationHandle newTrigger(
+      Set<CallbackMetric<?>> metrics, Runnable trigger) {
+    final ImmutableSet<CallbackMetricGlue> all = FluentIterable.from(metrics)
+        .transform(
+          new Function<CallbackMetric<?>, CallbackMetricGlue>() {
+            @Override
+            public CallbackMetricGlue apply(CallbackMetric<?> input) {
+              return (CallbackMetricGlue) input;
+            }
+          })
+        .toSet();
+
+    trigger = new CallbackGroup(trigger, all);
+    for (CallbackMetricGlue m : all) {
+      m.register(trigger);
+    }
+    trigger.run();
+
+    return new RegistrationHandle() {
+      @Override
+      public void remove() {
+        for (CallbackMetricGlue m : all) {
+          m.remove();
+        }
+      }
+    };
+  }
+
+  synchronized void remove(String name) {
+    bucketed.remove(name);
+    descriptions.remove(name);
+  }
+
+  private synchronized void define(String name, Description desc) {
+    if (descriptions.containsKey(name)) {
+      ImmutableMap<String, String> annotations = descriptions.get(name);
+      if (!desc.getAnnotations().get(Description.DESCRIPTION).equals(
+          annotations.get(Description.DESCRIPTION))) {
+        throw new IllegalStateException(String.format(
+            "metric %s already defined", name));
+      }
+    } else {
+      descriptions.put(name, desc.getAnnotations());
+    }
+  }
+
+  private static final Pattern METRIC_NAME_PATTERN = Pattern
+      .compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
+
+  private static void checkMetricName(String name) {
+    checkArgument(
+        METRIC_NAME_PATTERN.matcher(name).matches(),
+        "metric name must match %s", METRIC_NAME_PATTERN.pattern());
+  }
+
+  static String name(Description.FieldOrdering ordering,
+      String codeName,
+      String fieldValues) {
+    if (ordering == FieldOrdering.PREFIX_FIELDS_BASENAME) {
+      int s = codeName.lastIndexOf('/');
+      if (s > 0) {
+        String prefix = codeName.substring(0, s);
+        String metric = codeName.substring(s + 1);
+        return prefix + '/' + fieldValues + '/' + metric;
+      }
+    }
+    return codeName + '/' + fieldValues;
+  }
+
+  abstract class CounterImpl extends Counter0 {
+    private final String name;
+    final Metric metric;
+
+    CounterImpl(String name, Metric metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+
+  class TimerImpl extends Timer0 {
+    private final String name;
+    final com.codahale.metrics.Timer metric;
+
+    private TimerImpl(String name, com.codahale.metrics.Timer metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void record(long value, TimeUnit unit) {
+      checkArgument(value >= 0, "timer delta must be >= 0");
+      metric.update(value, unit);
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+
+  class HistogramImpl extends Histogram0 {
+    private final String name;
+    final com.codahale.metrics.Histogram metric;
+
+    private HistogramImpl(String name, com.codahale.metrics.Histogram metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void record(long value) {
+      metric.update(value);
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
new file mode 100644
index 0000000..47064df
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.Inject;
+
+import org.kohsuke.args4j.Option;
+
+class GetMetric implements RestReadView<MetricResource> {
+  private final CurrentUser user;
+  private final DropWizardMetricMaker metrics;
+
+  @Option(name = "--data-only", usage = "return only values")
+  boolean dataOnly;
+
+  @Inject
+  GetMetric(CurrentUser user, DropWizardMetricMaker metrics) {
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public MetricJson apply(MetricResource resource) throws AuthException {
+    if (!user.getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+    return new MetricJson(
+        resource.getMetric(),
+        metrics.getAnnotations(resource.getName()),
+        dataOnly);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
new file mode 100644
index 0000000..e3f9e1c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+
+/** Optimized version of {@link BucketedHistogram} for single dimension. */
+class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
+  HistogramImpl1(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Histogram1<F1> histogram1() {
+    return new Histogram1<F1>() {
+      @Override
+      public void record(F1 field1, long value) {
+        total.record(value);
+        forceCreate(field1).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
new file mode 100644
index 0000000..d832c60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+
+/** Generalized implementation of N-dimensional Histogram metrics. */
+class HistogramImplN extends BucketedHistogram implements BucketedMetric {
+  HistogramImplN(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Histogram2<F1, F2> histogram2() {
+    return new Histogram2<F1, F2>() {
+      @Override
+      public void record(F1 field1, F2 field2, long value) {
+        total.record(value);
+        forceCreate(field1, field2).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Histogram3<F1, F2, F3> histogram3() {
+    return new Histogram3<F1, F2, F3>() {
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3, long value) {
+        total.record(value);
+        forceCreate(field1, field2, field3).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
new file mode 100644
index 0000000..04d10a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+
+import com.codahale.metrics.Metric;
+
+import org.kohsuke.args4j.Option;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+class ListMetrics implements RestReadView<ConfigResource> {
+  private final CurrentUser user;
+  private final DropWizardMetricMaker metrics;
+
+  @Option(name = "--data-only", usage = "return only values")
+  boolean dataOnly;
+
+  @Option(name = "--prefix", aliases = {"-p"}, metaVar = "PREFIX",
+      usage = "match metric by exact match or prefix")
+  List<String> query = new ArrayList<>();
+
+  @Inject
+  ListMetrics(CurrentUser user, DropWizardMetricMaker metrics) {
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public Map<String, MetricJson> apply(ConfigResource resource)
+      throws AuthException {
+    if (!user.getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+
+    SortedMap<String, MetricJson> out = new TreeMap<>();
+    List<String> prefixes = new ArrayList<>(query.size());
+    for (String q : query) {
+      if (q.endsWith("/")) {
+        prefixes.add(q);
+      } else {
+        Metric m = metrics.getMetric(q);
+        if (m != null) {
+          out.put(q, toJson(q, m));
+        }
+      }
+    }
+
+    if (query.isEmpty() || !prefixes.isEmpty()) {
+      for (String name : metrics.getMetricNames()) {
+        if (include(prefixes, name)) {
+          out.put(name, toJson(name, metrics.getMetric(name)));
+        }
+      }
+    }
+
+    return out;
+  }
+
+  private MetricJson toJson(String q, Metric m) {
+    return new MetricJson(m, metrics.getAnnotations(q), dataOnly);
+  }
+
+  private static boolean include(List<String> prefixes, String name) {
+    if (prefixes.isEmpty()) {
+      return true;
+    }
+    for (String p : prefixes) {
+      if (name.startsWith(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
new file mode 100644
index 0000000..b332262
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -0,0 +1,210 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+class MetricJson {
+  String description;
+  String unit;
+  Boolean constant;
+  Boolean rate;
+  Boolean gauge;
+  Boolean cumulative;
+
+  Long count;
+  Object value;
+
+  Double rate_1m;
+  Double rate_5m;
+  Double rate_15m;
+  Double rate_mean;
+
+  Double p50;
+  Double p75;
+  Double p95;
+  Double p98;
+  Double p99;
+  Double p99_9;
+
+  Double min;
+  Double avg;
+  Double max;
+  Double sum;
+  Double std_dev;
+
+  List<FieldJson> fields;
+  Map<String, Object> buckets;
+
+  MetricJson(Metric metric, ImmutableMap<String, String> atts, boolean dataOnly) {
+    if (!dataOnly) {
+      description = atts.get(Description.DESCRIPTION);
+      unit = atts.get(Description.UNIT);
+      constant = toBool(atts, Description.CONSTANT);
+      rate = toBool(atts, Description.RATE);
+      gauge = toBool(atts, Description.GAUGE);
+      cumulative = toBool(atts, Description.CUMULATIVE);
+    }
+    init(metric, atts);
+  }
+
+  private void init(Metric metric, ImmutableMap<String, String> atts) {
+    if (metric instanceof BucketedMetric) {
+      BucketedMetric m = (BucketedMetric) metric;
+      if (m.getTotal() != null) {
+        init(m.getTotal(), atts);
+      }
+
+      Field<?>[] fieldList = m.getFields();
+      fields = new ArrayList<>(fieldList.length);
+      for (Field<?> f : fieldList) {
+        fields.add(new FieldJson(f));
+      }
+      buckets = makeBuckets(fieldList, m.getCells(), atts);
+
+    } else if (metric instanceof Counter) {
+      Counter c = (Counter) metric;
+      count = c.getCount();
+
+    } else if (metric instanceof Gauge) {
+      Gauge<?> g = (Gauge<?>) metric;
+      value = g.getValue();
+
+    } else if (metric instanceof Meter) {
+      Meter m = (Meter) metric;
+      count = m.getCount();
+      rate_1m = m.getOneMinuteRate();
+      rate_5m = m.getFiveMinuteRate();
+      rate_15m = m.getFifteenMinuteRate();
+
+    } else if (metric instanceof Timer) {
+      Timer m = (Timer) metric;
+      Snapshot s = m.getSnapshot();
+      count = m.getCount();
+      rate_1m = m.getOneMinuteRate();
+      rate_5m = m.getFiveMinuteRate();
+      rate_15m = m.getFifteenMinuteRate();
+
+      double div =
+          Description.getTimeUnit(atts.get(Description.UNIT)).toNanos(1);
+      p50 = s.getMedian() / div;
+      p75 = s.get75thPercentile() / div;
+      p95 = s.get95thPercentile() / div;
+      p98 = s.get98thPercentile() / div;
+      p99 = s.get99thPercentile() / div;
+      p99_9 = s.get999thPercentile() / div;
+
+      min = s.getMin() / div;
+      max = s.getMax() / div;
+      std_dev = s.getStdDev() / div;
+
+    } else if (metric instanceof Histogram) {
+      Histogram m = (Histogram) metric;
+      Snapshot s = m.getSnapshot();
+      count = m.getCount();
+
+      p50 = s.getMedian();
+      p75 = s.get75thPercentile();
+      p95 = s.get95thPercentile();
+      p98 = s.get98thPercentile();
+      p99 = s.get99thPercentile();
+      p99_9 = s.get999thPercentile();
+
+      min = (double) s.getMin();
+      avg = (double) s.getMean();
+      max = (double) s.getMax();
+      sum = s.getMean() * m.getCount();
+      std_dev = s.getStdDev();
+    }
+  }
+
+  private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
+    return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> makeBuckets(
+      Field<?>[] fields,
+      Map<?, Metric> metrics,
+      ImmutableMap<String, String> atts) {
+    if (fields.length == 1) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[0].formatter();
+      Map<String, Object> out = new TreeMap<>();
+      for (Map.Entry<?, Metric> e : metrics.entrySet()) {
+        out.put(
+            fmt.apply(e.getKey()),
+            new MetricJson(e.getValue(), atts, true));
+      }
+      return out;
+    }
+
+    Map<String, Object> out = new TreeMap<>();
+    for (Map.Entry<?, Metric> e : metrics.entrySet()) {
+      ImmutableList<Object> keys = (ImmutableList<Object>) e.getKey();
+      Map<String, Object> dst = out;
+
+      for (int i = 0; i < fields.length - 1; i++) {
+        Function<Object, String> fmt =
+            (Function<Object, String>) fields[i].formatter();
+        String key = fmt.apply(keys.get(i));
+        Map<String, Object> t = (Map<String, Object>) dst.get(key);
+        if (t == null) {
+          t = new TreeMap<>();
+          dst.put(key, t);
+        }
+        dst = t;
+      }
+
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[fields.length - 1].formatter();
+      dst.put(
+          fmt.apply(keys.get(fields.length - 1)),
+          new MetricJson(e.getValue(), atts, true));
+    }
+    return out;
+  }
+
+  static class FieldJson {
+    String name;
+    String type;
+    String description;
+
+    FieldJson(Field<?> field) {
+      this.name = field.getName();
+      this.description = field.getDescription();
+      this.type = Enum.class.isAssignableFrom(field.getType())
+          ? field.getType().getSimpleName()
+          : null;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
new file mode 100644
index 0000000..d073f37
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.TypeLiteral;
+
+import com.codahale.metrics.Metric;
+
+class MetricResource extends ConfigResource {
+  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
+      new TypeLiteral<RestView<MetricResource>>() {};
+
+  private final String name;
+  private final Metric metric;
+
+  MetricResource(String name, Metric metric) {
+    this.name = name;
+    this.metric = metric;
+  }
+
+  String getName() {
+    return name;
+  }
+
+  Metric getMetric() {
+    return metric;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
new file mode 100644
index 0000000..81945f1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import com.codahale.metrics.Metric;
+
+@Singleton
+class MetricsCollection implements
+    ChildCollection<ConfigResource, MetricResource> {
+  private final DynamicMap<RestView<MetricResource>> views;
+  private final Provider<ListMetrics> list;
+  private final Provider<CurrentUser> user;
+  private final DropWizardMetricMaker metrics;
+
+  @Inject
+  MetricsCollection(DynamicMap<RestView<MetricResource>> views,
+      Provider<ListMetrics> list, Provider<CurrentUser> user,
+      DropWizardMetricMaker metrics) {
+    this.views = views;
+    this.list = list;
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public DynamicMap<RestView<MetricResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public MetricResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException, AuthException {
+    if (!user.get().getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+
+    Metric metric = metrics.getMetric(id.get());
+    if (metric == null) {
+      throw new ResourceNotFoundException(id.get());
+    }
+    return new MetricResource(id.get(), metric);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
new file mode 100644
index 0000000..0164f6f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Timer1;
+
+import java.util.concurrent.TimeUnit;
+
+/** Optimized version of {@link BucketedTimer} for single dimension. */
+class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
+  TimerImpl1(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Timer1<F1> timer() {
+    return new Timer1<F1>() {
+      @Override
+      public void record(F1 field1, long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
new file mode 100644
index 0000000..49c9f14
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+
+import java.util.concurrent.TimeUnit;
+
+/** Generalized implementation of N-dimensional timer metrics. */
+class TimerImplN extends BucketedTimer implements BucketedMetric {
+  TimerImplN(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Timer2<F1, F2> timer2() {
+    return new Timer2<F1, F2>() {
+      @Override
+      public void record(F1 field1, F2 field2, long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1, field2).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
+    return new Timer3<F1, F2, F3>() {
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3,
+          long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1, field2, field3).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
new file mode 100644
index 0000000..b5a2fcc8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+
+import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+
+public class JGitMetricModule extends MetricModule {
+  @Override
+  protected void configure(MetricMaker metrics) {
+    metrics.newCallbackMetric(
+      "jgit/block_cache/cache_used",
+      Long.class,
+      new Description("Bytes of memory retained in JGit block cache.")
+        .setGauge()
+        .setUnit(Units.BYTES),
+      new Supplier<Long>() {
+        @Override
+        public Long get() {
+          return WindowCacheStatAccessor.getOpenBytes();
+        }
+      });
+
+    metrics.newCallbackMetric(
+        "jgit/block_cache/open_files",
+        Integer.class,
+        new Description("File handles held open by JGit block cache.")
+          .setGauge()
+          .setUnit("fds"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return WindowCacheStatAccessor.getOpenFiles();
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
new file mode 100644
index 0000000..c556ee4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+
+/** Guice module to configure metrics on server startup. */
+public abstract class MetricModule extends LifecycleModule {
+  /** Configure metrics during server startup. */
+  protected abstract void configure(MetricMaker metrics);
+
+  @Override
+  protected void configure() {
+    listener().toInstance(new LifecycleListener() {
+      @Inject
+      MetricMaker metrics;
+
+      @Override
+      public void start() {
+        configure(metrics);
+      }
+
+      @Override
+      public void stop() {
+      }
+    });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
new file mode 100644
index 0000000..4eccf53
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.OperatingSystemMXBean;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class OperatingSystemMXBeanProvider {
+  private static final Logger log =
+      LoggerFactory.getLogger(OperatingSystemMXBeanProvider.class);
+
+  private final OperatingSystemMXBean sys;
+  private final Method getProcessCpuTime;
+  private final Method getOpenFileDescriptorCount;
+
+  static class Factory {
+    static OperatingSystemMXBeanProvider create() {
+      OperatingSystemMXBean sys = ManagementFactory.getOperatingSystemMXBean();
+      for (String name :
+          Arrays.asList(
+              "com.sun.management.UnixOperatingSystemMXBean",
+              "com.ibm.lang.management.UnixOperatingSystemMXBean")) {
+        try {
+          Class<?> impl = Class.forName(name);
+          if (impl.isInstance(sys)) {
+            return new OperatingSystemMXBeanProvider(sys);
+          }
+        } catch (ReflectiveOperationException e) {
+          log.debug(String.format(
+              "No implementation for %s: %s", name, e.getMessage()));
+        }
+      }
+      log.warn("No implementation of UnixOperatingSystemMXBean found");
+      return null;
+    }
+  }
+
+  private OperatingSystemMXBeanProvider(OperatingSystemMXBean sys)
+      throws ReflectiveOperationException {
+    this.sys = sys;
+    getProcessCpuTime =
+        sys.getClass().getMethod("getProcessCpuTime", new Class[] {});
+    getProcessCpuTime.setAccessible(true);
+    getOpenFileDescriptorCount =
+        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class[] {});
+    getOpenFileDescriptorCount.setAccessible(true);
+  }
+
+  public long getProcessCpuTime() {
+    try {
+      return (long) getProcessCpuTime.invoke(sys, new Object[] {});
+    } catch (ReflectiveOperationException e) {
+      return -1;
+    }
+  }
+
+  public long getOpenFileDescriptorCount() {
+    try {
+      return (long) getOpenFileDescriptorCount.invoke(sys, new Object[] {});
+    } catch (ReflectiveOperationException e) {
+      return -1;
+    }
+  }
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
new file mode 100644
index 0000000..e05afd1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -0,0 +1,224 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import java.lang.management.GarbageCollectorMXBean;
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryMXBean;
+import java.lang.management.MemoryUsage;
+import java.lang.management.ThreadMXBean;
+import java.util.concurrent.TimeUnit;
+
+public class ProcMetricModule extends MetricModule {
+  @Override
+  protected void configure(MetricMaker metrics) {
+    buildLabel(metrics);
+    procUptime(metrics);
+    procCpuUsage(metrics);
+    procJvmGc(metrics);
+    procJvmMemory(metrics);
+    procJvmThread(metrics);
+  }
+
+  private void buildLabel(MetricMaker metrics) {
+    metrics.newConstantMetric(
+        "build/label",
+        Strings.nullToEmpty(Version.getVersion()),
+        new Description("Version of Gerrit server software"));
+  }
+
+  private void procUptime(MetricMaker metrics) {
+    metrics.newConstantMetric(
+        "proc/birth_timestamp",
+        Long.valueOf(TimeUnit.MILLISECONDS.toMicros(
+            System.currentTimeMillis())),
+        new Description("Time at which the process started")
+          .setUnit(Units.MICROSECONDS));
+
+    metrics.newCallbackMetric(
+        "proc/uptime",
+        Long.class,
+        new Description("Uptime of this process")
+          .setUnit(Units.MILLISECONDS),
+        new Supplier<Long>() {
+          @Override
+          public Long get() {
+            return ManagementFactory.getRuntimeMXBean().getUptime();
+          }
+        });
+  }
+
+  private void procCpuUsage(MetricMaker metrics) {
+    final OperatingSystemMXBeanProvider provider =
+        OperatingSystemMXBeanProvider.Factory.create();
+
+    if (provider == null) {
+      return;
+    }
+
+    if (provider.getProcessCpuTime() != -1) {
+      metrics.newCallbackMetric(
+          "proc/cpu/usage",
+          Double.class,
+          new Description("CPU time used by the process")
+            .setCumulative()
+            .setUnit(Units.SECONDS),
+          new Supplier<Double>() {
+            @Override
+            public Double get() {
+              return provider.getProcessCpuTime() / 1e9;
+            }
+          });
+    }
+
+    if (provider.getOpenFileDescriptorCount() != -1) {
+      metrics.newCallbackMetric(
+          "proc/num_open_fds",
+          Long.class,
+          new Description("Number of open file descriptors")
+            .setGauge()
+            .setUnit("fds"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return provider.getOpenFileDescriptorCount();
+            }
+          });
+    }
+  }
+
+  private void procJvmMemory(MetricMaker metrics) {
+    final CallbackMetric0<Long> heapCommitted = metrics.newCallbackMetric(
+        "proc/jvm/memory/heap_committed",
+        Long.class,
+        new Description("Amount of memory guaranteed for user objects.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> heapUsed = metrics.newCallbackMetric(
+        "proc/jvm/memory/heap_used",
+        Long.class,
+        new Description("Amount of memory holding user objects.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> nonHeapCommitted = metrics.newCallbackMetric(
+        "proc/jvm/memory/non_heap_committed",
+        Long.class,
+        new Description("Amount of memory guaranteed for classes, etc.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> nonHeapUsed = metrics.newCallbackMetric(
+        "proc/jvm/memory/non_heap_used",
+        Long.class,
+        new Description("Amount of memory holding classes, etc.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Integer> objectPendingFinalizationCount =
+        metrics.newCallbackMetric(
+        "proc/jvm/memory/object_pending_finalization_count",
+        Integer.class,
+        new Description("Approximate number of objects needing finalization.")
+          .setGauge()
+          .setUnit("objects"));
+
+    final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
+    metrics.newTrigger(
+      ImmutableSet.<CallbackMetric<?>> of(
+          heapCommitted, heapUsed, nonHeapCommitted,
+          nonHeapUsed, objectPendingFinalizationCount),
+      new Runnable() {
+        @Override
+        public void run() {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
+          }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(
+              memory.getObjectPendingFinalizationCount());
+        }
+      });
+  }
+
+  private void procJvmGc(MetricMaker metrics) {
+    final CallbackMetric1<String, Long> gcCount = metrics.newCallbackMetric(
+        "proc/jvm/gc/count",
+        Long.class,
+        new Description("Number of GCs").setCumulative(),
+        Field.ofString("gc_name", "The name of the garbage collector"));
+
+    final CallbackMetric1<String, Long> gcTime = metrics.newCallbackMetric(
+        "proc/jvm/gc/time",
+        Long.class,
+        new Description("Approximate accumulated GC elapsed time")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        Field.ofString("gc_name", "The name of the garbage collector"));
+
+    metrics.newTrigger(gcCount, gcTime, new Runnable() {
+      @Override
+      public void run() {
+        for (GarbageCollectorMXBean gc : ManagementFactory
+            .getGarbageCollectorMXBeans()) {
+          long count = gc.getCollectionCount();
+          if (count != -1) {
+            gcCount.set(gc.getName(), count);
+          }
+          long time = gc.getCollectionTime();
+          if (time != -1) {
+            gcTime.set(gc.getName(), time);
+          }
+        }
+      }
+    });
+  }
+
+  private void procJvmThread(MetricMaker metrics) {
+    final ThreadMXBean thread = ManagementFactory.getThreadMXBean();
+    metrics.newCallbackMetric(
+        "proc/jvm/thread/num_live",
+        Integer.class,
+        new Description("Current live thread count")
+          .setGauge()
+          .setUnit("threads"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return thread.getThreadCount();
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
index 4bd9423..9d32e38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
@@ -30,5 +30,5 @@
 @ExtensionPoint
 public interface PredicateProvider {
   /** Return set of packages that contain Prolog predicates */
-  public ImmutableSet<String> getPackages();
+  ImmutableSet<String> getPackages();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
index 26c0fd0..de54a0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
@@ -54,7 +54,7 @@
   private static final Logger log =
       LoggerFactory.getLogger(PrologEnvironment.class);
 
-  public static interface Factory {
+  public interface Factory {
     /**
      * Construct a new Prolog interpreter.
      *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
index 5dd26f9..068b70d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -61,6 +60,7 @@
 import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -75,20 +75,9 @@
  */
 @Singleton
 public class RulesCache {
-  /** Maximum size of a dynamic Prolog script, in bytes. */
-  private static final int SRC_LIMIT = 128 * 1024;
-
-  /** Default size of the internal Prolog database within each interpreter. */
-  private static final int DB_MAX = 256;
-
   private static final List<String> PACKAGE_LIST = ImmutableList.of(
       Prolog.BUILTIN, "gerrit");
 
-  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
-
-  private final ReferenceQueue<PrologMachineCopy> dead =
-      new ReferenceQueue<>();
-
   private static final class MachineRef extends WeakReference<PrologMachineCopy> {
     final ObjectId key;
 
@@ -100,17 +89,25 @@
   }
 
   private final boolean enableProjectRules;
+  private final int maxDbSize;
+  private final int maxSrcBytes;
   private final Path cacheDir;
   private final Path rulesDir;
   private final GitRepositoryManager gitMgr;
   private final DynamicSet<PredicateProvider> predicateProviders;
   private final ClassLoader systemLoader;
   private final PrologMachineCopy defaultMachine;
+  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
+  private final ReferenceQueue<PrologMachineCopy> dead =
+      new ReferenceQueue<>();
 
   @Inject
   protected RulesCache(@GerritServerConfig Config config, SitePaths site,
       GitRepositoryManager gm, DynamicSet<PredicateProvider> predicateProviders) {
-    enableProjectRules = config.getBoolean("rules", null, "enable", true);
+    maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
+    maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
+    enableProjectRules = config.getBoolean("rules", null, "enable", true)
+        && maxSrcBytes > 0;
     cacheDir = site.resolve(config.getString("cache", null, "directory"));
     rulesDir = cacheDir != null ? cacheDir.resolve("rules") : null;
     gitMgr = gm;
@@ -267,7 +264,7 @@
     try (Repository git = gitMgr.openRepository(project)) {
       try {
         ObjectLoader ldr = git.open(rulesId, Constants.OBJ_BLOB);
-        byte[] raw = ldr.getCachedBytes(SRC_LIMIT);
+        byte[] raw = ldr.getCachedBytes(maxSrcBytes);
         return RawParseUtils.decode(raw);
       } catch (LargeObjectException e) {
         throw new CompileException("rules of " + project + " are too large", e);
@@ -281,12 +278,12 @@
 
   private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
     BufferingPrologControl ctl = new BufferingPrologControl();
-    ctl.setMaxDatabaseSize(DB_MAX);
+    ctl.setMaxDatabaseSize(maxDbSize);
     ctl.setPrologClassLoader(new PrologClassLoader(new PredicateClassLoader(
         predicateProviders, cl)));
     ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
 
-    List<String> packages = Lists.newArrayList();
+    List<String> packages = new ArrayList<>();
     packages.addAll(PACKAGE_LIST);
     for (PredicateProvider predicateProvider : predicateProviders) {
       packages.addAll(predicateProvider.getPackages());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
index 27f15e6..206e840 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
@@ -93,4 +93,4 @@
   protected T createValue(Prolog engine) {
     return null;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 44c1f01..c493ccd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.rules.StoredValue.create;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -45,6 +44,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.Map;
 
 public final class StoredValues {
@@ -85,7 +85,7 @@
       PatchSetInfoFactory patchInfoFactory =
               env.getArgs().getPatchSetInfoFactory();
       try {
-        return patchInfoFactory.get(change, ps);
+        return patchInfoFactory.get(change.getProject(), ps);
       } catch (PatchSetInfoNotAvailableException e) {
         throw new SystemException(e.getMessage());
       }
@@ -100,10 +100,9 @@
       PatchListCache plCache = env.getArgs().getPatchListCache();
       Change change = getChange(engine);
       Project.NameKey project = change.getProject();
-      ObjectId a = null;
       ObjectId b = ObjectId.fromString(ps.getRevision().get());
       Whitespace ws = Whitespace.IGNORE_NONE;
-      PatchListKey plKey = new PatchListKey(a, b, ws);
+      PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
       PatchList patchList;
       try {
         patchList = plCache.get(plKey, project);
@@ -150,7 +149,7 @@
       new StoredValue<Map<Account.Id, IdentifiedUser>>() {
         @Override
         protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
-          return Maps.newHashMap();
+          return new HashMap<>();
         }
       };
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
index ff09df5..36888e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
@@ -14,17 +14,13 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 
-import java.util.Collection;
 import java.util.Collections;
-import java.util.Set;
 
 /** An anonymous user who has not yet authenticated. */
 public class AnonymousUser extends CurrentUser {
@@ -39,16 +35,6 @@
   }
 
   @Override
-  public Set<Change.Id> getStarredChanges() {
-    return Collections.emptySet();
-  }
-
-  @Override
-  public Collection<AccountProjectWatch> getNotificationFilters() {
-    return Collections.emptySet();
-  }
-
-  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index 1d1f571..bc2ec06 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -16,20 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.change.ChangeKind.NO_CHANGE;
-import static com.google.gerrit.server.change.ChangeKind.NO_CODE_CHANGE;
-import static com.google.gerrit.server.change.ChangeKind.TRIVIAL_REBASE;
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.ChangeKind;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
@@ -48,9 +44,6 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
-import java.util.NavigableSet;
-import java.util.Objects;
-import java.util.SortedSet;
 import java.util.TreeMap;
 
 /**
@@ -58,7 +51,7 @@
  * <p>
  * The result of a copy may either be stored, as when stamping approvals in the
  * database at submit time, or refreshed on demand, as when reading approvals
- * from the notedb.
+ * from the NoteDb.
  */
 @Singleton
 public class ApprovalCopier {
@@ -67,18 +60,21 @@
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
   private final ChangeData.Factory changeDataFactory;
+  private final PatchSetUtil psUtil;
 
   @Inject
   ApprovalCopier(GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
-      ChangeData.Factory changeDataFactory) {
+      ChangeData.Factory changeDataFactory,
+      PatchSetUtil psUtil) {
     this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
     this.changeDataFactory = changeDataFactory;
+    this.psUtil = psUtil;
   }
 
   public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps)
@@ -88,7 +84,7 @@
 
   Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
       ChangeControl ctl, PatchSet.Id psId) throws OrmException {
-    PatchSet ps = db.patchSets().get(psId);
+    PatchSet ps = psUtil.get(db, ctl.getNotes(), psId);
     if (ps == null) {
       return Collections.emptyList();
     }
@@ -105,6 +101,8 @@
       ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
       checkNotNull(all, "all should not be null");
 
+      Table<String, Account.Id, PatchSetApproval> wontCopy =
+          HashBasedTable.create();
       Table<String, Account.Id, PatchSetApproval> byUser =
           HashBasedTable.create();
       for (PatchSetApproval psa : all.get(ps.getId())) {
@@ -112,7 +110,6 @@
       }
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
-      NavigableSet<Integer> allPsIds = patchSets.navigableKeySet();
 
       try (Repository repo =
           repoManager.openRepository(project.getProject().getNameKey())) {
@@ -131,11 +128,18 @@
               ObjectId.fromString(ps.getRevision().get()));
 
           for (PatchSetApproval psa : priorApprovals) {
-            if (!byUser.contains(psa.getLabel(), psa.getAccountId())
-                && canCopy(project, psa, ps.getId(), allPsIds, kind)) {
-              byUser.put(psa.getLabel(), psa.getAccountId(),
-                  copy(psa, ps.getId()));
+            if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+              continue;
             }
+            if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
+              continue;
+            }
+            if (!canCopy(project, psa, ps.getId(), kind)) {
+              wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+              continue;
+            }
+            byUser.put(psa.getLabel(), psa.getAccountId(),
+                copy(psa, ps.getId()));
           }
         }
         return labelNormalizer.normalize(ctl, byUser.values()).getNormalized();
@@ -148,7 +152,7 @@
   private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd)
       throws OrmException {
     Collection<PatchSet> patchSets = cd.patchSets();
-    TreeMap<Integer, PatchSet> result = Maps.newTreeMap();
+    TreeMap<Integer, PatchSet> result = new TreeMap<>();
     for (PatchSet ps : patchSets) {
       result.put(ps.getId().get(), ps);
     }
@@ -156,22 +160,33 @@
   }
 
   private static boolean canCopy(ProjectState project, PatchSetApproval psa,
-      PatchSet.Id psId, NavigableSet<Integer> allPsIds, ChangeKind kind) {
+      PatchSet.Id psId, ChangeKind kind) {
     int n = psa.getKey().getParentKey().get();
     checkArgument(n != psId.get());
     LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
     if (type == null) {
       return false;
-    } else if (Objects.equals(n, previous(allPsIds, psId.get())) && (
-        type.isCopyMinScore() && type.isMaxNegative(psa)
-        || type.isCopyMaxScore() && type.isMaxPositive(psa))) {
-      // Copy min/max score only from the immediately preceding patch set (which
-      // may not be psId.get() - 1).
+    } else if (
+        (type.isCopyMinScore() && type.isMaxNegative(psa))
+        || (type.isCopyMaxScore() && type.isMaxPositive(psa))) {
       return true;
     }
-    return (type.isCopyAllScoresOnTrivialRebase() && kind == TRIVIAL_REBASE)
-        || (type.isCopyAllScoresIfNoCodeChange() && kind == NO_CODE_CHANGE)
-        || (type.isCopyAllScoresIfNoChange() && kind == NO_CHANGE);
+    switch (kind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+        return type.isCopyAllScoresOnMergeFirstParentUpdate();
+      case NO_CODE_CHANGE:
+        return type.isCopyAllScoresIfNoCodeChange();
+      case TRIVIAL_REBASE:
+        return type.isCopyAllScoresOnTrivialRebase();
+      case NO_CHANGE:
+        return type.isCopyAllScoresIfNoChange()
+            || type.isCopyAllScoresOnTrivialRebase()
+            || type.isCopyAllScoresOnMergeFirstParentUpdate()
+            || type.isCopyAllScoresIfNoCodeChange();
+      case REWORK:
+      default:
+        return false;
+    }
   }
 
   private static PatchSetApproval copy(PatchSetApproval src, PatchSet.Id psId) {
@@ -180,9 +195,4 @@
     }
     return new PatchSetApproval(psId, src);
   }
-
-  private static <T> T previous(NavigableSet<T> s, T v) {
-    SortedSet<T> head = s.headSet(v);
-    return !head.isEmpty() ? head.last() : null;
-  }
-}
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 31058bc..e0526e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -14,22 +14,19 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
-import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
@@ -44,8 +41,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -54,6 +52,8 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -73,13 +73,15 @@
  */
 @Singleton
 public class ApprovalsUtil {
-  private static Ordering<PatchSetApproval> SORT_APPROVALS = Ordering.natural()
-      .onResultOf(new Function<PatchSetApproval, Timestamp>() {
-        @Override
-        public Timestamp apply(PatchSetApproval a) {
-          return a.getGranted();
-        }
-      });
+  private static final Ordering<PatchSetApproval> SORT_APPROVALS =
+      Ordering.natural()
+          .onResultOf(
+              new Function<PatchSetApproval, Timestamp>() {
+                @Override
+                public Timestamp apply(PatchSetApproval a) {
+                  return a.getGranted();
+                }
+              });
 
   public static List<PatchSetApproval> sortApprovals(
       Iterable<PatchSetApproval> approvals) {
@@ -112,60 +114,49 @@
    *
    * @param db review database.
    * @param notes change notes.
-   * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerState#REMOVED} is not present.
+   * @return reviewers for the change.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
-      ReviewDb db, ChangeNotes notes) throws OrmException {
+  public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
     if (!migration.readChanges()) {
-      return getReviewers(db.patchSetApprovals().byChange(notes.getChangeId()));
+      return ReviewerSet.fromApprovals(
+          db.patchSetApprovals().byChange(notes.getChangeId()));
     }
     return notes.load().getReviewers();
   }
 
   /**
-   * Get all reviewers for a change.
+   * Get all reviewers and CCed accounts for a change.
    *
    * @param allApprovals all approvals to consider; must all belong to the same
    *     change.
-   * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerState#REMOVED} is not present.
+   * @return reviewers for the change.
+   * @throws OrmException if reviewers for the change could not be read.
    */
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
-      ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
+  public ReviewerSet getReviewers(ChangeNotes notes,
+      Iterable<PatchSetApproval> allApprovals)
       throws OrmException {
     if (!migration.readChanges()) {
-      return getReviewers(allApprovals);
+      return ReviewerSet.fromApprovals(allApprovals);
     }
     return notes.load().getReviewers();
   }
 
-  private static ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
-      Iterable<PatchSetApproval> allApprovals) {
-    PatchSetApproval first = null;
-    SetMultimap<ReviewerState, Account.Id> reviewers =
-        LinkedHashMultimap.create();
-    for (PatchSetApproval psa : allApprovals) {
-      if (first == null) {
-        first = psa;
-      } else {
-        checkArgument(
-            first.getKey().getParentKey().getParentKey().equals(
-              psa.getKey().getParentKey().getParentKey()),
-            "multiple change IDs: %s, %s", first.getKey(), psa.getKey());
-      }
-      Account.Id id = psa.getAccountId();
-      if (psa.getValue() != 0) {
-        reviewers.put(ReviewerState.REVIEWER, id);
-        reviewers.remove(ReviewerState.CC, id);
-      } else if (!reviewers.containsEntry(ReviewerState.REVIEWER, id)) {
-        reviewers.put(ReviewerState.CC, id);
-      }
+  /**
+   * Get updates to reviewer set.
+   * Always returns empty list for ReviewDb.
+   *
+   * @param notes change notes.
+   * @return reviewer updates for the change.
+   * @throws OrmException if reviewer updates for the change could not be read.
+   */
+  public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
     }
-    return ImmutableSetMultimap.copyOf(reviewers);
+    return notes.load().getReviewerUpdates();
   }
 
   public List<PatchSetApproval> addReviewers(ReviewDb db,
@@ -181,8 +172,25 @@
       ChangeUpdate update, LabelTypes labelTypes, Change change,
       Iterable<Account.Id> wantReviewers) throws OrmException {
     PatchSet.Id psId = change.currentPatchSetId();
+    Collection<Account.Id> existingReviewers;
+    if (migration.readChanges()) {
+      // If using NoteDB, we only want reviewers in the REVIEWER state.
+      existingReviewers = notes.load().getReviewers().byState(REVIEWER);
+    } else {
+      // Prior to NoteDB, we gather all reviewers regardless of state.
+      existingReviewers = getReviewers(db, notes).all();
+    }
+    // Existing reviewers should include pending additions in the REVIEWER
+    // state, taken from ChangeUpdate.
+    existingReviewers = Lists.newArrayList(existingReviewers);
+    for (Map.Entry<Account.Id, ReviewerStateInternal> entry :
+        update.getReviewers().entrySet()) {
+      if (entry.getValue() == REVIEWER) {
+        existingReviewers.add(entry.getKey());
+      }
+    }
     return addReviewers(db, update, labelTypes, change, psId, false, null, null,
-        wantReviewers, getReviewers(db, notes).values());
+        wantReviewers, existingReviewers);
   }
 
   private List<PatchSetApproval> addReviewers(ReviewDb db, ChangeUpdate update,
@@ -214,20 +222,45 @@
     for (Account.Id account : need) {
       cells.add(new PatchSetApproval(
           new PatchSetApproval.Key(psId, account, labelId),
-          (short) 0, TimeUtil.nowTs()));
-      update.putReviewer(account, ReviewerState.REVIEWER);
+          (short) 0, update.getWhen()));
+      update.putReviewer(account, REVIEWER);
     }
     db.patchSetApprovals().insert(cells);
     return Collections.unmodifiableList(cells);
   }
 
+  /**
+   * Adds accounts to a change as reviewers in the CC state.
+   *
+   * @param notes change notes.
+   * @param update change update.
+   * @param wantCCs accounts to CC.
+   * @return whether a change was made.
+   * @throws OrmException
+   */
+  public Collection<Account.Id> addCcs(ChangeNotes notes, ChangeUpdate update,
+      Collection<Account.Id> wantCCs) throws OrmException {
+    return addCcs(update, wantCCs, notes.load().getReviewers());
+  }
+
+  private Collection<Account.Id> addCcs(ChangeUpdate update,
+      Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
+    Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
+    need.removeAll(existingReviewers.all());
+    need.removeAll(update.getReviewers().keySet());
+    for (Account.Id account : need) {
+      update.putReviewer(account, CC);
+    }
+    return need;
+  }
+
   public void addApprovals(ReviewDb db, ChangeUpdate update,
       LabelTypes labelTypes, PatchSet ps, ChangeControl changeCtl,
       Map<String, Short> approvals) throws OrmException {
     if (!approvals.isEmpty()) {
       checkApprovals(approvals, changeCtl);
       List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-      Timestamp ts = TimeUtil.nowTs();
+      Date ts = update.getWhen();
       for (Map.Entry<String, Short> vote : approvals.entrySet()) {
         LabelType lt = labelTypes.byLabel(vote.getKey());
         cells.add(new PatchSetApproval(new PatchSetApproval.Key(
@@ -319,7 +352,7 @@
     }
     PatchSetApproval submitter = null;
     for (PatchSetApproval a : approvals) {
-      if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isSubmit()) {
+      if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isLegacySubmit()) {
         if (submitter == null
             || a.getGranted().compareTo(submitter.getGranted()) > 0) {
           submitter = a;
@@ -328,4 +361,25 @@
     }
     return submitter;
   }
+
+  public static String renderMessageWithApprovals(int patchSetId,
+      Map<String, Short> n, Map<String, PatchSetApproval> c) {
+    StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
+    if (!n.isEmpty()) {
+      boolean first = true;
+      for (Map.Entry<String, Short> e : n.entrySet()) {
+        if (c.containsKey(e.getKey())
+            && c.get(e.getKey()).getValue() == e.getValue()) {
+          continue;
+        }
+        if (first) {
+          msgs.append(":");
+          first = false;
+        }
+        msgs.append(" ")
+            .append(LabelVote.create(e.getKey(), e.getValue()).format());
+      }
+    }
+    return msgs.toString();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
new file mode 100644
index 0000000..bc6f732
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Optional;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@Singleton
+public class ChangeFinder {
+  private final Provider<InternalChangeQuery> queryProvider;
+
+  @Inject
+  ChangeFinder(Provider<InternalChangeQuery> queryProvider) {
+    this.queryProvider = queryProvider;
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier, either a numeric ID, a Change-Id, or
+   *     project~branch~id triplet.
+   * @param user user to wrap in controls.
+   * @return possibly-empty list of controls for all matching changes,
+   *     corresponding to the given user; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database.
+   */
+  public List<ChangeControl> find(String id, CurrentUser user)
+      throws OrmException {
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+
+    // Try legacy id
+    if (!id.isEmpty() && id.charAt(0) != '0') {
+      Integer n = Ints.tryParse(id);
+      if (n != null) {
+        return asChangeControls(query.byLegacyChangeId(new Change.Id(n)), user);
+      }
+    }
+
+    // Try isolated changeId
+    if (!id.contains("~")) {
+      return asChangeControls(query.byKeyPrefix(id), user);
+    }
+
+    // Try change triplet
+    Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id);
+    if (triplet.isPresent()) {
+      return asChangeControls(query.byBranchKey(
+          triplet.get().branch(),
+          triplet.get().id()),
+          user);
+    }
+
+    return Collections.emptyList();
+  }
+
+  public ChangeControl findOne(Change.Id id, CurrentUser user)
+      throws OrmException, NoSuchChangeException {
+    List<ChangeControl> ctls = find(id, user);
+    if (ctls.size() != 1) {
+      throw new NoSuchChangeException(id);
+    }
+    return ctls.get(0);
+  }
+
+  public List<ChangeControl> find(Change.Id id, CurrentUser user)
+      throws OrmException {
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+    return asChangeControls(query.byLegacyChangeId(id), user);
+  }
+
+  private List<ChangeControl> asChangeControls(List<ChangeData> cds,
+      CurrentUser user) throws OrmException {
+    List<ChangeControl> ctls = new ArrayList<>(cds.size());
+    for (ChangeData cd : cds) {
+      ctls.add(cd.changeControl(user));
+    }
+    return ctls;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index f31a65b..f3fdbcb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -27,6 +29,7 @@
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * Utility functions to manipulate ChangeMessages.
@@ -53,9 +56,8 @@
     if (!migration.readChanges()) {
       return
           sortChangeMessages(db.changeMessages().byChange(notes.getChangeId()));
-    } else {
-      return sortChangeMessages(notes.load().getChangeMessages().values());
     }
+    return notes.load().getChangeMessages();
   }
 
   public Iterable<ChangeMessage> byPatchSet(ReviewDb db, ChangeNotes notes,
@@ -63,12 +65,17 @@
     if (!migration.readChanges()) {
       return db.changeMessages().byPatchSet(psId);
     }
-    return notes.load().getChangeMessages().get(psId);
+    return notes.load().getChangeMessagesByPatchSet().get(psId);
   }
 
   public void addChangeMessage(ReviewDb db, ChangeUpdate update,
       ChangeMessage changeMessage) throws OrmException {
+    checkState(
+        Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
+        "cannot store change message by %s in update by %s",
+        changeMessage.getAuthor(), update.getNullableAccountId());
     update.setChangeMessage(changeMessage.getMessage());
+    update.setTag(changeMessage.getTag());
     db.changeMessages().insert(Collections.singleton(changeMessage));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 6c80d26..11a3d81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -14,67 +14,19 @@
 
 package com.google.gerrit.server;
 
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-
 import com.google.common.base.Function;
-import com.google.common.base.Optional;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Ordering;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeMessages;
-import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.mail.RevertedSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.RefControl;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.IdGenerator;
-import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.util.ChangeIdUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 
 @Singleton
@@ -88,9 +40,6 @@
   private static final String SUBJECT_CROP_APPENDIX = "...";
   private static final int SUBJECT_CROP_RANGE = 10;
 
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeUtil.class);
-
   public static final Function<PatchSet, Integer> TO_PS_ID =
       new Function<PatchSet, Integer>() {
         @Override
@@ -126,29 +75,6 @@
     return u + '_' + l;
   }
 
-  public static void touch(Change change, ReviewDb db)
-      throws OrmException {
-    try {
-      updated(change);
-      db.changes().update(Collections.singleton(change));
-    } catch (OrmConcurrencyException e) {
-      // Ignore a concurrent update, we just wanted to tag it as newer.
-    }
-  }
-
-  public static void bumpRowVersionNotLastUpdatedOn(Change.Id id, ReviewDb db)
-      throws OrmException {
-    // Empty update of Change to bump rowVersion, changing its ETag.
-    Change c = db.changes().get(id);
-    if (c != null) {
-      db.changes().update(Collections.singleton(c));
-    }
-  }
-
-  public static void updated(Change c) {
-    c.setLastUpdatedOn(TimeUtil.nowTs());
-  }
-
   public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs,
       PatchSet.Id id) {
     PatchSet.Id next = nextPatchSetId(id);
@@ -158,6 +84,10 @@
     return next;
   }
 
+  public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
+    return new PatchSet.Id(id.getParentKey(), id.get() + 1);
+  }
+
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id)
       throws IOException {
     return nextPatchSetId(git.getRefDatabase().getRefs(RefDatabase.ALL), id);
@@ -177,301 +107,6 @@
     return subject;
   }
 
-  private final Provider<IdentifiedUser> user;
-  private final Provider<ReviewDb> db;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final RevertedSender.Factory revertedSenderFactory;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final GitRepositoryManager gitManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ChangeIndexer indexer;
-  private final BatchUpdate.Factory updateFactory;
-  private final ChangeMessagesUtil changeMessagesUtil;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-
-  @Inject
-  ChangeUtil(Provider<IdentifiedUser> user,
-      Provider<ReviewDb> db,
-      Provider<InternalChangeQuery> queryProvider,
-      RevertedSender.Factory revertedSenderFactory,
-      ChangeInserter.Factory changeInserterFactory,
-      GitRepositoryManager gitManager,
-      GitReferenceUpdated gitRefUpdated,
-      ChangeIndexer indexer,
-      BatchUpdate.Factory updateFactory,
-      ChangeMessagesUtil changeMessagesUtil,
-      ChangeUpdate.Factory changeUpdateFactory) {
-    this.user = user;
-    this.db = db;
-    this.queryProvider = queryProvider;
-    this.revertedSenderFactory = revertedSenderFactory;
-    this.changeInserterFactory = changeInserterFactory;
-    this.gitManager = gitManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.indexer = indexer;
-    this.updateFactory = updateFactory;
-    this.changeMessagesUtil = changeMessagesUtil;
-    this.changeUpdateFactory = changeUpdateFactory;
-  }
-
-  public Change.Id revert(ChangeControl ctl, PatchSet.Id patchSetId,
-      String message, PersonIdent myIdent)
-      throws NoSuchChangeException, OrmException,
-      MissingObjectException, IncorrectObjectTypeException, IOException,
-      RestApiException, UpdateException {
-    Change.Id changeId = patchSetId.getParentKey();
-    PatchSet patch = db.get().patchSets().get(patchSetId);
-    if (patch == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    Change changeToRevert = db.get().changes().get(changeId);
-
-    Project.NameKey project = ctl.getChange().getProject();
-    try (Repository git = gitManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commitToRevert =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
-
-      PersonIdent authorIdent = user.get()
-          .newCommitterIdent(myIdent.getWhen(), myIdent.getTimeZone());
-
-      if (commitToRevert.getParentCount() == 0) {
-        throw new ResourceConflictException("Cannot revert initial commit");
-      }
-
-      RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
-      revWalk.parseHeaders(parentToCommitToRevert);
-
-      CommitBuilder revertCommitBuilder = new CommitBuilder();
-      revertCommitBuilder.addParentId(commitToRevert);
-      revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-      revertCommitBuilder.setAuthor(authorIdent);
-      revertCommitBuilder.setCommitter(authorIdent);
-
-      if (message == null) {
-        message = MessageFormat.format(
-            ChangeMessages.get().revertChangeDefaultMessage,
-            changeToRevert.getSubject(), patch.getRevision().get());
-      }
-
-      ObjectId computedChangeId =
-          ChangeIdUtil.computeChangeId(parentToCommitToRevert.getTree(),
-              commitToRevert, authorIdent, myIdent, message);
-      revertCommitBuilder.setMessage(
-          ChangeIdUtil.insertId(message, computedChangeId, true));
-
-      RevCommit revertCommit;
-      ChangeInserter ins;
-      try (ObjectInserter oi = git.newObjectInserter()) {
-        ObjectId id = oi.insert(revertCommitBuilder);
-        oi.flush();
-        revertCommit = revWalk.parseCommit(id);
-
-        RefControl refControl = ctl.getRefControl();
-        Change change = new Change(
-            new Change.Key("I" + computedChangeId.name()),
-            new Change.Id(db.get().nextChangeId()),
-            user.get().getAccountId(),
-            changeToRevert.getDest(),
-            TimeUtil.nowTs());
-        change.setTopic(changeToRevert.getTopic());
-        ins = changeInserterFactory.create(
-              refControl, change, revertCommit)
-            .setValidatePolicy(CommitValidators.Policy.GERRIT);
-
-        ChangeMessage changeMessage = new ChangeMessage(
-            new ChangeMessage.Key(
-                patchSetId.getParentKey(), ChangeUtil.messageUUID(db.get())),
-                user.get().getAccountId(), TimeUtil.nowTs(), patchSetId);
-        StringBuilder msgBuf = new StringBuilder();
-        msgBuf.append("Patch Set ").append(patchSetId.get()).append(": Reverted");
-        msgBuf.append("\n\n");
-        msgBuf.append("This patchset was reverted in change: ")
-              .append(change.getKey().get());
-        changeMessage.setMessage(msgBuf.toString());
-        ChangeUpdate update = changeUpdateFactory.create(ctl, TimeUtil.nowTs());
-        changeMessagesUtil.addChangeMessage(db.get(), update, changeMessage);
-        update.commit();
-
-        ins.setMessage("Uploaded patch set 1.");
-        try (BatchUpdate bu = updateFactory.create(
-            db.get(), change.getProject(), refControl.getUser(),
-            change.getCreatedOn())) {
-          bu.setRepository(git, revWalk, oi);
-          bu.insertChange(ins);
-          bu.execute();
-        }
-      }
-
-      Change.Id id = ins.getChange().getId();
-      try {
-        RevertedSender cm = revertedSenderFactory.create(id);
-        cm.setFrom(user.get().getAccountId());
-        cm.setChangeMessage(ins.getChangeMessage());
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot send email for revert change " + id, err);
-      }
-
-      return id;
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-
-  public String getMessage(Change change)
-      throws NoSuchChangeException, OrmException,
-      MissingObjectException, IncorrectObjectTypeException, IOException {
-    Change.Id changeId = change.getId();
-    PatchSet ps = db.get().patchSets().get(change.currentPatchSetId());
-    if (ps == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    try (Repository git = gitManager.openRepository(change.getProject());
-        RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commit = revWalk.parseCommit(
-          ObjectId.fromString(ps.getRevision().get()));
-      return commit.getFullMessage();
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(changeId, e);
-    }
-  }
-
-  public void deleteDraftChange(Change change)
-      throws NoSuchChangeException, OrmException, IOException {
-    Change.Id changeId = change.getId();
-    if (change.getStatus() != Change.Status.DRAFT) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    ReviewDb db = this.db.get();
-    db.changes().beginTransaction(change.getId());
-    try {
-      List<PatchSet> patchSets = db.patchSets().byChange(changeId).toList();
-      for (PatchSet ps : patchSets) {
-        if (!ps.isDraft()) {
-          throw new NoSuchChangeException(changeId);
-        }
-        db.accountPatchReviews().delete(
-            db.accountPatchReviews().byPatchSet(ps.getId()));
-      }
-
-      // No need to delete from notedb; draft patch sets will be filtered out.
-      db.patchComments().delete(db.patchComments().byChange(changeId));
-
-      db.patchSetApprovals().delete(db.patchSetApprovals().byChange(changeId));
-      db.patchSets().delete(patchSets);
-      db.changeMessages().delete(db.changeMessages().byChange(changeId));
-      db.starredChanges().delete(db.starredChanges().byChange(changeId));
-      db.changes().delete(Collections.singleton(change));
-
-      // Delete all refs at once.
-      try (Repository repo = gitManager.openRepository(change.getProject());
-          RevWalk rw = new RevWalk(repo)) {
-        String prefix = new PatchSet.Id(changeId, 1).toRefName();
-        prefix = prefix.substring(0, prefix.length() - 1);
-        BatchRefUpdate ru = repo.getRefDatabase().newBatchUpdate();
-        for (Ref ref : repo.getRefDatabase().getRefs(prefix).values()) {
-          ru.addCommand(
-              new ReceiveCommand(
-                ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-        }
-        ru.execute(rw, NullProgressMonitor.INSTANCE);
-        for (ReceiveCommand cmd : ru.getCommands()) {
-          if (cmd.getResult() != ReceiveCommand.Result.OK) {
-            throw new IOException("failed: " + cmd + ": " + cmd.getResult());
-          }
-        }
-      }
-
-      db.commit();
-      indexer.delete(change.getId());
-    } finally {
-      db.rollback();
-    }
-  }
-
-  public void deleteOnlyDraftPatchSet(PatchSet patch, Change change)
-      throws NoSuchChangeException, OrmException, IOException {
-    PatchSet.Id patchSetId = patch.getId();
-    if (!patch.isDraft()) {
-      throw new NoSuchChangeException(patchSetId.getParentKey());
-    }
-
-    try (Repository repo = gitManager.openRepository(change.getProject())) {
-      RefUpdate update = repo.updateRef(patch.getRefName());
-      update.setForceUpdate(true);
-      update.disableRefLog();
-      switch (update.delete()) {
-        case NEW:
-        case FAST_FORWARD:
-        case FORCED:
-        case NO_CHANGE:
-          // Successful deletion.
-          break;
-        default:
-          throw new IOException("Failed to delete ref " + patch.getRefName() +
-              " in " + repo.getDirectory() + ": " + update.getResult());
-      }
-      gitRefUpdated.fire(change.getProject(), update, ReceiveCommand.Type.DELETE);
-    }
-
-    deleteOnlyDraftPatchSetPreserveRef(this.db.get(), patch);
-  }
-
-  /**
-   * Find changes matching the given identifier.
-   *
-   * @param id change identifier, either a numeric ID, a Change-Id, or
-   *     project~branch~id triplet.
-   * @return all matching changes, even if they are not visible to the current
-   *     user.
-   */
-  public List<Change> findChanges(String id)
-      throws OrmException, ResourceNotFoundException {
-    // Try legacy id
-    if (id.matches("^[1-9][0-9]*$")) {
-      Change c = db.get().changes().get(Change.Id.parse(id));
-      if (c != null) {
-        return ImmutableList.of(c);
-      }
-      return Collections.emptyList();
-    }
-
-    // Try isolated changeId
-    if (!id.contains("~")) {
-      return asChanges(queryProvider.get().byKeyPrefix(id));
-    }
-
-    // Try change triplet
-    Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id);
-    if (triplet.isPresent()) {
-      return asChanges(queryProvider.get().byBranchKey(
-          triplet.get().branch(),
-          triplet.get().id()));
-    }
-
-    throw new ResourceNotFoundException(id);
-  }
-
-  private static void deleteOnlyDraftPatchSetPreserveRef(ReviewDb db,
-      PatchSet patch) throws NoSuchChangeException, OrmException {
-    PatchSet.Id patchSetId = patch.getId();
-    if (!patch.isDraft()) {
-      throw new NoSuchChangeException(patchSetId.getParentKey());
-    }
-
-    db.accountPatchReviews().delete(db.accountPatchReviews().byPatchSet(patchSetId));
-    db.changeMessages().delete(db.changeMessages().byPatchSet(patchSetId));
-    // No need to delete from notedb; draft patch sets will be filtered out.
-    db.patchComments().delete(db.patchComments().byPatchSet(patchSetId));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(patchSetId));
-
-    db.patchSets().delete(Collections.singleton(patch));
-  }
-
-  public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
-    return new PatchSet.Id(id.getParentKey(), id.get() + 1);
+  private ChangeUtil() {
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 6a8600f..34a2d02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.servlet.RequestScoped;
 
-import java.util.Collection;
-import java.util.Set;
-
 /**
  * Information about the currently logged in user.
  * <p>
@@ -33,6 +29,16 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
+  /** Unique key for plugin/extension specific data on a CurrentUser. */
+  public static final class PropertyKey<T> {
+    public static <T> PropertyKey<T> create() {
+      return new PropertyKey<>();
+    }
+
+    private PropertyKey() {
+    }
+  }
+
   private final CapabilityControl.Factory capabilityControlFactory;
   private AccessPath accessPath = AccessPath.UNKNOWN;
 
@@ -78,12 +84,6 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
-  /** Set of changes starred by this user. */
-  public abstract Set<Change.Id> getStarredChanges();
-
-  /** Filters selecting changes the user wants to monitor. */
-  public abstract Collection<AccountProjectWatch> getNotificationFilters();
-
   /** Unique name of the user on this server, if one has been assigned. */
   public String getUserName() {
     return null;
@@ -118,4 +118,24 @@
   public boolean isInternalUser() {
     return false;
   }
+
+  /**
+   * Lookup a previously stored property.
+   *
+   * @param key unique property key.
+   * @return previously stored value, or {@code null}.
+   */
+  @Nullable
+  public <T> T get(PropertyKey<T> key) {
+    return null;
+  }
+
+  /**
+   * Store a property for later retrieval.
+   *
+   * @param key unique property key.
+   * @param value value to store; or {@code null} to clear the value.
+   */
+  public <T> void put(PropertyKey<T> key, @Nullable T value) {
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
index 2768733..168dbf7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
@@ -14,17 +14,10 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.CapabilityControl;
@@ -37,28 +30,22 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DisableReverseDnsLookup;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
-import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.Date;
-import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
 
@@ -96,29 +83,26 @@
       this.disableReverseDnsLookup = disableReverseDnsLookup;
     }
 
-    public IdentifiedUser create(final Account.Id id) {
+    public IdentifiedUser create(AccountState state) {
+      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
+          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
+          disableReverseDnsLookup, Providers.of((SocketAddress) null), state,
+          null);
+    }
+
+    public IdentifiedUser create(Account.Id id) {
       return create((SocketAddress) null, id);
     }
 
-    public IdentifiedUser create(Provider<ReviewDb> db, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, null, db, id, null);
-    }
-
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of(remotePeer), null,
-          id, null);
+      return runAs(remotePeer, id, null);
     }
 
-    public CurrentUser runAs(SocketAddress remotePeer, Account.Id id,
+    public IdentifiedUser runAs(SocketAddress remotePeer, Account.Id id,
         @Nullable CurrentUser caller) {
       return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
           anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of(remotePeer), null,
-          id, caller);
+          disableReverseDnsLookup, Providers.of(remotePeer), id, caller);
     }
   }
 
@@ -138,22 +122,19 @@
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
     private final Boolean disableReverseDnsLookup;
-
     private final Provider<SocketAddress> remotePeerProvider;
-    private final Provider<ReviewDb> dbProvider;
 
     @Inject
     RequestFactory(
         CapabilityControl.Factory capabilityControlFactory,
-        final AuthConfig authConfig,
+        AuthConfig authConfig,
         Realm realm,
-        @AnonymousCowardName final String anonymousCowardName,
-        @CanonicalWebUrl final Provider<String> canonicalUrl,
-        final AccountCache accountCache,
-        final GroupBackend groupBackend,
-        @DisableReverseDnsLookup final Boolean disableReverseDnsLookup,
-        @RemotePeer final Provider<SocketAddress> remotePeerProvider,
-        final Provider<ReviewDb> dbProvider) {
+        @AnonymousCowardName String anonymousCowardName,
+        @CanonicalWebUrl Provider<String> canonicalUrl,
+        AccountCache accountCache,
+        GroupBackend groupBackend,
+        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        @RemotePeer Provider<SocketAddress> remotePeerProvider) {
       this.capabilityControlFactory = capabilityControlFactory;
       this.authConfig = authConfig;
       this.realm = realm;
@@ -163,27 +144,21 @@
       this.groupBackend = groupBackend;
       this.disableReverseDnsLookup = disableReverseDnsLookup;
       this.remotePeerProvider = remotePeerProvider;
-      this.dbProvider = dbProvider;
     }
 
     public IdentifiedUser create(Account.Id id) {
       return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
           anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, dbProvider,
-          id, null);
+          disableReverseDnsLookup, remotePeerProvider, id, null);
     }
 
     public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
       return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
           anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, dbProvider,
-          id, caller);
+          disableReverseDnsLookup, remotePeerProvider, id, caller);
     }
   }
 
-  private static final Logger log =
-      LoggerFactory.getLogger(IdentifiedUser.class);
-
   private static final GroupMembership registeredGroups =
       new ListGroupMembership(ImmutableSet.of(
           SystemGroupBackend.ANONYMOUS_USERS,
@@ -199,35 +174,45 @@
   private final Set<String> validEmails =
       Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
 
-  @Nullable
   private final Provider<SocketAddress> remotePeerProvider;
-
-  @Nullable
-  private final Provider<ReviewDb> dbProvider;
-
   private final Account.Id accountId;
 
   private AccountState state;
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Set<Change.Id> starredChanges;
-  private ResultSet<StarredChange> starredQuery;
-  private Collection<AccountProjectWatch> notificationFilters;
   private CurrentUser realUser;
+  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       CapabilityControl.Factory capabilityControlFactory,
-      final AuthConfig authConfig,
+      AuthConfig authConfig,
       Realm realm,
-      final String anonymousCowardName,
-      final Provider<String> canonicalUrl,
-      final AccountCache accountCache,
-      final GroupBackend groupBackend,
-      final Boolean disableReverseDnsLookup,
-      @Nullable final Provider<SocketAddress> remotePeerProvider,
-      @Nullable final Provider<ReviewDb> dbProvider,
-      final Account.Id id,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      AccountState state,
+      @Nullable CurrentUser realUser) {
+    this(capabilityControlFactory, authConfig, realm, anonymousCowardName,
+        canonicalUrl, accountCache, groupBackend, disableReverseDnsLookup,
+        remotePeerProvider, state.getAccount().getId(), realUser);
+    this.state = state;
+  }
+
+  private IdentifiedUser(
+      CapabilityControl.Factory capabilityControlFactory,
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Account.Id id,
       @Nullable CurrentUser realUser) {
     super(capabilityControlFactory);
     this.canonicalUrl = canonicalUrl;
@@ -238,7 +223,6 @@
     this.anonymousCowardName = anonymousCowardName;
     this.disableReverseDnsLookup = disableReverseDnsLookup;
     this.remotePeerProvider = remotePeerProvider;
-    this.dbProvider = dbProvider;
     this.accountId = id;
     this.realUser = realUser != null ? realUser : this;
   }
@@ -248,7 +232,6 @@
     return realUser;
   }
 
-  // TODO(cranger): maybe get the state through the accountCache instead.
   public AccountState state() {
     if (state == null) {
       state = accountCache.get(getAccountId());
@@ -300,11 +283,11 @@
   }
 
   public String getName() {
-    return new AccountInfo(getAccount()).getName(anonymousCowardName);
+    return getAccount().getName(anonymousCowardName);
   }
 
   public String getNameEmail() {
-    return new AccountInfo(getAccount()).getNameEmail(anonymousCowardName);
+    return getAccount().getNameEmail(anonymousCowardName);
   }
 
   @Override
@@ -319,90 +302,6 @@
     return effectiveGroups;
   }
 
-  @Override
-  public Set<Change.Id> getStarredChanges() {
-    if (starredChanges == null) {
-      checkRequestScope();
-      try {
-        starredChanges = starredChangeIds(
-            starredQuery != null ? starredQuery : starredQuery());
-      } catch (OrmException | RuntimeException e) {
-        log.warn("Cannot query starred changes", e);
-        starredChanges = Collections.emptySet();
-      } finally {
-        starredQuery = null;
-      }
-    }
-    return starredChanges;
-  }
-
-  public void clearStarredChanges() {
-    // Async query may have started before an update that the caller expects
-    // to see the results of, so we can't trust it.
-    abortStarredChanges();
-    starredChanges = null;
-  }
-
-  public void asyncStarredChanges() {
-    if (starredChanges == null && dbProvider != null) {
-      try {
-        starredQuery = starredQuery();
-      } catch (OrmException e) {
-        log.warn("Cannot query starred by user changes", e);
-        starredQuery = null;
-        starredChanges = Collections.emptySet();
-      }
-    }
-  }
-
-  public void abortStarredChanges() {
-    if (starredQuery != null) {
-      try {
-        starredQuery.close();
-      } finally {
-        starredQuery = null;
-      }
-    }
-  }
-
-  private void checkRequestScope() {
-    if (dbProvider == null) {
-      throw new OutOfScopeException("Not in request scoped user");
-    }
-  }
-
-  private ResultSet<StarredChange> starredQuery() throws OrmException {
-    return dbProvider.get().starredChanges().byAccount(getAccountId());
-  }
-
-  private static ImmutableSet<Change.Id> starredChangeIds(
-      Iterable<StarredChange> scs) {
-    return FluentIterable.from(scs)
-        .transform(new Function<StarredChange, Change.Id>() {
-          @Override
-          public Change.Id apply(StarredChange in) {
-            return in.getChangeId();
-          }
-        }).toSet();
-  }
-
-  @Override
-  public Collection<AccountProjectWatch> getNotificationFilters() {
-    if (notificationFilters == null) {
-      checkRequestScope();
-      List<AccountProjectWatch> r;
-      try {
-        r = dbProvider.get().accountProjectWatches() //
-            .byAccount(getAccountId()).toList();
-      } catch (OrmException e) {
-        log.warn("Cannot query notification filters of a user", e);
-        r = Collections.emptyList();
-      }
-      notificationFilters = Collections.unmodifiableList(r);
-    }
-    return notificationFilters;
-  }
-
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
@@ -425,14 +324,11 @@
     user = user + "|" + "account-" + ua.getId().toString();
 
     String host = null;
-    if (remotePeerProvider != null) {
-      final SocketAddress remotePeer = remotePeerProvider.get();
-      if (remotePeer instanceof InetSocketAddress) {
-        final InetSocketAddress sa = (InetSocketAddress) remotePeer;
-        final InetAddress in = sa.getAddress();
-
-        host = in != null ? getHost(in) : sa.getHostName();
-      }
+    SocketAddress remotePeer = remotePeerProvider.get();
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? getHost(in) : sa.getHostName();
     }
     if (host == null || host.isEmpty()) {
       host = "unknown";
@@ -493,6 +389,41 @@
     return true;
   }
 
+  @Override
+  @Nullable
+  public synchronized <T> T get(PropertyKey<T> key) {
+    if (properties != null) {
+      @SuppressWarnings("unchecked")
+      T value = (T) properties.get(key);
+      return value;
+    }
+    return null;
+  }
+
+  /**
+   * Store a property for later retrieval.
+   *
+   * @param key unique property key.
+   * @param value value to store; or {@code null} to clear the value.
+   */
+  @Override
+  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
+    if (properties == null) {
+      if (value == null) {
+        return;
+      }
+      properties = new HashMap<>();
+    }
+
+    @SuppressWarnings("unchecked")
+    PropertyKey<Object> k = (PropertyKey<Object>) key;
+    if (value != null) {
+      properties.put(k, value);
+    } else {
+      properties.remove(k);
+    }
+  }
+
   private String getHost(final InetAddress in) {
     if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
       return in.getCanonicalHostName();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
index d0c2dc0..02d41f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
@@ -15,16 +15,10 @@
 package com.google.gerrit.server;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.Inject;
 
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Set;
-
 /**
  * User identity for plugin code that needs an identity.
  * <p>
@@ -52,16 +46,6 @@
   }
 
   @Override
-  public Set<Change.Id> getStarredChanges() {
-    return Collections.emptySet();
-  }
-
-  @Override
-  public Collection<AccountProjectWatch> getNotificationFilters() {
-    return Collections.emptySet();
-  }
-
-  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
index 7b182b1..603f528 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Optional;
 import com.google.common.base.Predicate;
 import com.google.common.collect.ComparisonChain;
@@ -35,13 +35,11 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.DraftCommentNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
@@ -49,16 +47,19 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 
 /**
  * Utility functions to manipulate PatchLineComments.
@@ -114,14 +115,13 @@
   private final DraftCommentNotes.Factory draftFactory;
   private final NotesMigration migration;
 
-  @VisibleForTesting
   @Inject
-  public PatchLineCommentsUtil(GitRepositoryManager repoManager,
-      AllUsersNameProvider allUsersProvider,
+  PatchLineCommentsUtil(GitRepositoryManager repoManager,
+      AllUsersName allUsers,
       DraftCommentNotes.Factory draftFactory,
       NotesMigration migration) {
     this.repoManager = repoManager;
-    this.allUsers = allUsersProvider.get();
+    this.allUsers = allUsers;
     this.draftFactory = draftFactory;
     this.migration = migration;
   }
@@ -152,7 +152,7 @@
     }
 
     notes.load();
-    List<PatchLineComment> comments = Lists.newArrayList();
+    List<PatchLineComment> comments = new ArrayList<>();
     comments.addAll(notes.getComments().values());
     return sort(comments);
   }
@@ -164,10 +164,9 @@
           db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
     }
 
-    List<PatchLineComment> comments = Lists.newArrayList();
-    Iterable<String> filtered = getDraftRefs(notes.getChangeId());
-    for (String refName : filtered) {
-      Account.Id account = Account.Id.fromRefPart(refName);
+    List<PatchLineComment> comments = new ArrayList<>();
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
         comments.addAll(draftByChangeAuthor(db, notes, account));
       }
@@ -193,12 +192,11 @@
     if (!migration.readChanges()) {
       return sort(db.patchComments().byPatchSet(psId).toList());
     }
-    List<PatchLineComment> comments = Lists.newArrayList();
+    List<PatchLineComment> comments = new ArrayList<>();
     comments.addAll(publishedByPatchSet(db, notes, psId));
 
-    Iterable<String> filtered = getDraftRefs(notes.getChangeId());
-    for (String refName : filtered) {
-      Account.Id account = Account.Id.fromRefPart(refName);
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
         comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
       }
@@ -264,56 +262,48 @@
             }
           }).toSortedList(PLC_ORDER);
     }
-    List<PatchLineComment> comments = Lists.newArrayList();
+    List<PatchLineComment> comments = new ArrayList<>();
     comments.addAll(notes.getDraftComments(author).values());
     return sort(comments);
   }
 
+  @Deprecated // To be used only by HasDraftByLegacyPredicate.
   public List<PatchLineComment> draftByAuthor(ReviewDb db,
       Account.Id author) throws OrmException {
     if (!migration.readChanges()) {
       return sort(db.patchComments().draftByAuthor(author).toList());
     }
 
-    // TODO(dborowitz): Just scan author space.
-    Set<String> refNames = getRefNamesAllUsers(RefNames.REFS_DRAFT_COMMENTS);
-    List<PatchLineComment> comments = Lists.newArrayList();
-    for (String refName : refNames) {
-      Account.Id id = Account.Id.fromRefPart(refName);
-      if (!author.equals(id)) {
-        continue;
+    List<PatchLineComment> comments = new ArrayList<>();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (String refName : repo.getRefDatabase()
+          .getRefs(RefNames.REFS_DRAFT_COMMENTS).keySet()) {
+        Account.Id accountId = Account.Id.fromRefSuffix(refName);
+        Change.Id changeId = Change.Id.fromRefPart(refName);
+        if (accountId == null || changeId == null) {
+          continue;
+        }
+        // Avoid loading notes for all affected changes just to be able to auto-
+        // rebuild. This is only used in a corner case in the search codepath,
+        // so returning slightly stale values is ok.
+        DraftCommentNotes notes =
+            draftFactory.createWithAutoRebuildingDisabled(changeId, author);
+        comments.addAll(notes.load().getComments().values());
       }
-      Change.Id changeId = Change.Id.parse(refName);
-      comments.addAll(
-          draftFactory.create(changeId, author).load().getComments().values());
+    } catch (IOException e) {
+      throw new OrmException(e);
     }
     return sort(comments);
   }
 
-  public void insertComments(ReviewDb db, ChangeUpdate update,
+  public void putComments(ReviewDb db, ChangeUpdate update,
       Iterable<PatchLineComment> comments) throws OrmException {
     for (PatchLineComment c : comments) {
-      update.insertComment(c);
-    }
-    db.patchComments().insert(comments);
-  }
-
-  public void upsertComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.upsertComment(c);
+      update.putComment(c);
     }
     db.patchComments().upsert(comments);
   }
 
-  public void updateComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.updateComment(c);
-    }
-    db.patchComments().update(comments);
-  }
-
   public void deleteComments(ReviewDb db, ChangeUpdate update,
       Iterable<PatchLineComment> comments) throws OrmException {
     for (PatchLineComment c : comments) {
@@ -322,6 +312,28 @@
     db.patchComments().delete(comments);
   }
 
+  public void deleteAllDraftsFromAllUsers(Change.Id changeId)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (Ref ref : getDraftRefs(repo, changeId)) {
+        bru.addCommand(new ReceiveCommand(
+            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+      }
+      bru.setRefLogMessage("Delete drafts from NoteDb", false);
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand cmd : bru.getCommands()) {
+        if (cmd.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(String.format(
+              "Failed to delete draft comment ref %s at %s: %s (%s)",
+              cmd.getRefName(), cmd.getOldId(), cmd.getResult(),
+              cmd.getMessage()));
+        }
+      }
+    }
+  }
+
   private static List<PatchLineComment> commentsOnFile(
       Collection<PatchLineComment> allComments,
       String file) {
@@ -349,13 +361,21 @@
 
   public static RevId setCommentRevId(PatchLineComment c,
       PatchListCache cache, Change change, PatchSet ps) throws OrmException {
+    checkArgument(c.getPatchSetId().equals(ps.getId()),
+        "cannot set RevId for patch set %s on comment %s", ps.getId(), c);
     if (c.getRevId() == null) {
       try {
-        // TODO(dborowitz): Bypass cache if side is REVISION.
-        PatchList patchList = cache.get(change, ps);
-        c.setRevId((c.getSide() == (short) 0)
-          ? new RevId(ObjectId.toString(patchList.getOldId()))
-          : new RevId(ObjectId.toString(patchList.getNewId())));
+        if (Side.fromShort(c.getSide()) == Side.PARENT) {
+          if (c.getSide() < 0) {
+            c.setRevId(new RevId(ObjectId.toString(
+                cache.getOldId(change, ps, -c.getSide()))));
+          } else {
+            c.setRevId(new RevId(ObjectId.toString(
+                cache.getOldId(change, ps, null))));
+          }
+        } else {
+          c.setRevId(ps.getRevision());
+        }
       } catch (PatchListNotAvailableException e) {
         throw new OrmException(e);
       }
@@ -363,25 +383,19 @@
     return c.getRevId();
   }
 
-  private Set<String> getRefNamesAllUsers(String prefix) throws OrmException {
+  public Collection<Ref> getDraftRefs(Change.Id changeId)
+      throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      RefDatabase refDb = repo.getRefDatabase();
-      return refDb.getRefs(prefix).keySet();
+      return getDraftRefs(repo, changeId);
     } catch (IOException e) {
       throw new OrmException(e);
     }
   }
 
-  private Iterable<String> getDraftRefs(final Change.Id changeId)
-      throws OrmException {
-    Set<String> refNames = getRefNamesAllUsers(RefNames.REFS_DRAFT_COMMENTS);
-    final String suffix = "-" + changeId.get();
-    return Iterables.filter(refNames, new Predicate<String>() {
-      @Override
-      public boolean apply(String input) {
-        return input.endsWith(suffix);
-      }
-    });
+  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId)
+      throws IOException {
+    return repo.getRefDatabase().getRefs(
+        RefNames.refsDraftCommentsPrefix(changeId)).values();
   }
 
   private static List<PatchLineComment> sort(List<PatchLineComment> comments) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
new file mode 100644
index 0000000..0dcf3bf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.notedb.PatchSetState.DRAFT;
+import static com.google.gerrit.server.notedb.PatchSetState.PUBLISHED;
+
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.PatchSetState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.List;
+
+/** Utilities for manipulating patch sets. */
+@Singleton
+public class PatchSetUtil {
+  private final NotesMigration migration;
+
+  @Inject
+  PatchSetUtil(NotesMigration migration) {
+    this.migration = migration;
+  }
+
+  public PatchSet current(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
+    return get(db, notes, notes.getChange().currentPatchSetId());
+  }
+
+  public PatchSet get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return db.patchSets().get(psId);
+    }
+    return notes.load().getPatchSets().get(psId);
+  }
+
+  public ImmutableCollection<PatchSet> byChange(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return ChangeUtil.PS_ID_ORDER.immutableSortedCopy(
+          db.patchSets().byChange(notes.getChangeId()));
+    }
+    return notes.load().getPatchSets().values();
+  }
+
+  public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ReviewDb db,
+        ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      ImmutableMap.Builder<PatchSet.Id, PatchSet> result =
+          ImmutableMap.builder();
+      for (PatchSet ps : ChangeUtil.PS_ID_ORDER.sortedCopy(
+          db.patchSets().byChange(notes.getChangeId()))) {
+        result.put(ps.getId(), ps);
+      }
+      return result.build();
+    }
+    return notes.load().getPatchSets();
+  }
+
+  public PatchSet insert(ReviewDb db, RevWalk rw, ChangeUpdate update,
+      PatchSet.Id psId, ObjectId commit, boolean draft,
+      List<String> groups, String pushCertificate)
+      throws OrmException, IOException {
+    checkNotNull(groups, "groups may not be null");
+    ensurePatchSetMatches(psId, update);
+
+    PatchSet ps = new PatchSet(psId);
+    ps.setRevision(new RevId(commit.name()));
+    ps.setUploader(update.getAccountId());
+    ps.setCreatedOn(new Timestamp(update.getWhen().getTime()));
+    ps.setDraft(draft);
+    ps.setGroups(groups);
+    ps.setPushCertificate(pushCertificate);
+    db.patchSets().insert(Collections.singleton(ps));
+
+    update.setCommit(rw, commit, pushCertificate);
+    update.setGroups(groups);
+    if (draft) {
+      update.setPatchSetState(DRAFT);
+    }
+
+    return ps;
+  }
+
+  public void publish(ReviewDb db, ChangeUpdate update, PatchSet ps)
+      throws OrmException {
+    ensurePatchSetMatches(ps.getId(), update);
+    ps.setDraft(false);
+    update.setPatchSetState(PUBLISHED);
+    db.patchSets().update(Collections.singleton(ps));
+  }
+
+  public void delete(ReviewDb db, ChangeUpdate update, PatchSet ps)
+      throws OrmException {
+    ensurePatchSetMatches(ps.getId(), update);
+    checkArgument(ps.isDraft(),
+        "cannot delete non-draft patch set %s", ps.getId());
+    update.setPatchSetState(PatchSetState.DELETED);
+    db.patchSets().delete(Collections.singleton(ps));
+  }
+
+  private void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
+    Change.Id changeId = update.getChange().getId();
+    checkArgument(psId.getParentKey().equals(changeId),
+        "cannot modify patch set %s on update for change %s", psId, changeId);
+    if (update.getPatchSetId() != null) {
+      checkArgument(update.getPatchSetId().equals(psId),
+          "cannot modify patch set %s on update for %s",
+          psId, update.getPatchSetId());
+    } else {
+      update.setPatchSetId(psId);
+    }
+  }
+
+  public void setGroups(ReviewDb db, ChangeUpdate update, PatchSet ps,
+      List<String> groups) throws OrmException {
+    ps.setGroups(groups);
+    update.setGroups(groups);
+    db.patchSets().update(Collections.singleton(ps));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
index 4d26f02..6616a66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -14,17 +14,12 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 import java.net.SocketAddress;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Set;
 
 /** Identity of a peer daemon process that isn't this JVM. */
 public class PeerDaemonUser extends CurrentUser {
@@ -49,16 +44,6 @@
     return GroupMembership.EMPTY;
   }
 
-  @Override
-  public Set<Change.Id> getStarredChanges() {
-    return Collections.emptySet();
-  }
-
-  @Override
-  public Collection<AccountProjectWatch> getNotificationFilters() {
-    return Collections.emptySet();
-  }
-
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
new file mode 100644
index 0000000..515cef7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change.
+ * <p>
+ * A given account may appear in multiple states and at different timestamps. No
+ * reviewers with state {@link ReviewerStateInternal#REMOVED} are ever exposed
+ * by this interface.
+ */
+public class ReviewerSet {
+  private static final ReviewerSet EMPTY = new ReviewerSet(
+      ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>of());
+
+  public static ReviewerSet fromApprovals(
+      Iterable<PatchSetApproval> approvals) {
+    PatchSetApproval first = null;
+    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers =
+        HashBasedTable.create();
+    for (PatchSetApproval psa : approvals) {
+      if (first == null) {
+        first = psa;
+      } else {
+        checkArgument(
+            first.getKey().getParentKey().getParentKey().equals(
+              psa.getKey().getParentKey().getParentKey()),
+            "multiple change IDs: %s, %s", first.getKey(), psa.getKey());
+      }
+      Account.Id id = psa.getAccountId();
+      if (psa.getValue() != 0) {
+        reviewers.put(REVIEWER, id, psa.getGranted());
+        reviewers.remove(CC, id);
+      } else if (!reviewers.contains(REVIEWER, id)) {
+        reviewers.put(CC, id, psa.getGranted());
+      }
+    }
+    return new ReviewerSet(reviewers);
+  }
+
+  public static ReviewerSet fromTable(
+      Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+    return new ReviewerSet(table);
+  }
+
+  public static ReviewerSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
+      table;
+  private ImmutableSet<Account.Id> accounts;
+
+  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Account.Id> all() {
+    if (accounts == null) {
+      // Idempotent and immutable, don't bother locking.
+      accounts = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return accounts;
+  }
+
+  public ImmutableSet<Account.Id> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
+      asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerSet) && table.equals(((ReviewerSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
new file mode 100644
index 0000000..bbe4013
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+
+import java.sql.Timestamp;
+
+/** Change to a reviewer's status. */
+@AutoValue
+public abstract class ReviewerStatusUpdate {
+  public static ReviewerStatusUpdate create(
+      Timestamp ts, Account.Id updatedBy, Account.Id reviewer,
+      ReviewerStateInternal state) {
+    return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
+  }
+
+  public abstract Timestamp date();
+  public abstract Account.Id updatedBy();
+  public abstract Account.Id reviewer();
+  public abstract ReviewerStateInternal state();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index e38f88c..3b43162 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
@@ -28,35 +29,75 @@
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.change.ReviewerSuggestionCache;
 import com.google.gerrit.server.change.SuggestReviewers;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryResult;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gerrit.server.query.account.AccountQueryProcessor;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
 public class ReviewersUtil {
+  private static final Logger log = LoggerFactory.getLogger(ReviewersUtil.class);
+
+  @Singleton
+  private static class Metrics {
+    final Timer0 queryAccountsLatency;
+    final Timer0 queryGroupsLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      queryAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/query_accounts",
+              new Description("Latency for querying accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      queryGroupsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/query_groups",
+              new Description("Latency for querying groups for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+    }
+  }
+
   private static final String MAX_SUFFIX = "\u9fa5";
   private static final Ordering<SuggestedReviewerInfo> ORDERING =
       Ordering.natural().onResultOf(new Function<SuggestedReviewerInfo, String>() {
@@ -74,30 +115,41 @@
       });
   private final AccountLoader accountLoader;
   private final AccountCache accountCache;
-  private final ReviewerSuggestionCache reviewerSuggestionCache;
+  private final AccountIndexCollection indexes;
+  private final AccountQueryBuilder queryBuilder;
+  private final AccountQueryProcessor queryProcessor;
   private final AccountControl accountControl;
   private final Provider<ReviewDb> dbProvider;
   private final GroupBackend groupBackend;
   private final GroupMembers.Factory groupMembersFactory;
   private final Provider<CurrentUser> currentUser;
+  private final Metrics metrics;
 
   @Inject
   ReviewersUtil(AccountLoader.Factory accountLoaderFactory,
       AccountCache accountCache,
-      ReviewerSuggestionCache reviewerSuggestionCache,
+      AccountIndexCollection indexes,
+      AccountQueryBuilder queryBuilder,
+      AccountQueryProcessor queryProcessor,
       AccountControl.Factory accountControlFactory,
       Provider<ReviewDb> dbProvider,
       GroupBackend groupBackend,
       GroupMembers.Factory groupMembersFactory,
-      Provider<CurrentUser> currentUser) {
-    this.accountLoader = accountLoaderFactory.create(true);
+      Provider<CurrentUser> currentUser,
+      Metrics metrics) {
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
+    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountCache = accountCache;
-    this.reviewerSuggestionCache = reviewerSuggestionCache;
+    this.indexes = indexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
     this.accountControl = accountControlFactory.get();
     this.dbProvider = dbProvider;
     this.groupBackend = groupBackend;
     this.groupMembersFactory = groupMembersFactory;
     this.currentUser = currentUser;
+    this.metrics = metrics;
   }
 
   public interface VisibilityControl {
@@ -111,7 +163,6 @@
     String query = suggestReviewers.getQuery();
     boolean suggestAccounts = suggestReviewers.getSuggestAccounts();
     int suggestFrom = suggestReviewers.getSuggestFrom();
-    boolean useFullTextSearch = suggestReviewers.getUseFullTextSearch();
     int limit = suggestReviewers.getLimit();
 
     if (Strings.isNullOrEmpty(query)) {
@@ -122,61 +173,81 @@
       return Collections.emptyList();
     }
 
-    List<AccountInfo> suggestedAccounts;
-    if (useFullTextSearch) {
-      suggestedAccounts = suggestAccountFullTextSearch(suggestReviewers, visibilityControl);
-    } else {
-      suggestedAccounts = suggestAccount(suggestReviewers, visibilityControl);
-    }
+    Collection<AccountInfo> suggestedAccounts =
+        suggestAccounts(suggestReviewers, visibilityControl);
 
-    List<SuggestedReviewerInfo> reviewer = Lists.newArrayList();
+    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
     for (AccountInfo a : suggestedAccounts) {
       SuggestedReviewerInfo info = new SuggestedReviewerInfo();
       info.account = a;
+      info.count = 1;
       reviewer.add(info);
     }
 
-    for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
-      if (suggestGroupAsReviewer(suggestReviewers, projectControl.getProject(),
-          g, visibilityControl)) {
-        GroupBaseInfo info = new GroupBaseInfo();
-        info.id = Url.encode(g.getUUID().get());
-        info.name = g.getName();
-        SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
-        suggestedReviewerInfo.group = info;
-        reviewer.add(suggestedReviewerInfo);
+    try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
+      for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
+        GroupAsReviewer result = suggestGroupAsReviewer(
+            suggestReviewers, projectControl.getProject(), g, visibilityControl);
+        if (result.allowed || result.allowedWithConfirmation) {
+          GroupBaseInfo info = new GroupBaseInfo();
+          info.id = Url.encode(g.getUUID().get());
+          info.name = g.getName();
+          SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
+          suggestedReviewerInfo.group = info;
+          suggestedReviewerInfo.count = result.size;
+          if (result.allowedWithConfirmation) {
+            suggestedReviewerInfo.confirm = true;
+          }
+          reviewer.add(suggestedReviewerInfo);
+        }
       }
     }
 
     reviewer = ORDERING.immutableSortedCopy(reviewer);
     if (reviewer.size() <= limit) {
       return reviewer;
-    } else {
-      return reviewer.subList(0, limit);
     }
+    return reviewer.subList(0, limit);
   }
 
-  private List<AccountInfo> suggestAccountFullTextSearch(
-      SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
-          throws IOException, OrmException {
-    List<AccountInfo> results = reviewerSuggestionCache.search(
-        suggestReviewers.getQuery(), suggestReviewers.getFullTextMaxMatches());
-
-    Iterator<AccountInfo> it = results.iterator();
-    while (it.hasNext()) {
-      Account.Id accountId = new Account.Id(it.next()._accountId);
-      if (!(visibilityControl.isVisibleTo(accountId)
-          && accountControl.canSee(accountId))) {
-        it.remove();
+  private Collection<AccountInfo> suggestAccounts(SuggestReviewers suggestReviewers,
+      VisibilityControl visibilityControl) throws OrmException {
+    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
+      AccountIndex searchIndex = indexes.getSearchIndex();
+      if (searchIndex != null) {
+        return suggestAccountsFromIndex(suggestReviewers, visibilityControl);
       }
+      log.warn("Account index not available; suggesting reviewers from DB");
+      return suggestAccountsFromDb(suggestReviewers, visibilityControl);
     }
-
-    return results;
   }
 
-  private List<AccountInfo> suggestAccount(SuggestReviewers suggestReviewers,
-      VisibilityControl visibilityControl)
+  private Collection<AccountInfo> suggestAccountsFromIndex(
+      SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
       throws OrmException {
+    try {
+      Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
+      QueryResult<AccountState> result = queryProcessor
+          .setLimit(suggestReviewers.getLimit())
+          .query(queryBuilder.defaultQuery(suggestReviewers.getQuery()));
+      for (AccountState accountState : result.entities()) {
+        Account.Id id = accountState.getAccount().getId();
+        if (visibilityControl.isVisibleTo(id)) {
+          matches.put(id, accountLoader.get(id));
+        }
+      }
+
+      accountLoader.fill();
+
+      return matches.values();
+    } catch (QueryParseException e) {
+      return ImmutableList.of();
+    }
+  }
+
+  private Collection<AccountInfo> suggestAccountsFromDb(
+      SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
+          throws OrmException {
     String query = suggestReviewers.getQuery();
     int limit = suggestReviewers.getLimit();
 
@@ -232,7 +303,7 @@
     if (!map.containsKey(account)
         // Can the suggestion see the change?
         && visibilityControl.isVisibleTo(account)
-        // Can the account see the current user?
+        // Can the current user see the account?
         && accountControl.canSee(account)) {
       map.put(account, accountLoader.get(account));
       return true;
@@ -247,13 +318,22 @@
             suggestReviewers.getLimit()));
   }
 
-  private boolean suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
+  private static class GroupAsReviewer {
+    boolean allowed;
+    boolean allowedWithConfirmation;
+    int size;
+  }
+
+  private GroupAsReviewer suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
       Project project, GroupReference group,
       VisibilityControl visibilityControl) throws OrmException, IOException {
+    GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
+    int maxAllowedWithoutConfirmation =
+        suggestReviewers.getMaxAllowedWithoutConfirmation();
 
     if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
-      return false;
+      return result;
     }
 
     try {
@@ -262,25 +342,33 @@
           .listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
-        return false;
+        return result;
       }
 
-      if (maxAllowed > 0 && members.size() > maxAllowed) {
-        return false;
+      result.size = members.size();
+      if (maxAllowed > 0 && result.size > maxAllowed) {
+        return result;
       }
 
+      boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+
       // require that at least one member in the group can see the change
       for (Account account : members) {
         if (visibilityControl.isVisibleTo(account.getId())) {
-          return true;
+          if (needsConfirmation) {
+            result.allowedWithConfirmation = true;
+          } else {
+            result.allowed = true;
+          }
+          return result;
         }
       }
     } catch (NoSuchGroupException e) {
-      return false;
+      return result;
     } catch (NoSuchProjectException e) {
-      return false;
+      return result;
     }
 
-    return false;
+    return result;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
new file mode 100644
index 0000000..9448ceb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SuppressWarnings("deprecation")
+@Singleton
+public class Sequences {
+  private final Provider<ReviewDb> db;
+  private final NotesMigration migration;
+  private final RepoSequence changeSeq;
+
+  @Inject
+  Sequences(@GerritServerConfig Config cfg,
+      final Provider<ReviewDb> db,
+      NotesMigration migration,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjects) {
+    this.db = db;
+    this.migration = migration;
+
+    final int gap = cfg.getInt("noteDb", "changes", "initialSequenceGap", 0);
+    changeSeq = new RepoSequence(
+        repoManager,
+        allProjects,
+        "changes",
+        new RepoSequence.Seed() {
+          @Override
+          public int get() throws OrmException {
+            return db.get().nextChangeId() + gap;
+          }
+        },
+        cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20));
+  }
+
+  public int nextChangeId() throws OrmException {
+    if (!migration.readChangeSequence()) {
+      return db.get().nextChangeId();
+    }
+    return changeSeq.next();
+  }
+
+  public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
+    if (migration.readChangeSequence()) {
+      return changeSeq.next(count);
+    }
+
+    if (count == 0) {
+      return ImmutableList.of();
+    }
+    checkArgument(count > 0, "count is negative: %s", count);
+    List<Integer> ids = new ArrayList<>(count);
+    ReviewDb db = this.db.get();
+    for (int i = 0; i < count; i++) {
+      ids.add(db.nextChangeId());
+    }
+    return ImmutableList.copyOf(ids);
+  }
+
+  @VisibleForTesting
+  public RepoSequence getChangeIdRepoSequence() {
+    return changeSeq;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
new file mode 100644
index 0000000..5a89afa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -0,0 +1,469 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.base.Predicate;
+import com.google.common.base.Splitter;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+@Singleton
+public class StarredChangesUtil {
+  @AutoValue
+  public abstract static class StarField {
+    private static final String SEPARATOR = ":";
+
+    public static StarField parse(String s) {
+      int p = s.indexOf(SEPARATOR);
+      if (p >= 0) {
+        Integer id = Ints.tryParse(s.substring(0, p));
+        if (id == null) {
+          return null;
+        }
+        Account.Id accountId = new Account.Id(id);
+        String label = s.substring(p + 1);
+        return create(accountId, label);
+      }
+      return null;
+    }
+
+    public static StarField create(Account.Id accountId, String label) {
+      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
+    }
+
+    public abstract Account.Id accountId();
+    public abstract String label();
+
+    @Override
+    public String toString() {
+      return accountId() + SEPARATOR + label();
+    }
+  }
+
+  public static class IllegalLabelException extends IllegalArgumentException {
+    private static final long serialVersionUID = 1L;
+
+    static IllegalLabelException invalidLabels(Set<String> invalidLabels) {
+      return new IllegalLabelException(
+          String.format("invalid labels: %s",
+              Joiner.on(", ").join(invalidLabels)));
+    }
+
+    static IllegalLabelException mutuallyExclusiveLabels(String label1,
+        String label2) {
+      return new IllegalLabelException(
+          String.format("The labels %s and %s are mutually exclusive."
+              + " Only one of them can be set.", label1, label2));
+    }
+
+    IllegalLabelException(String message) {
+      super(message);
+    }
+  }
+
+  private static final Logger log =
+      LoggerFactory.getLogger(StarredChangesUtil.class);
+
+  public static final String DEFAULT_LABEL = "star";
+  public static final String IGNORE_LABEL = "ignore";
+  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
+      ImmutableSortedSet.of(DEFAULT_LABEL);
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final Provider<ReviewDb> dbProvider;
+  private final PersonIdent serverIdent;
+  private final ChangeIndexer indexer;
+  private final Provider<InternalChangeQuery> queryProvider;
+
+  @Inject
+  StarredChangesUtil(GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      Provider<ReviewDb> dbProvider,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeIndexer indexer,
+      Provider<InternalChangeQuery> queryProvider) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.dbProvider = dbProvider;
+    this.serverIdent = serverIdent;
+    this.indexer = indexer;
+    this.queryProvider = queryProvider;
+  }
+
+  public ImmutableSortedSet<String> getLabels(Account.Id accountId,
+      Change.Id changeId) throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return ImmutableSortedSet.copyOf(
+          readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Reading stars from change %d for account %d failed",
+              changeId.get(), accountId.get()), e);
+    }
+  }
+
+  public ImmutableSortedSet<String> star(Account.Id accountId,
+      Project.NameKey project, Change.Id changeId, Set<String> labelsToAdd,
+      Set<String> labelsToRemove) throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsStarredChanges(changeId, accountId);
+      ObjectId oldObjectId = getObjectId(repo, refName);
+
+      SortedSet<String> labels = readLabels(repo, oldObjectId);
+      if (labelsToAdd != null) {
+        labels.addAll(labelsToAdd);
+      }
+      if (labelsToRemove != null) {
+        labels.removeAll(labelsToRemove);
+      }
+
+      if (labels.isEmpty()) {
+        deleteRef(repo, refName, oldObjectId);
+      } else {
+        checkMutuallyExclusiveLabels(labels);
+        updateLabels(repo, refName, oldObjectId, labels);
+      }
+
+      indexer.index(dbProvider.get(), project, changeId);
+      return ImmutableSortedSet.copyOf(labels);
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Star change %d for account %d failed",
+              changeId.get(), accountId.get()), e);
+    }
+  }
+
+  public void unstarAll(Project.NameKey project, Change.Id changeId)
+      throws OrmException, NoSuchChangeException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAllowNonFastForwards(true);
+      batchUpdate.setRefLogIdent(serverIdent);
+      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
+      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+        String refName = RefNames.refsStarredChanges(changeId, accountId);
+        Ref ref = repo.getRefDatabase().getRef(refName);
+        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(),
+            ObjectId.zeroId(), refName));
+      }
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(String.format(
+              "Unstar change %d failed, ref %s could not be deleted: %s",
+              changeId.get(), command.getRefName(), command.getResult()));
+        }
+      }
+      indexer.index(dbProvider.get(), project, changeId);
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Unstar change %d failed", changeId.get()), e);
+    }
+  }
+
+  public ImmutableMultimap<Account.Id, String> byChange(Change.Id changeId)
+      throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableMultimap.Builder<Account.Id, String> builder =
+          new ImmutableMultimap.Builder<>();
+      for (String refPart : getRefNames(repo,
+          RefNames.refsStarredChangesPrefix(changeId))) {
+        Integer id = Ints.tryParse(refPart);
+        if (id == null) {
+          continue;
+        }
+        Account.Id accountId = new Account.Id(id);
+        builder.putAll(accountId,
+            readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new OrmException(String.format(
+          "Get accounts that starred change %d failed", changeId.get()), e);
+    }
+  }
+
+  public Set<Account.Id> byChange(final Change.Id changeId,
+      final String label) throws OrmException {
+    try (final Repository repo = repoManager.openRepository(allUsers)) {
+      return FluentIterable
+          .from(getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId)))
+          .transform(new Function<String, Account.Id>() {
+            @Override
+            public Account.Id apply(String refPart) {
+              return Account.Id.parse(refPart);
+            }
+          })
+          .filter(new Predicate<Account.Id>() {
+            @Override
+            public boolean apply(Account.Id accountId) {
+              try {
+                return readLabels(repo,
+                    RefNames.refsStarredChanges(changeId, accountId))
+                        .contains(label);
+              } catch (IOException e) {
+                log.error(String.format(
+                    "Cannot query stars by account %d on change %d",
+                    accountId.get(), changeId.get()), e);
+                return false;
+              }
+            }
+          }).toSet();
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Get accounts that starred change %d failed",
+              changeId.get()), e);
+    }
+  }
+
+  @Deprecated
+  // To be used only for IsStarredByLegacyPredicate.
+  public Set<Change.Id> byAccount(final Account.Id accountId,
+      final String label) throws OrmException {
+    try (final Repository repo = repoManager.openRepository(allUsers)) {
+      return FluentIterable
+          .from(getRefNames(repo, RefNames.REFS_STARRED_CHANGES))
+          .filter(new Predicate<String>() {
+            @Override
+            public boolean apply(String refPart) {
+              return refPart.endsWith("/" + accountId.get());
+            }
+          })
+          .transform(new Function<String, Change.Id>() {
+            @Override
+            public Change.Id apply(String refPart) {
+              return Change.Id.fromRefPart(refPart);
+            }
+          })
+          .filter(new Predicate<Change.Id>() {
+            @Override
+            public boolean apply(Change.Id changeId) {
+              try {
+                return readLabels(repo,
+                    RefNames.refsStarredChanges(changeId, accountId))
+                        .contains(label);
+              } catch (IOException e) {
+                log.error(String.format(
+                    "Cannot query stars by account %d on change %d",
+                    accountId.get(), changeId.get()), e);
+                return false;
+              }
+            }
+          }).toSet();
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Get changes that were starred by %d failed",
+              accountId.get()), e);
+    }
+  }
+
+  public ImmutableMultimap<Account.Id, String> byChangeFromIndex(
+      Change.Id changeId) throws OrmException, NoSuchChangeException {
+    Set<String> fields = ImmutableSet.of(
+        ChangeField.ID.getName(),
+        ChangeField.STAR.getName());
+    List<ChangeData> changeData = queryProvider.get().setRequestedFields(fields)
+        .byLegacyChangeId(changeId);
+    if (changeData.size() != 1) {
+      throw new NoSuchChangeException(changeId);
+    }
+    return changeData.get(0).stars();
+  }
+
+  private static Set<String> getRefNames(Repository repo, String prefix)
+      throws IOException {
+    RefDatabase refDb = repo.getRefDatabase();
+    return refDb.getRefs(prefix).keySet();
+  }
+
+  public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getObjectId(repo,
+          RefNames.refsStarredChanges(changeId, accountId));
+    } catch (IOException e) {
+      log.error(String.format(
+          "Getting star object ID for account %d on change %d failed",
+          accountId.get(), changeId.get()), e);
+      return ObjectId.zeroId();
+    }
+  }
+
+  private static ObjectId getObjectId(Repository repo, String refName)
+      throws IOException {
+    Ref ref = repo.exactRef(refName);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  private static SortedSet<String> readLabels(Repository repo, String refName)
+      throws IOException {
+    return readLabels(repo, getObjectId(repo, refName));
+  }
+
+  private static TreeSet<String> readLabels(Repository repo, ObjectId id)
+      throws IOException {
+    if (ObjectId.zeroId().equals(id)) {
+      return new TreeSet<>();
+    }
+
+    try (ObjectReader reader = repo.newObjectReader()) {
+      ObjectLoader obj = reader.open(id, Constants.OBJ_BLOB);
+      TreeSet<String> labels = new TreeSet<>();
+      Iterables.addAll(labels,
+          Splitter.on(CharMatcher.whitespace()).omitEmptyStrings()
+              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
+      return labels;
+    }
+  }
+
+  public static ObjectId writeLabels(Repository repo, SortedSet<String> labels)
+      throws IOException {
+    validateLabels(labels);
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id = oi.insert(Constants.OBJ_BLOB,
+          Joiner.on("\n").join(labels).getBytes(UTF_8));
+      oi.flush();
+      return id;
+    }
+  }
+
+  private static void checkMutuallyExclusiveLabels(Set<String> labels) {
+    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
+      throw IllegalLabelException.mutuallyExclusiveLabels(DEFAULT_LABEL,
+          IGNORE_LABEL);
+    }
+  }
+
+  private static void validateLabels(Set<String> labels) {
+    if (labels == null) {
+      return;
+    }
+
+    SortedSet<String> invalidLabels = new TreeSet<>();
+    for (String label : labels) {
+      if (CharMatcher.whitespace().matchesAnyOf(label)) {
+        invalidLabels.add(label);
+      }
+    }
+    if (!invalidLabels.isEmpty()) {
+      throw IllegalLabelException.invalidLabels(invalidLabels);
+    }
+  }
+
+  private void updateLabels(Repository repo, String refName,
+      ObjectId oldObjectId, SortedSet<String> labels)
+          throws IOException, OrmException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setForceUpdate(true);
+      u.setNewObjectId(writeLabels(repo, labels));
+      u.setRefLogIdent(serverIdent);
+      u.setRefLogMessage("Update star labels", true);
+      RefUpdate.Result result = u.update(rw);
+      switch (result) {
+        case NEW:
+        case FORCED:
+        case NO_CHANGE:
+        case FAST_FORWARD:
+          return;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+          throw new OrmException(
+              String.format("Update star labels on ref %s failed: %s", refName,
+                  result.name()));
+      }
+    }
+  }
+
+  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
+      throws IOException, OrmException {
+    RefUpdate u = repo.updateRef(refName);
+    u.setForceUpdate(true);
+    u.setExpectedOldObjectId(oldObjectId);
+    u.setRefLogIdent(serverIdent);
+    u.setRefLogMessage("Unstar change", true);
+    RefUpdate.Result result = u.delete();
+    switch (result) {
+      case FORCED:
+        return;
+      case NEW:
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+        throw new OrmException(String.format("Delete star ref %s failed: %s",
+            refName, result.name()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
index 2133dfb..0aef9e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
@@ -26,7 +26,7 @@
     { "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a",
       "\\b",   "\\t",   "\\n",   "\\v",   "\\f",   "\\r",   "\\x0e", "\\x0f",
       "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17",
-      "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f" };
+      "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f", };
 
   /**
    * Escapes the input string so that all non-printable characters
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index 351de5e..a8345e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.webui.DiffWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
@@ -44,7 +45,7 @@
 
         @Override
         public boolean apply(WebLinkInfo link) {
-          if (link == null){
+          if (link == null) {
             return false;
           } else if (Strings.isNullOrEmpty(link.name)
               || Strings.isNullOrEmpty(link.url)) {
@@ -73,6 +74,7 @@
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
+  private final DynamicSet<ParentWebLink> parentLinks;
   private final DynamicSet<FileWebLink> fileLinks;
   private final DynamicSet<FileHistoryWebLink> fileHistoryLinks;
   private final DynamicSet<DiffWebLink> diffLinks;
@@ -81,6 +83,7 @@
 
   @Inject
   public WebLinks(DynamicSet<PatchSetWebLink> patchSetLinks,
+      DynamicSet<ParentWebLink> parentLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
@@ -88,6 +91,7 @@
       DynamicSet<BranchWebLink> branchLinks
       ) {
     this.patchSetLinks = patchSetLinks;
+    this.parentLinks = parentLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
     this.diffLinks = diffLinks;
@@ -113,6 +117,22 @@
   }
 
   /**
+   * @param project Project name.
+   * @param revision SHA1 of the parent revision.
+   * @return Links for patch sets.
+   */
+  public FluentIterable<WebLinkInfo> getParentLinks(final Project.NameKey project,
+      final String revision) {
+    return filterLinks(parentLinks, new Function<WebLink, WebLinkInfo>() {
+
+      @Override
+      public WebLinkInfo apply(WebLink webLink) {
+        return ((ParentWebLink)webLink).getParentWebLink(project.get(), revision);
+      }
+    });
+  }
+
+  /**
    *
    * @param project Project name.
    * @param revision SHA1 of revision.
@@ -159,6 +179,9 @@
             WebLinkInfo info =
                 ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
                     revision, file);
+            if (info == null) {
+              return null;
+            }
             WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
             commonInfo.name = info.name;
             commonInfo.imageUrl = info.imageUrl;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
index 93a3814..aeff017 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
@@ -14,293 +14,45 @@
 
 package com.google.gerrit.server.access;
 
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectJson;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.GetAccess;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
-import java.util.HashMap;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
+import java.util.TreeMap;
 
 public class ListAccess implements RestReadView<TopLevelResource> {
 
   @Option(name = "--project", aliases = {"-p"}, metaVar = "PROJECT",
       usage = "projects for which the access rights should be returned")
-  private List<String> projects = Lists.newArrayList();
+  private List<String> projects = new ArrayList<>();
 
-  private final Provider<CurrentUser> self;
-  private final ProjectControl.GenericFactory projectControlFactory;
-  private final ProjectCache projectCache;
-  private final ProjectJson projectJson;
-  private final MetaDataUpdate.Server metaDataUpdateFactory;
-  private final GroupControl.Factory groupControlFactory;
-  private final GroupBackend groupBackend;
-  private final AllProjectsName allProjectsName;
+  private final GetAccess getAccess;
 
   @Inject
-  public ListAccess(Provider<CurrentUser> self,
-      ProjectControl.GenericFactory projectControlFactory,
-      ProjectCache projectCache, ProjectJson projectJson,
-      MetaDataUpdate.Server metaDataUpdateFactory,
-      GroupControl.Factory groupControlFactory, GroupBackend groupBackend,
-      AllProjectsName allProjectsName) {
-    this.self = self;
-    this.projectControlFactory = projectControlFactory;
-    this.projectCache = projectCache;
-    this.projectJson = projectJson;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.groupControlFactory = groupControlFactory;
-    this.groupBackend = groupBackend;
-    this.allProjectsName = allProjectsName;
+  public ListAccess(GetAccess getAccess) {
+    this.getAccess = getAccess;
   }
 
   @Override
   public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
       throws ResourceNotFoundException, ResourceConflictException, IOException {
-    Map<String, ProjectAccessInfo> access = Maps.newTreeMap();
-    for (String p: projects) {
+    Map<String, ProjectAccessInfo> access = new TreeMap<>();
+    for (String p : projects) {
       Project.NameKey projectName = new Project.NameKey(p);
-      ProjectControl pc = open(projectName);
-      ProjectConfig config;
-
-      try {
-        // Load the current configuration from the repository, ensuring it's the most
-        // recent version available. If it differs from what was in the project
-        // state, force a cache flush now.
-        //
-        MetaDataUpdate md = metaDataUpdateFactory.create(projectName);
-        try {
-          config = ProjectConfig.read(md);
-
-          if (config.updateGroupNames(groupBackend)) {
-            md.setMessage("Update group names\n");
-            config.commit(md);
-            projectCache.evict(config.getProject());
-            pc = open(projectName);
-          } else if (config.getRevision() != null
-              && !config.getRevision().equals(
-                  pc.getProjectState().getConfig().getRevision())) {
-            projectCache.evict(config.getProject());
-            pc = open(projectName);
-          }
-        } catch (ConfigInvalidException e) {
-          throw new ResourceConflictException(e.getMessage());
-        } finally {
-          md.close();
-        }
-      } catch (RepositoryNotFoundException e) {
-        throw new ResourceNotFoundException(p);
-      }
-
-      access.put(p, new ProjectAccessInfo(pc, config));
+      access.put(p, getAccess.apply(projectName));
     }
     return access;
   }
 
-  private ProjectControl open(Project.NameKey projectName)
-      throws ResourceNotFoundException, IOException {
-    try {
-      return projectControlFactory.validateFor(projectName,
-          ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
-    } catch (NoSuchProjectException e) {
-      throw new ResourceNotFoundException(projectName.get());
-    }
-  }
-
-  public class ProjectAccessInfo {
-    public String revision;
-    public ProjectInfo inheritsFrom;
-    public Map<String, AccessSectionInfo> local;
-    public Boolean isOwner;
-    public Set<String> ownerOf;
-    public Boolean canUpload;
-    public Boolean canAdd;
-    public Boolean configVisible;
-
-    public ProjectAccessInfo(ProjectControl pc, ProjectConfig config) {
-      final RefControl metaConfigControl =
-          pc.controlForRef(RefNames.REFS_CONFIG);
-      local = Maps.newHashMap();
-      ownerOf = Sets.newHashSet();
-      Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
-
-      for (AccessSection section : config.getAccessSections()) {
-        String name = section.getName();
-        if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (pc.isOwner()) {
-            local.put(name, new AccessSectionInfo(section));
-            ownerOf.add(name);
-
-          } else if (metaConfigControl.isVisible()) {
-            local.put(section.getName(), new AccessSectionInfo(section));
-          }
-
-        } else if (RefConfigSection.isValid(name)) {
-          RefControl rc = pc.controlForRef(name);
-          if (rc.isOwner()) {
-            local.put(name, new AccessSectionInfo(section));
-            ownerOf.add(name);
-
-          } else if (metaConfigControl.isVisible()) {
-            local.put(name, new AccessSectionInfo(section));
-
-          } else if (rc.isVisible()) {
-            // Filter the section to only add rules describing groups that
-            // are visible to the current-user. This includes any group the
-            // user is a member of, as well as groups they own or that
-            // are visible to all users.
-
-            AccessSection dst = null;
-            for (Permission srcPerm : section.getPermissions()) {
-              Permission dstPerm = null;
-
-              for (PermissionRule srcRule : srcPerm.getRules()) {
-                AccountGroup.UUID group = srcRule.getGroup().getUUID();
-                if (group == null) {
-                  continue;
-                }
-
-                Boolean canSeeGroup = visibleGroups.get(group);
-                if (canSeeGroup == null) {
-                  try {
-                    canSeeGroup = groupControlFactory.controlFor(group).isVisible();
-                  } catch (NoSuchGroupException e) {
-                    canSeeGroup = Boolean.FALSE;
-                  }
-                  visibleGroups.put(group, canSeeGroup);
-                }
-
-                if (canSeeGroup) {
-                  if (dstPerm == null) {
-                    if (dst == null) {
-                      dst = new AccessSection(name);
-                      local.put(name, new AccessSectionInfo(dst));
-                    }
-                    dstPerm = dst.getPermission(srcPerm.getName(), true);
-                  }
-                  dstPerm.add(srcRule);
-                }
-              }
-            }
-          }
-        }
-      }
-
-      if (ownerOf.isEmpty() && pc.isOwnerAnyRef()) {
-        // Special case: If the section list is empty, this project has no current
-        // access control information. Rely on what ProjectControl determines
-        // is ownership, which probably means falling back to site administrators.
-        ownerOf.add(AccessSection.ALL);
-      }
-
-
-      if (config.getRevision() != null) {
-        revision = config.getRevision().name();
-      }
-
-      ProjectState parent =
-          Iterables.getFirst(pc.getProjectState().parents(), null);
-      if (parent != null) {
-        inheritsFrom = projectJson.format(parent.getProject());
-      }
-
-      if (pc.getProject().getNameKey().equals(allProjectsName)) {
-        if (pc.isOwner()) {
-          ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-        }
-      }
-
-      isOwner = toBoolean(pc.isOwner());
-      canUpload = toBoolean(pc.isOwner()
-          || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
-      canAdd = toBoolean(pc.canAddRefs());
-      configVisible = pc.isOwner() || metaConfigControl.isVisible();
-    }
-  }
-
-  public static class AccessSectionInfo {
-    public Map<String, PermissionInfo> permissions;
-
-    public AccessSectionInfo(AccessSection section) {
-      permissions = Maps.newHashMap();
-      for (Permission p : section.getPermissions()) {
-        permissions.put(p.getName(), new PermissionInfo(p));
-      }
-    }
-  }
-
-  public static class PermissionInfo {
-    public String label;
-    public Boolean exclusive;
-    public Map<String, PermissionRuleInfo> rules;
-
-    public PermissionInfo(Permission permission) {
-      label = permission.getLabel();
-      exclusive = toBoolean(permission.getExclusiveGroup());
-      rules = Maps.newHashMap();
-      for (PermissionRule r : permission.getRules()) {
-        rules.put(r.getGroup().getUUID().get(), new PermissionRuleInfo(r));
-      }
-    }
-  }
-
-  public static class PermissionRuleInfo {
-    public PermissionRule.Action action;
-    public Boolean force;
-    public Integer min;
-    public Integer max;
-
-
-    public PermissionRuleInfo(PermissionRule rule) {
-      action = rule.getAction();
-      force = toBoolean(rule.getForce());
-      if (hasRange(rule)) {
-        min = rule.getMin();
-        max = rule.getMax();
-      }
-    }
-
-    private boolean hasRange(PermissionRule rule) {
-      return (!(rule.getMin() == null || rule.getMin() == 0))
-          || (!(rule.getMax() == null || rule.getMax() == 0));
-    }
-  }
-
-  private static Boolean toBoolean(boolean value) {
-    return value ? true : null;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
index 406ab52..9001ea5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
@@ -20,7 +20,7 @@
 
 /** Translates an email address to a set of matching accounts. */
 public interface AccountByEmailCache {
-  public Set<Account.Id> get(String email);
+  Set<Account.Id> get(String email);
 
-  public void evict(String email);
+  void evict(String email);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index 28df97a..0856616 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -17,14 +17,16 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
@@ -33,6 +35,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
@@ -84,22 +87,37 @@
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final AccountIndexCollection accountIndexes;
+    private final Provider<InternalAccountQuery> accountQueryProvider;
 
     @Inject
-    Loader(final SchemaFactory<ReviewDb> schema) {
+    Loader(SchemaFactory<ReviewDb> schema,
+        AccountIndexCollection accountIndexes,
+        Provider<InternalAccountQuery> accountQueryProvider) {
       this.schema = schema;
+      this.accountIndexes = accountIndexes;
+      this.accountQueryProvider = accountQueryProvider;
     }
 
     @Override
     public Set<Account.Id> load(String email) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<Account.Id> r = Sets.newHashSet();
+        Set<Account.Id> r = new HashSet<>();
         for (Account a : db.accounts().byPreferredEmail(email)) {
           r.add(a.getId());
         }
-        for (AccountExternalId a : db.accountExternalIds()
-            .byEmailAddress(email)) {
-          r.add(a.getAccountId());
+        if (accountIndexes.getSearchIndex() != null) {
+          for (AccountState accountState : accountQueryProvider.get()
+              .byExternalId(
+                  (new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO,
+                      email)).get())) {
+            r.add(accountState.getAccount().getId());
+          }
+        } else {
+          for (AccountExternalId a : db.accountExternalIds()
+              .byEmailAddress(email)) {
+            r.add(a.getAccountId());
+          }
         }
         return ImmutableSet.copyOf(r);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
index 86308a9..3a4566a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
@@ -16,15 +16,19 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 
+import java.io.IOException;
+
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
-  public AccountState get(Account.Id accountId);
+  AccountState get(Account.Id accountId);
 
-  public AccountState getIfPresent(Account.Id accountId);
+  AccountState getIfPresent(Account.Id accountId);
 
-  public AccountState getByUsername(String username);
+  AccountState getByUsername(String username);
 
-  public void evict(Account.Id accountId);
+  void evict(Account.Id accountId) throws IOException;
 
-  public void evictByUsername(String username);
+  void evictByUsername(String username);
+
+  void evictAll() throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index bedd9f1..149931d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -19,26 +19,39 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
@@ -71,12 +84,15 @@
 
   private final LoadingCache<Account.Id, AccountState> byId;
   private final LoadingCache<String, Optional<Account.Id>> byName;
+  private final Provider<AccountIndexer> indexer;
 
   @Inject
   AccountCacheImpl(@Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
-      @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername) {
+      @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
+      Provider<AccountIndexer> indexer) {
     this.byId = byId;
     this.byName = byUsername;
+    this.indexer = indexer;
   }
 
   @Override
@@ -106,9 +122,18 @@
   }
 
   @Override
-  public void evict(Account.Id accountId) {
+  public void evict(Account.Id accountId) throws IOException {
     if (accountId != null) {
       byId.invalidate(accountId);
+      indexer.get().index(accountId);
+    }
+  }
+
+  @Override
+  public void evictAll() throws IOException {
+    byId.invalidateAll();
+    for (Account.Id accountId : byId.asMap().keySet()) {
+      indexer.get().index(accountId);
     }
   }
 
@@ -124,20 +149,33 @@
     account.setActive(false);
     Collection<AccountExternalId> ids = Collections.emptySet();
     Set<AccountGroup.UUID> anon = ImmutableSet.of();
-    return new AccountState(account, anon, ids);
+    return new AccountState(account, anon, ids,
+        new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 
   static class ByIdLoader extends CacheLoader<Account.Id, AccountState> {
     private final SchemaFactory<ReviewDb> schema;
     private final GroupCache groupCache;
+    private final GeneralPreferencesLoader loader;
     private final LoadingCache<String, Optional<Account.Id>> byName;
+    private final boolean readFromGit;
+    private final Provider<WatchConfig.Accessor> watchConfig;
 
     @Inject
-    ByIdLoader(SchemaFactory<ReviewDb> sf, GroupCache groupCache,
-        @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername) {
+    ByIdLoader(SchemaFactory<ReviewDb> sf,
+        GroupCache groupCache,
+        GeneralPreferencesLoader loader,
+        @Named(BYUSER_NAME) LoadingCache<String,
+            Optional<Account.Id>> byUsername,
+        @GerritServerConfig Config cfg,
+        Provider<WatchConfig.Accessor> watchConfig) {
       this.schema = sf;
       this.groupCache = groupCache;
+      this.loader = loader;
       this.byName = byUsername;
+      this.readFromGit =
+          cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
+      this.watchConfig = watchConfig;
     }
 
     @Override
@@ -153,17 +191,16 @@
     }
 
     private AccountState load(final ReviewDb db, final Account.Id who)
-        throws OrmException {
-      final Account account = db.accounts().get(who);
+        throws OrmException, IOException, ConfigInvalidException {
+      Account account = db.accounts().get(who);
       if (account == null) {
         // Account no longer exists? They are anonymous.
-        //
         return missing(who);
       }
 
-      final Collection<AccountExternalId> externalIds =
-          Collections.unmodifiableCollection(db.accountExternalIds().byAccount(
-              who).toList());
+      Collection<AccountExternalId> externalIds =
+          Collections.unmodifiableCollection(
+              db.accountExternalIds().byAccount(who).toList());
 
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
@@ -175,25 +212,53 @@
       }
       internalGroups = Collections.unmodifiableSet(internalGroups);
 
-      return new AccountState(account, internalGroups, externalIds);
+      try {
+        account.setGeneralPreferences(loader.load(who));
+      } catch (IOException | ConfigInvalidException e) {
+        log.warn("Cannot load GeneralPreferences for " + who +
+            " (using default)", e);
+        account.setGeneralPreferences(GeneralPreferencesInfo.defaults());
+      }
+
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+          readFromGit
+              ? watchConfig.get().getProjectWatches(who)
+              : GetWatchedProjects.readProjectWatchesFromDb(db, who);
+
+      return new AccountState(account, internalGroups, externalIds,
+          projectWatches);
     }
   }
 
   static class ByNameLoader extends CacheLoader<String, Optional<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final AccountIndexCollection accountIndexes;
+    private final Provider<InternalAccountQuery> accountQueryProvider;
 
     @Inject
-    ByNameLoader(final SchemaFactory<ReviewDb> sf) {
+    ByNameLoader(SchemaFactory<ReviewDb> sf,
+        AccountIndexCollection accountIndexes,
+        Provider<InternalAccountQuery> accountQueryProvider) {
       this.schema = sf;
+      this.accountIndexes = accountIndexes;
+      this.accountQueryProvider = accountQueryProvider;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        final AccountExternalId.Key key = new AccountExternalId.Key( //
+        AccountExternalId.Key key = new AccountExternalId.Key( //
             AccountExternalId.SCHEME_USERNAME, //
             username);
-        final AccountExternalId id = db.accountExternalIds().get(key);
+      if (accountIndexes.getSearchIndex() != null) {
+        AccountState accountState =
+            accountQueryProvider.get().oneByExternalId(key.get());
+        return accountState != null
+            ? Optional.of(accountState.getAccount().getId())
+            : Optional.<Account.Id>absent();
+      }
+
+      try (ReviewDb db = schema.open()) {
+        AccountExternalId id = db.accountExternalIds().get(key);
         if (id != null) {
           return Optional.of(id.getAccountId());
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
index aad427b..c5b0699 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
@@ -78,20 +78,24 @@
     this.accountVisibility = accountVisibility;
   }
 
+  public CurrentUser getUser() {
+    return user;
+  }
+
   /**
-   * Returns true if the otherUser is allowed to see the current user, based
+   * Returns true if the current user is allowed to see the otherUser, based
    * on the account visibility policy. Depending on the group membership
    * realms supported, this may not be able to determine SAME_GROUP or
    * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
    * {@link GroupMembership#getKnownGroups()} may only return a subset of the
    * effective groups.
    */
-  public boolean canSee(final Account otherUser) {
+  public boolean canSee(Account otherUser) {
     return canSee(otherUser.getId());
   }
 
   /**
-   * Returns true if the otherUser is allowed to see the current user, based
+   * Returns true if the current user is allowed to see the otherUser, based
    * on the account visibility policy. Depending on the group membership
    * realms supported, this may not be able to determine SAME_GROUP or
    * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
@@ -99,8 +103,45 @@
    * effective groups.
    */
   public boolean canSee(final Account.Id otherUser) {
+    return canSee(new OtherUser() {
+      @Override
+      Account.Id getId() {
+        return otherUser;
+      }
+
+      @Override
+      IdentifiedUser createUser() {
+        return userFactory.create(otherUser);
+      }
+    });
+  }
+
+  /**
+   * Returns true if the current user is allowed to see the otherUser, based
+   * on the account visibility policy. Depending on the group membership
+   * realms supported, this may not be able to determine SAME_GROUP or
+   * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
+   * {@link GroupMembership#getKnownGroups()} may only return a subset of the
+   * effective groups.
+   */
+  public boolean canSee(final AccountState otherUser) {
+    return canSee(new OtherUser() {
+      @Override
+      Account.Id getId() {
+        return otherUser.getAccount().getId();
+      }
+
+      @Override
+      IdentifiedUser createUser() {
+        return userFactory.create(otherUser);
+      }
+    });
+  }
+
+  private boolean canSee(OtherUser otherUser) {
     // Special case: I can always see myself.
-    if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser)) {
+    if (user.isIdentifiedUser()
+        && user.getAccountId().equals(otherUser.getId())) {
       return true;
     }
     if (user.getCapabilities().canViewAllAccounts()) {
@@ -111,7 +152,7 @@
       case ALL:
         return true;
       case SAME_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser);
+        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
         for (PermissionRule rule : accountsSection.getSameGroupVisibility()) {
           if (rule.isBlock() || rule.isDeny()) {
             usersGroups.remove(rule.getGroup().getUUID());
@@ -124,7 +165,7 @@
         break;
       }
       case VISIBLE_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser);
+        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
         for (AccountGroup.UUID usersGroup : usersGroups) {
           try {
             if (groupControlFactory.controlFor(usersGroup).isVisible()) {
@@ -144,9 +185,9 @@
     return false;
   }
 
-  private Set<AccountGroup.UUID> groupsOf(Account.Id account) {
+  private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
     return new HashSet<>(Sets.filter(
-      userFactory.create(account).getEffectiveGroups().getKnownGroups(),
+      user.getEffectiveGroups().getKnownGroups(),
       new Predicate<AccountGroup.UUID>() {
         @Override
         public boolean apply(AccountGroup.UUID in) {
@@ -154,4 +195,18 @@
         }
       }));
   }
+
+  private abstract static class OtherUser {
+    IdentifiedUser user;
+
+    IdentifiedUser getUser() {
+      if (user == null) {
+        user = createUser();
+      }
+      return user;
+    }
+
+    abstract IdentifiedUser createUser();
+    abstract Account.Id getId();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
index b4ca530..63d2fc6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -32,6 +32,9 @@
     /** Preferred email address to contact the user at. */
     EMAIL,
 
+    /** All secondary email addresses of the user. */
+    SECONDARY_EMAILS,
+
     /** User profile images. */
     AVATARS,
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
index 3e9b575..f84d399 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
@@ -18,25 +18,25 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.DirectoryException;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
 public class AccountLoader {
-  private static final Set<FillOptions> DETAILED_OPTIONS =
+  public static final Set<FillOptions> DETAILED_OPTIONS =
       Collections.unmodifiableSet(EnumSet.of(
           FillOptions.ID,
           FillOptions.NAME,
@@ -46,6 +46,7 @@
 
   public interface Factory {
     AccountLoader create(boolean detailed);
+    AccountLoader create(Set<FillOptions> options);
   }
 
   private final InternalAccountDirectory directory;
@@ -53,12 +54,22 @@
   private final Map<Account.Id, AccountInfo> created;
   private final List<AccountInfo> provided;
 
-  @Inject
-  AccountLoader(InternalAccountDirectory directory, @Assisted boolean detailed) {
+  @AssistedInject
+  AccountLoader(InternalAccountDirectory directory,
+      @Assisted boolean detailed) {
+    this(directory,
+        detailed
+            ? DETAILED_OPTIONS
+            : InternalAccountDirectory.ID_ONLY);
+  }
+
+  @AssistedInject
+  AccountLoader(InternalAccountDirectory directory,
+      @Assisted Set<FillOptions> options) {
     this.directory = directory;
-    options = detailed ? DETAILED_OPTIONS : InternalAccountDirectory.ID_ONLY;
-    created = Maps.newHashMap();
-    provided = Lists.newArrayList();
+    this.options = options;
+    created = new HashMap<>();
+    provided = new ArrayList<>();
   }
 
   public AccountInfo get(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index c0eb947..1e46409 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.errors.InvalidUserNameException;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -38,6 +37,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -102,7 +102,8 @@
    * @throws AccountException the account does not exist, and cannot be created,
    *         or exists, but cannot be located, or is inactive.
    */
-  public AuthResult authenticate(AuthRequest who) throws AccountException {
+  public AuthResult authenticate(AuthRequest who)
+      throws AccountException, IOException {
     who = realm.authenticate(who);
     try {
       try (ReviewDb db = schema.open()) {
@@ -112,19 +113,17 @@
           // New account, automatically create and return.
           //
           return create(db, who);
-
-        } else { // Account exists
-
-          Account act = byIdCache.get(id.getAccountId()).getAccount();
-          if (!act.isActive()) {
-            throw new AccountException("Authentication error, account inactive");
-          }
-
-          // return the identity to the caller.
-          update(db, who, id);
-          return new AuthResult(id.getAccountId(), key, false);
         }
 
+        // Account exists
+        Account act = byIdCache.get(id.getAccountId()).getAccount();
+        if (!act.isActive()) {
+          throw new AccountException("Authentication error, account inactive");
+        }
+
+        // return the identity to the caller.
+        update(db, who, id);
+        return new AuthResult(id.getAccountId(), key, false);
       }
     } catch (OrmException e) {
       throw new AccountException("Authentication error", e);
@@ -133,16 +132,13 @@
 
   private AccountExternalId getAccountExternalId(ReviewDb db,
       AccountExternalId.Key key) throws OrmException {
-    String keyValue = key.get();
-    String keyScheme = keyValue.substring(0, keyValue.indexOf(':') + 1);
-
     // We don't have at the moment an account_by_external_id cache
     // but by using the accounts cache we get the list of external_ids
     // without having to query the DB every time
-    if (keyScheme.equals(AccountExternalId.SCHEME_GERRIT)
-        || keyScheme.equals(AccountExternalId.SCHEME_USERNAME)) {
+    if (key.getScheme().equals(AccountExternalId.SCHEME_GERRIT)
+        || key.getScheme().equals(AccountExternalId.SCHEME_USERNAME)) {
       AccountState state = byIdCache.getByUsername(
-          keyValue.substring(keyScheme.length()));
+          key.get().substring(key.getScheme().length()));
       if (state != null) {
         for (AccountExternalId accountExternalId : state.getExternalIds()) {
           if (accountExternalId.getKey().equals(key)) {
@@ -155,7 +151,7 @@
   }
 
   private void update(ReviewDb db, AuthRequest who, AccountExternalId extId)
-      throws OrmException {
+      throws OrmException, IOException {
     IdentifiedUser user = userFactory.create(extId.getAccountId());
     Account toUpdate = null;
 
@@ -218,7 +214,7 @@
   }
 
   private AuthResult create(ReviewDb db, AuthRequest who)
-      throws OrmException, AccountException {
+      throws OrmException, AccountException, IOException {
     Account.Id newId = new Account.Id(db.nextAccountId());
     Account account = new Account(newId, TimeUtil.nowTs());
     AccountExternalId extId = createId(newId, who);
@@ -232,6 +228,17 @@
 
     try {
       db.accounts().upsert(Collections.singleton(account));
+
+      AccountExternalId existingExtId =
+          db.accountExternalIds().get(extId.getKey());
+      if (existingExtId != null
+          && !existingExtId.getAccountId().equals(extId.getAccountId())) {
+        // external ID is assigned to another account, do not overwrite
+        db.accounts().delete(Collections.singleton(account));
+        throw new AccountException(
+            "Cannot assign external ID \"" + extId.getExternalId()
+                + "\" to account " + newId + "; external ID already in use.");
+      }
       db.accountExternalIds().upsert(Collections.singleton(extId));
     } finally {
       // If adding the account failed, it may be that it actually was the
@@ -282,6 +289,7 @@
     }
 
     byEmailCache.evict(account.getPreferredEmail());
+    byIdCache.evict(account.getId());
     realm.onCreateAccount(who, account);
     return new AuthResult(newId, extId.getKey(), true);
   }
@@ -344,10 +352,8 @@
    *         cannot be linked at this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
-      who = realm.link(db, to, who);
-
       AccountExternalId.Key key = id(who);
       AccountExternalId extId = getAccountExternalId(db, key);
       if (extId != null) {
@@ -370,8 +376,8 @@
 
         if (who.getEmailAddress() != null) {
           byEmailCache.evict(who.getEmailAddress());
-          byIdCache.evict(to);
         }
+        byIdCache.evict(to);
       }
 
       return new AuthResult(to, key, false);
@@ -393,7 +399,7 @@
    *         cannot be linked at this time.
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who) throws OrmException,
-      AccountException {
+      AccountException, IOException {
     try (ReviewDb db = schema.open()) {
       AccountExternalId.Key key = id(who);
       List<AccountExternalId.Key> filteredKeysByScheme =
@@ -430,15 +436,14 @@
    *         cannot be unlinked at this time.
    */
   public AuthResult unlink(Account.Id from, AuthRequest who)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
-      who = realm.unlink(db, from, who);
-
       AccountExternalId.Key key = id(who);
       AccountExternalId extId = getAccountExternalId(db, key);
       if (extId != null) {
         if (!extId.getAccountId().equals(from)) {
-          throw new AccountException("Identity in use by another account");
+          throw new AccountException(
+              "Identity '" + key.get() + "' in use by another account");
         }
         db.accountExternalIds().delete(Collections.singleton(extId));
 
@@ -454,7 +459,7 @@
         }
 
       } else {
-        throw new AccountException("Identity not found");
+        throw new AccountException("Identity '" + key.get() + "' not found");
       }
 
       return new AuthResult(from, key, false);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
index 558f8c0..5a18269 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.collect.Sets;
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,15 +38,20 @@
   private final Realm realm;
   private final AccountByEmailCache byEmail;
   private final AccountCache byId;
-  private final Provider<ReviewDb> schema;
+  private final AccountIndexCollection accountIndexes;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
 
   @Inject
-  AccountResolver(final Realm realm, final AccountByEmailCache byEmail,
-      final AccountCache byId, final Provider<ReviewDb> schema) {
+  AccountResolver(Realm realm,
+      AccountByEmailCache byEmail,
+      AccountCache byId,
+      AccountIndexCollection accountIndexes,
+      Provider<InternalAccountQuery> accountQueryProvider) {
     this.realm = realm;
     this.byEmail = byEmail;
     this.byId = byId;
-    this.schema = schema;
+    this.accountIndexes = accountIndexes;
+    this.accountQueryProvider = accountQueryProvider;
   }
 
   /**
@@ -56,8 +64,8 @@
    * @return the single account that matches; null if no account matches or
    *         there are multiple candidates.
    */
-  public Account find(final String nameOrEmail) throws OrmException {
-    Set<Account.Id> r = findAll(nameOrEmail);
+  public Account find(ReviewDb db, String nameOrEmail) throws OrmException {
+    Set<Account.Id> r = findAll(db, nameOrEmail);
     if (r.size() == 1) {
       return byId.get(r.iterator().next()).getAccount();
     }
@@ -79,30 +87,30 @@
   /**
    * Find all accounts matching the name or name/email string.
    *
+   * @param db open database handle.
    * @param nameOrEmail a string of the format
    *        "Full Name &lt;email@example&gt;", just the email address
    *        ("email@example"), a full name ("Full Name"), an account id
    *        ("18419") or an user name ("username").
    * @return the accounts that match, empty collection if none.  Never null.
    */
-  public Set<Account.Id> findAll(String nameOrEmail) throws OrmException {
+  public Set<Account.Id> findAll(ReviewDb db, String nameOrEmail)
+      throws OrmException {
     Matcher m = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$").matcher(nameOrEmail);
     if (m.matches()) {
       Account.Id id = Account.Id.parse(m.group(1));
-      if (exists(id)) {
+      if (exists(db, id)) {
         return Collections.singleton(id);
-      } else {
-        return Collections.emptySet();
       }
+      return Collections.emptySet();
     }
 
     if (nameOrEmail.matches("^[1-9][0-9]*$")) {
       Account.Id id = Account.Id.parse(nameOrEmail);
-      if (exists(id)) {
+      if (exists(db, id)) {
         return Collections.singleton(id);
-      } else {
-        return Collections.emptySet();
       }
+      return Collections.emptySet();
     }
 
     if (nameOrEmail.matches(Account.USER_NAME_PATTERN)) {
@@ -112,40 +120,42 @@
       }
     }
 
-    return findAllByNameOrEmail(nameOrEmail);
+    return findAllByNameOrEmail(db, nameOrEmail);
   }
 
-  private boolean exists(Account.Id id) throws OrmException {
-    return schema.get().accounts().get(id) != null;
+  private boolean exists(ReviewDb db, Account.Id id) throws OrmException {
+    return db.accounts().get(id) != null;
   }
 
   /**
    * Locate exactly one account matching the name or name/email string.
    *
+   * @param db open database handle.
    * @param nameOrEmail a string of the format
    *        "Full Name &lt;email@example&gt;", just the email address
    *        ("email@example"), a full name ("Full Name").
    * @return the single account that matches; null if no account matches or
    *         there are multiple candidates.
    */
-  public Account findByNameOrEmail(final String nameOrEmail)
+  public Account findByNameOrEmail(ReviewDb db, String nameOrEmail)
       throws OrmException {
-    Set<Account.Id> r = findAllByNameOrEmail(nameOrEmail);
+    Set<Account.Id> r = findAllByNameOrEmail(db, nameOrEmail);
     return r.size() == 1 ? byId.get(r.iterator().next()).getAccount() : null;
   }
 
   /**
    * Locate exactly one account matching the name or name/email string.
    *
+   * @param db open database handle.
    * @param nameOrEmail a string of the format
    *        "Full Name &lt;email@example&gt;", just the email address
    *        ("email@example"), a full name ("Full Name").
    * @return the accounts that match, empty collection if none. Never null.
    */
-  public Set<Account.Id> findAllByNameOrEmail(final String nameOrEmail)
+  public Set<Account.Id> findAllByNameOrEmail(ReviewDb db, String nameOrEmail)
       throws OrmException {
-    final int lt = nameOrEmail.indexOf('<');
-    final int gt = nameOrEmail.indexOf('>');
+    int lt = nameOrEmail.indexOf('<');
+    int gt = nameOrEmail.indexOf('>');
     if (lt >= 0 && gt > lt && nameOrEmail.contains("@")) {
       Set<Account.Id> ids = byEmail.get(nameOrEmail.substring(lt + 1, gt));
       if (ids.isEmpty() || ids.size() == 1) {
@@ -154,7 +164,7 @@
 
       // more than one match, try to return the best one
       String name = nameOrEmail.substring(0, lt - 1);
-      Set<Account.Id> nameMatches = Sets.newHashSet();
+      Set<Account.Id> nameMatches = new HashSet<>();
       for (Account.Id id : ids) {
         Account a = byId.get(id).getAccount();
         if (name.equals(a.getFullName())) {
@@ -168,34 +178,49 @@
       return byEmail.get(nameOrEmail);
     }
 
-    final Account.Id id = realm.lookup(nameOrEmail);
+    Account.Id id = realm.lookup(nameOrEmail);
     if (id != null) {
       return Collections.singleton(id);
     }
 
-    List<Account> m = schema.get().accounts().byFullName(nameOrEmail).toList();
+    if (accountIndexes.getSearchIndex() != null) {
+      List<AccountState> m = accountQueryProvider.get().byFullName(nameOrEmail);
+      if (m.size() == 1) {
+        return Collections.singleton(m.get(0).getAccount().getId());
+      }
+
+      // At this point we have no clue. Just perform a whole bunch of suggestions
+      // and pray we come up with a reasonable result list.
+      return FluentIterable
+          .from(accountQueryProvider.get().byDefault(nameOrEmail))
+          .transform(new Function<AccountState, Account.Id>() {
+            @Override
+            public Account.Id apply(AccountState accountState) {
+              return accountState.getAccount().getId();
+            }
+          }).toSet();
+    }
+
+    List<Account> m = db.accounts().byFullName(nameOrEmail).toList();
     if (m.size() == 1) {
       return Collections.singleton(m.get(0).getId());
     }
 
     // At this point we have no clue. Just perform a whole bunch of suggestions
     // and pray we come up with a reasonable result list.
-    //
     Set<Account.Id> result = new HashSet<>();
     String a = nameOrEmail;
     String b = nameOrEmail + "\u9fa5";
-    for (Account act : schema.get().accounts().suggestByFullName(a, b, 10)) {
+    for (Account act : db.accounts().suggestByFullName(a, b, 10)) {
       result.add(act.getId());
     }
-    for (AccountExternalId extId : schema
-        .get()
-        .accountExternalIds()
+    for (AccountExternalId extId : db.accountExternalIds()
         .suggestByKey(
             new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, a),
             new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, b), 10)) {
       result.add(extId.getAccountId());
     }
-    for (AccountExternalId extId : schema.get().accountExternalIds()
+    for (AccountExternalId extId : db.accountExternalIds()
         .suggestByEmailAddress(a, b, 10)) {
       result.add(extId.getAccountId());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
index 75e5ae5..8bebf52 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.TypeLiteral;
 
+import java.util.Set;
+
 public class AccountResource implements RestResource {
   public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
       new TypeLiteral<RestView<AccountResource>>() {};
@@ -108,4 +110,32 @@
       return change.getChange();
     }
   }
+
+  public static class Star implements RestResource {
+    public static final TypeLiteral<RestView<Star>> STAR_KIND =
+        new TypeLiteral<RestView<Star>>() {};
+
+    private final IdentifiedUser user;
+    private final ChangeResource change;
+    private final Set<String> labels;
+
+    public Star(IdentifiedUser user, ChangeResource change,
+        Set<String> labels) {
+      this.user = user;
+      this.change = change;
+      this.labels = labels;
+    }
+
+    public IdentifiedUser getUser() {
+      return user;
+    }
+
+    public Change getChange() {
+      return change.getChange();
+    }
+
+    public Set<String> getLabels() {
+      return labels;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 815b519..05a7179 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,26 +14,49 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
 
+import com.google.common.base.Function;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.server.CurrentUser.PropertyKey;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 
 import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 
 public class AccountState {
+  public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
+      new Function<AccountState, Account.Id>() {
+        @Override
+        public Account.Id apply(AccountState in) {
+          return in.getAccount().getId();
+        }
+      };
+
   private final Account account;
   private final Set<AccountGroup.UUID> internalGroups;
   private final Collection<AccountExternalId> externalIds;
+  private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
+  private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
-  public AccountState(final Account account,
-      final Set<AccountGroup.UUID> actualGroups,
-      final Collection<AccountExternalId> externalIds) {
+  public AccountState(Account account,
+      Set<AccountGroup.UUID> actualGroups,
+      Collection<AccountExternalId> externalIds,
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
     this.account = account;
     this.internalGroups = actualGroups;
     this.externalIds = externalIds;
+    this.projectWatches = projectWatches;
     this.account.setUserName(getUserName(externalIds));
   }
 
@@ -68,6 +91,11 @@
     return externalIds;
   }
 
+  /** The project watches of the account. */
+  public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
+    return projectWatches;
+  }
+
   /** The set of groups maintained directly within the Gerrit database. */
   public Set<AccountGroup.UUID> getInternalGroups() {
     return internalGroups;
@@ -81,4 +109,69 @@
     }
     return null;
   }
+
+  public static Set<String> getEmails(Collection<AccountExternalId> ids) {
+    Set<String> emails = new HashSet<>();
+    for (AccountExternalId id : ids) {
+      if (id.isScheme(SCHEME_MAILTO)) {
+        emails.add(id.getSchemeRest());
+      }
+    }
+    return emails;
+  }
+
+  /**
+   * Lookup a previously stored property.
+   * <p>
+   * All properties are automatically cleared when the account cache invalidates
+   * the {@code AccountState}. This method is thread-safe.
+   *
+   * @param key unique property key.
+   * @return previously stored value, or {@code null}.
+   */
+  @Nullable
+  public <T> T get(PropertyKey<T> key) {
+    Cache<PropertyKey<Object>, Object> p = properties(false);
+    if (p != null) {
+      @SuppressWarnings("unchecked")
+      T value = (T) p.getIfPresent(key);
+      return value;
+    }
+    return null;
+  }
+
+  /**
+   * Store a property for later retrieval.
+   * <p>
+   * This method is thread-safe.
+   *
+   * @param key unique property key.
+   * @param value value to store; or {@code null} to clear the value.
+   */
+  public <T> void put(PropertyKey<T> key, @Nullable T value) {
+    Cache<PropertyKey<Object>, Object> p = properties(value != null);
+    if (p != null) {
+      @SuppressWarnings("unchecked")
+      PropertyKey<Object> k = (PropertyKey<Object>) key;
+      if (value != null) {
+        p.put(k, value);
+      } else {
+        p.invalidate(k);
+      }
+    }
+  }
+
+  private synchronized Cache<PropertyKey<Object>, Object> properties(
+      boolean allocate) {
+    if (properties == null && allocate) {
+      properties = CacheBuilder.newBuilder()
+          .concurrencyLevel(1)
+          .initialCapacity(16)
+          // Use weakKeys to ensure plugins that garbage collect will also
+          // eventually release data held in any still live AccountState.
+          .weakKeys()
+          .build();
+    }
+    return properties;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
index d7f7a19..25f0a7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
@@ -19,13 +19,8 @@
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class AccountVisibilityProvider implements Provider<AccountVisibility> {
-  private static final Logger log =
-      LoggerFactory.getLogger(AccountVisibilityProvider.class);
-
   private final AccountVisibility accountVisibility;
 
   @Inject
@@ -33,19 +28,6 @@
     AccountVisibility av;
     if (cfg.getString("accounts", null, "visibility") != null) {
       av = cfg.getEnum("accounts", null, "visibility", AccountVisibility.ALL);
-    } else if (cfg.getString("suggest", null, "accounts") != null) {
-      try {
-        av = cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
-        log.warn(String.format(
-            "Using legacy value %s for suggest.accounts;"
-            + " use accounts.visibility=%s instead",
-            av, av));
-      } catch (IllegalArgumentException err) {
-        // If suggest.accounts is a valid boolean, it's a new-style config, and
-        // we should use the default here. Invalid values are caught in
-        // SuggestServiceImpl so we don't worry about them here.
-        av = AccountVisibility.ALL;
-      }
     } else {
       av = AccountVisibility.ALL;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 80ea907..04ebc87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -35,23 +36,26 @@
 @Singleton
 public class AccountsCollection implements
     RestCollection<TopLevelResource, AccountResource>,
-    AcceptsCreate<TopLevelResource>{
+    AcceptsCreate<TopLevelResource> {
+  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> self;
   private final AccountResolver resolver;
   private final AccountControl.Factory accountControlFactory;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<SuggestAccounts> list;
+  private final Provider<QueryAccounts> list;
   private final DynamicMap<RestView<AccountResource>> views;
   private final CreateAccount.Factory createAccountFactory;
 
   @Inject
-  AccountsCollection(Provider<CurrentUser> self,
+  AccountsCollection(Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
       IdentifiedUser.GenericFactory userFactory,
-      Provider<SuggestAccounts> list,
+      Provider<QueryAccounts> list,
       DynamicMap<RestView<AccountResource>> views,
       CreateAccount.Factory createAccountFactory) {
+    this.db = db;
     this.self = self;
     this.resolver = resolver;
     this.accountControlFactory = accountControlFactory;
@@ -122,7 +126,7 @@
       }
     }
 
-    Account match = resolver.find(id);
+    Account match = resolver.find(db.get(), id);
     if (match == null) {
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 7ec659e..216672c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -16,35 +16,32 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.Iterables;
 import com.google.common.io.ByteSource;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
-import com.google.gerrit.server.account.GetSshKeys.SshKeyInfo;
 import com.google.gerrit.server.mail.AddKeySender;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.Collections;
 
 @Singleton
 public class AddSshKey implements RestModifyView<AccountResource, Input> {
@@ -55,22 +52,25 @@
   }
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AddKeySender.Factory addKeyFactory;
 
   @Inject
-  AddSshKey(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider,
-      SshKeyCache sshKeyCache, AddKeySender.Factory addKeyFactory) {
+  AddSshKey(Provider<CurrentUser> self,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      AddKeySender.Factory addKeyFactory) {
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.addKeyFactory = addKeyFactory;
   }
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException {
+      throws AuthException, BadRequestException, OrmException, IOException,
+      ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to add SSH keys");
@@ -79,7 +79,8 @@
   }
 
   public Response<SshKeyInfo> apply(IdentifiedUser user, Input input)
-      throws BadRequestException, OrmException, IOException {
+      throws BadRequestException, IOException,
+      ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
@@ -87,11 +88,6 @@
       throw new BadRequestException("SSH public key missing");
     }
 
-    ResultSet<AccountSshKey> byAccountLast =
-        dbProvider.get().accountSshKeys().byAccountLast(user.getAccountId());
-    AccountSshKey last = Iterables.getOnlyElement(byAccountLast, null);
-    int max = last == null ? 0 : last.getKey().get();
-
     final RawInput rawKey = input.raw;
     String sshPublicKey = new ByteSource() {
       @Override
@@ -102,17 +98,17 @@
 
     try {
       AccountSshKey sshKey =
-          sshKeyCache.create(new AccountSshKey.Id(
-              user.getAccountId(), max + 1), sshPublicKey);
-      dbProvider.get().accountSshKeys().insert(Collections.singleton(sshKey));
+          authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
+
       try {
         addKeyFactory.create(user, sshKey).send();
       } catch (EmailException e) {
         log.error("Cannot send SSH key added message to "
             + user.getAccount().getPreferredEmail(), e);
       }
+
       sshKeyCache.evict(user.getUserName());
-      return Response.<SshKeyInfo>created(new SshKeyInfo(sshKey));
+      return Response.<SshKeyInfo>created(GetSshKeys.newSshKeyInfo(sshKey));
     } catch (InvalidSshKeyException e) {
       throw new BadRequestException(e.getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index 35ab3af..c585f97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
 
@@ -38,6 +39,15 @@
     return r;
   }
 
+  /** Create a request for an external username. */
+  public static AuthRequest forExternalUser(String username) {
+    AccountExternalId.Key i =
+        new AccountExternalId.Key(SCHEME_EXTERNAL, username);
+    AuthRequest r = new AuthRequest(i.get());
+    r.setUserName(username);
+    return r;
+  }
+
   /**
    * Create a request for an email address registration.
    * <p>
@@ -58,6 +68,8 @@
   private String emailAddress;
   private String userName;
   private boolean skipAuthentication;
+  private String authPlugin;
+  private String authProvider;
 
   public AuthRequest(final String externalId) {
     this.externalId = externalId;
@@ -125,4 +137,20 @@
   public void setSkipAuthentication(boolean skip) {
     skipAuthentication = skip;
   }
+
+  public String getAuthPlugin() {
+    return authPlugin;
+  }
+
+  public void setAuthPlugin(String authPlugin) {
+    this.authPlugin = authPlugin;
+  }
+
+  public String getAuthProvider() {
+    return authProvider;
+  }
+
+  public void setAuthProvider(String authProvider) {
+    this.authProvider = authProvider;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
new file mode 100644
index 0000000..0e8c051
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+public class AuthorizedKeys {
+  public static final String FILE_NAME = "authorized_keys";
+
+  @VisibleForTesting
+  public static final String INVALID_KEY_COMMENT_PREFIX = "# INVALID ";
+
+  @VisibleForTesting
+  public static final String DELETED_KEY_COMMENT = "# DELETED";
+
+  public static List<Optional<AccountSshKey>> parse(
+      Account.Id accountId, String s) {
+    List<Optional<AccountSshKey>> keys = new ArrayList<>();
+    int seq = 1;
+    for (String line : s.split("\\r?\\n")) {
+      line = line.trim();
+      if (line.isEmpty()) {
+        continue;
+      } else if (line.startsWith(INVALID_KEY_COMMENT_PREFIX)) {
+        String pub = line.substring(INVALID_KEY_COMMENT_PREFIX.length());
+        AccountSshKey key =
+            new AccountSshKey(new AccountSshKey.Id(accountId, seq++), pub);
+        key.setInvalid();
+        keys.add(Optional.of(key));
+      } else if (line.startsWith(DELETED_KEY_COMMENT)) {
+        keys.add(Optional.<AccountSshKey> absent());
+        seq++;
+      } else if (line.startsWith("#")) {
+        continue;
+      } else {
+        AccountSshKey key =
+            new AccountSshKey(new AccountSshKey.Id(accountId, seq++), line);
+        keys.add(Optional.of(key));
+      }
+    }
+    return keys;
+  }
+
+  public static String serialize(Collection<Optional<AccountSshKey>> keys) {
+    StringBuilder b = new StringBuilder();
+    for (Optional<AccountSshKey> key : keys) {
+      if (key.isPresent()) {
+        if (!key.get().isValid()) {
+          b.append(INVALID_KEY_COMMENT_PREFIX);
+        }
+        b.append(key.get().getSshPublicKey().trim());
+      } else {
+        b.append(DELETED_KEY_COMMENT);
+      }
+      b.append("\n");
+    }
+    return b.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
index 3017f73..4bf4214 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -14,32 +14,46 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Caches active {@link GlobalCapability} set for a site. */
 public class CapabilityCollection {
-  private final Map<String, List<PermissionRule>> permissions;
+  public interface Factory {
+    CapabilityCollection create(@Nullable AccessSection section);
+  }
 
-  public final List<PermissionRule> administrateServer;
-  public final List<PermissionRule> batchChangesLimit;
-  public final List<PermissionRule> emailReviewers;
-  public final List<PermissionRule> priority;
-  public final List<PermissionRule> queryLimit;
+  private final ImmutableMap<String, ImmutableList<PermissionRule>> permissions;
 
-  public CapabilityCollection(AccessSection section) {
+  public final ImmutableList<PermissionRule> administrateServer;
+  public final ImmutableList<PermissionRule> batchChangesLimit;
+  public final ImmutableList<PermissionRule> emailReviewers;
+  public final ImmutableList<PermissionRule> priority;
+  public final ImmutableList<PermissionRule> queryLimit;
+
+  @Inject
+  CapabilityCollection(
+      @AdministrateServerGroups ImmutableSet<GroupReference> admins,
+      @Assisted @Nullable AccessSection section) {
     if (section == null) {
       section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
     }
@@ -61,18 +75,19 @@
       }
     }
     configureDefaults(tmp, section);
+    if (!tmp.containsKey(GlobalCapability.ADMINISTRATE_SERVER) && !admins.isEmpty()) {
+      tmp.put(GlobalCapability.ADMINISTRATE_SERVER, ImmutableList.<PermissionRule>of());
+    }
 
-    Map<String, List<PermissionRule>> res = new HashMap<>();
+    ImmutableMap.Builder<String, ImmutableList<PermissionRule>> m = ImmutableMap.builder();
     for (Map.Entry<String, List<PermissionRule>> e : tmp.entrySet()) {
       List<PermissionRule> rules = e.getValue();
-      if (rules.size() == 1) {
-        res.put(e.getKey(), Collections.singletonList(rules.get(0)));
-      } else {
-        res.put(e.getKey(), Collections.unmodifiableList(
-            Arrays.asList(rules.toArray(new PermissionRule[rules.size()]))));
+      if (GlobalCapability.ADMINISTRATE_SERVER.equals(e.getKey())) {
+        rules = mergeAdmin(admins, rules);
       }
+      m.put(e.getKey(), ImmutableList.copyOf(rules));
     }
-    permissions = Collections.unmodifiableMap(res);
+    permissions = m.build();
 
     administrateServer = getPermission(GlobalCapability.ADMINISTRATE_SERVER);
     batchChangesLimit = getPermission(GlobalCapability.BATCH_CHANGES_LIMIT);
@@ -81,9 +96,27 @@
     queryLimit = getPermission(GlobalCapability.QUERY_LIMIT);
   }
 
-  public List<PermissionRule> getPermission(String permissionName) {
-    List<PermissionRule> r = permissions.get(permissionName);
-    return r != null ? r : Collections.<PermissionRule> emptyList();
+  private static List<PermissionRule> mergeAdmin(Set<GroupReference> admins,
+      List<PermissionRule> rules) {
+    if (admins.isEmpty()) {
+      return rules;
+    }
+
+    List<PermissionRule> r = new ArrayList<>(admins.size() + rules.size());
+    for (GroupReference g : admins) {
+      r.add(new PermissionRule(g));
+    }
+    for (PermissionRule rule : rules) {
+      if (!admins.contains(rule.getGroup())) {
+        r.add(rule);
+      }
+    }
+    return r;
+  }
+
+  public ImmutableList<PermissionRule> getPermission(String permissionName) {
+    ImmutableList<PermissionRule> r = permissions.get(permissionName);
+    return r != null ? r : ImmutableList.<PermissionRule> of();
   }
 
   private static final GroupReference anonymous = SystemGroupBackend
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index 7bb00c5..e348e73 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -39,8 +39,8 @@
 
 /** Access control management for server-wide capabilities. */
 public class CapabilityControl {
-  public static interface Factory {
-    public CapabilityControl create(CurrentUser user);
+  public interface Factory {
+    CapabilityControl create(CurrentUser user);
   }
 
   private final CapabilityCollection capabilities;
@@ -215,18 +215,16 @@
     if (batch) {
       // If any of our groups matched to the BATCH queue, use it.
       return QueueProvider.QueueType.BATCH;
-    } else {
-      return QueueProvider.QueueType.INTERACTIVE;
     }
+    return QueueProvider.QueueType.INTERACTIVE;
   }
 
   /** True if the user has this permission. Works only for non labels. */
   public boolean canPerform(String permissionName) {
     if (GlobalCapability.ADMINISTRATE_SERVER.equals(permissionName)) {
       return canAdministrateServer();
-    } else {
-      return !access(permissionName).isEmpty();
     }
+    return !access(permissionName).isEmpty();
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -265,20 +263,7 @@
     }
 
     rules = capabilities.getPermission(permissionName);
-
-    if (rules.isEmpty()) {
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
     GroupMembership groups = user.getEffectiveGroups();
-    if (rules.size() == 1) {
-      if (!match(groups, rules.get(0))) {
-        rules = Collections.emptyList();
-      }
-      effective.put(permissionName, rules);
-      return rules;
-    }
 
     List<PermissionRule> mine = new ArrayList<>(rules.size());
     for (PermissionRule rule : rules) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
index c26b1ab..2bf147d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
@@ -32,6 +32,11 @@
       .getLogger(CapabilityUtils.class);
 
   public static void checkRequiresCapability(Provider<CurrentUser> userProvider,
+      String pluginName, Class<?> clazz) throws AuthException {
+    checkRequiresCapability(userProvider.get(), pluginName, clazz);
+  }
+
+  public static void checkRequiresCapability(CurrentUser user,
       String pluginName, Class<?> clazz)
       throws AuthException {
     RequiresCapability rc = getClassAnnotation(clazz, RequiresCapability.class);
@@ -45,7 +50,6 @@
           RequiresAnyCapability.class.getSimpleName()));
       throw new AuthException("cannot check capability");
     }
-    CurrentUser user = userProvider.get();
     CapabilityControl ctl = user.getCapabilities();
     if (ctl.canAdministrateServer()) {
       return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index b413e81..c1ecafd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.InvalidUserNameException;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -30,6 +29,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -72,7 +72,7 @@
 
   @Override
   public VoidResult call() throws OrmException, NameAlreadyUsedException,
-      InvalidUserNameException {
+      InvalidUserNameException, IOException {
     final Collection<AccountExternalId> old = old();
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index c4f35e1..8d121c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.collect.Sets;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -33,11 +33,11 @@
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.CreateAccount.Input;
+import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.mail.OutgoingEmailValidator;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -46,59 +46,69 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
 import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Set;
 
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-public class CreateAccount implements RestModifyView<TopLevelResource, Input> {
-  public static class Input {
-    @DefaultInput
-    public String username;
-    public String name;
-    public String email;
-    public String sshKey;
-    public String httpPassword;
-    public List<String> groups;
-  }
-
-  public static interface Factory {
+public class CreateAccount
+    implements RestModifyView<TopLevelResource, AccountInput> {
+  public interface Factory {
     CreateAccount create(String username);
   }
 
   private final ReviewDb db;
   private final Provider<IdentifiedUser> currentUser;
   private final GroupsCollection groupsCollection;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AccountCache accountCache;
+  private final AccountIndexer indexer;
   private final AccountByEmailCache byEmailCache;
   private final AccountLoader.Factory infoLoader;
-  private final String username;
+  private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final String username;
 
   @Inject
-  CreateAccount(ReviewDb db, Provider<IdentifiedUser> currentUser,
-      GroupsCollection groupsCollection, SshKeyCache sshKeyCache,
-      AccountCache accountCache, AccountByEmailCache byEmailCache,
+  CreateAccount(ReviewDb db,
+      Provider<IdentifiedUser> currentUser,
+      GroupsCollection groupsCollection,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      AccountCache accountCache,
+      AccountIndexer indexer,
+      AccountByEmailCache byEmailCache,
       AccountLoader.Factory infoLoader,
-      @Assisted String username, AuditService auditService) {
+      DynamicSet<AccountExternalIdCreator> externalIdCreators,
+      AuditService auditService,
+      @Assisted String username) {
     this.db = db;
     this.currentUser = currentUser;
     this.groupsCollection = groupsCollection;
+    this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
+    this.indexer = indexer;
     this.byEmailCache = byEmailCache;
     this.infoLoader = infoLoader;
-    this.username = username;
+    this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.username = username;
   }
 
   @Override
-  public Response<AccountInfo> apply(TopLevelResource rsrc, Input input)
+  public Response<AccountInfo> apply(TopLevelResource rsrc, AccountInput input)
       throws BadRequestException, ResourceConflictException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException,
+      ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new AccountInput();
     }
     if (input.username != null && !username.equals(input.username)) {
       throw new BadRequestException("username must match URL");
@@ -112,7 +122,6 @@
     Set<AccountGroup.Id> groups = parseGroups(input.groups);
 
     Account.Id id = new Account.Id(db.nextAccountId());
-    AccountSshKey key = createSshKey(id, input.sshKey);
 
     AccountExternalId extUser =
         new AccountExternalId(id, new AccountExternalId.Key(
@@ -136,8 +145,14 @@
       }
     }
 
+    LinkedList<AccountExternalId> externalIds = new LinkedList<>();
+    externalIds.add(extUser);
+    for (AccountExternalIdCreator c : externalIdCreators) {
+      externalIds.addAll(c.create(id, username, input.email));
+    }
+
     try {
-      db.accountExternalIds().insert(Collections.singleton(extUser));
+      db.accountExternalIds().insert(externalIds);
     } catch (OrmDuplicateKeyException duplicateKey) {
       throw new ResourceConflictException(
           "username '" + username + "' already exists");
@@ -165,10 +180,6 @@
     a.setPreferredEmail(input.email);
     db.accounts().insert(Collections.singleton(a));
 
-    if (key != null) {
-      db.accountSshKeys().insert(Collections.singleton(key));
-    }
-
     for (AccountGroup.Id groupId : groups) {
       AccountGroupMember m =
           new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
@@ -177,9 +188,18 @@
       db.accountGroupMembers().insert(Collections.singleton(m));
     }
 
-    sshKeyCache.evict(username);
+    if (input.sshKey != null) {
+      try {
+        authorizedKeys.addKey(id, input.sshKey);
+        sshKeyCache.evict(username);
+      } catch (InvalidSshKeyException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+    }
+
     accountCache.evictByUsername(username);
     byEmailCache.evict(input.email);
+    indexer.index(id);
 
     AccountLoader loader = infoLoader.create(true);
     AccountInfo info = loader.get(id);
@@ -189,7 +209,7 @@
 
   private Set<AccountGroup.Id> parseGroups(List<String> groups)
       throws UnprocessableEntityException {
-    Set<AccountGroup.Id> groupIds = Sets.newHashSet();
+    Set<AccountGroup.Id> groupIds = new HashSet<>();
     if (groups != null) {
       for (String g : groups) {
         groupIds.add(GroupDescriptions.toAccountGroup(
@@ -199,18 +219,6 @@
     return groupIds;
   }
 
-  private AccountSshKey createSshKey(Account.Id id, String sshKey)
-      throws BadRequestException {
-    if (sshKey == null) {
-      return null;
-    }
-    try {
-      return sshKeyCache.create(new AccountSshKey.Id(id, 1), sshKey.trim());
-    } catch (InvalidSshKeyException e) {
-      throw new BadRequestException(e.getMessage());
-    }
-  }
-
   private AccountExternalId.Key getEmailKey(String email) {
     return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 5c903d7..713154c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GetEmails.EmailInfo;
-import com.google.gerrit.server.mail.OutgoingEmailValidator;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.RegisterNewEmailSender;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -39,10 +39,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+
 public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateEmail.class);
 
-  public static interface Factory {
+  public interface Factory {
     CreateEmail create(String email);
   }
 
@@ -75,7 +77,7 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
       ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException {
+      MethodNotAllowedException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to add email address");
@@ -104,7 +106,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
       ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException {
+      MethodNotAllowedException, IOException {
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
index 5978703..eb3c9a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AuthType;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,13 +52,14 @@
         default:
           return true;
       }
-    } else {
-      switch (field) {
-        case REGISTER_NEW_EMAIL:
-          return authConfig.isAllowRegisterNewEmail();
-        default:
-          return true;
-      }
+    }
+    switch (field) {
+      case REGISTER_NEW_EMAIL:
+        return authConfig.isAllowRegisterNewEmail();
+      case FULL_NAME:
+      case USER_NAME:
+      default:
+        return true;
     }
   }
 
@@ -73,16 +73,6 @@
   }
 
   @Override
-  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who) {
-    return who;
-  }
-
-  @Override
-  public AuthRequest unlink(ReviewDb db, Account.Id from, AuthRequest who) {
-    return who;
-  }
-
-  @Override
   public void onCreateAccount(final AuthRequest who, final Account account) {
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index abdaf23..f6c48af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -27,6 +27,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -46,7 +47,7 @@
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index f1e02bd..76f63b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -31,6 +31,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
   public static class Input {
@@ -53,7 +55,8 @@
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, MethodNotAllowedException, OrmException {
+      ResourceConflictException, MethodNotAllowedException, OrmException,
+      IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to delete email address");
@@ -63,7 +66,7 @@
 
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException,
-      MethodNotAllowedException, OrmException {
+      MethodNotAllowedException, OrmException, IOException {
     if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index 9066858..9212002 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.DeleteSshKey.Input;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -26,7 +25,10 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+import java.io.IOException;
 
 @Singleton
 public class DeleteSshKey implements
@@ -35,28 +37,31 @@
   }
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
 
   @Inject
-  DeleteSshKey(Provider<ReviewDb> dbProvider,
-      Provider<CurrentUser> self,
+  DeleteSshKey(Provider<CurrentUser> self,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache) {
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
   }
 
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, RepositoryNotFoundException,
+      IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to delete SSH keys");
     }
-    dbProvider.get().accountSshKeys()
-        .deleteKeys(Collections.singleton(rsrc.getSshKey().getKey()));
+
+    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(),
+        rsrc.getSshKey().getKey().get());
     sshKeyCache.evict(rsrc.getUser().getUserName());
+
     return Response.none();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
new file mode 100644
index 0000000..e2fbc3c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+
+@Singleton
+public class DeleteWatchedProjects
+    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<IdentifiedUser> self;
+  private final AccountCache accountCache;
+  private final WatchConfig.Accessor watchConfig;
+
+  @Inject
+  DeleteWatchedProjects(Provider<ReviewDb> dbProvider,
+      Provider<IdentifiedUser> self,
+      AccountCache accountCache,
+      WatchConfig.Accessor watchConfig) {
+    this.dbProvider = dbProvider;
+    this.self = self;
+    this.accountCache = accountCache;
+    this.watchConfig = watchConfig;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+      throws AuthException, UnprocessableEntityException, OrmException,
+      IOException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()
+        && !self.get().getCapabilities().canAdministrateServer()) {
+      throw new AuthException("It is not allowed to edit project watches "
+          + "of other users");
+    }
+    if (input == null) {
+      return Response.none();
+    }
+
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    deleteFromDb(accountId, input);
+    deleteFromGit(accountId, input);
+    accountCache.evict(accountId);
+    return Response.none();
+  }
+
+  private void deleteFromDb(Account.Id accountId, List<ProjectWatchInfo> input)
+      throws OrmException, IOException {
+    ResultSet<AccountProjectWatch> watchedProjects =
+        dbProvider.get().accountProjectWatches().byAccount(accountId);
+    HashMap<AccountProjectWatch.Key, AccountProjectWatch> watchedProjectsMap =
+        new HashMap<>();
+    for (AccountProjectWatch watchedProject : watchedProjects) {
+      watchedProjectsMap.put(watchedProject.getKey(), watchedProject);
+    }
+
+    List<AccountProjectWatch> watchesToDelete = new LinkedList<>();
+    for (ProjectWatchInfo projectInfo : input) {
+      AccountProjectWatch.Key key = new AccountProjectWatch.Key(accountId,
+          new Project.NameKey(projectInfo.project), projectInfo.filter);
+      if (watchedProjectsMap.containsKey(key)) {
+        watchesToDelete.add(watchedProjectsMap.get(key));
+      }
+    }
+    if (!watchesToDelete.isEmpty()) {
+      dbProvider.get().accountProjectWatches().delete(watchesToDelete);
+      accountCache.evict(accountId);
+    }
+  }
+
+  private void deleteFromGit(Account.Id accountId, List<ProjectWatchInfo> input)
+      throws IOException, ConfigInvalidException {
+    watchConfig.deleteProjectWatches(accountId, Lists.transform(input,
+        new Function<ProjectWatchInfo, ProjectWatchKey>() {
+          @Override
+          public ProjectWatchKey apply(ProjectWatchInfo info) {
+            return ProjectWatchKey.create(new Project.NameKey(info.project),
+                info.filter);
+          }
+        }));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
index f60492e..75408c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
@@ -19,11 +19,11 @@
  * Expands user name to a local email address, usually by adding a domain.
  */
 public interface EmailExpander {
-  public boolean canExpand(String user);
+  boolean canExpand(String user);
 
-  public String expand(String user);
+  String expand(String user);
 
-  public static class None implements EmailExpander {
+  class None implements EmailExpander {
     public static final None INSTANCE = new None();
 
     public static boolean canHandle(final String fmt) {
@@ -44,7 +44,7 @@
     }
   }
 
-  public static class Simple implements EmailExpander {
+  class Simple implements EmailExpander {
     private static final String PLACEHOLDER = "{0}";
 
     public static boolean canHandle(final String fmt) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
index f8d6bd1..d3b938f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 
 /** Fake implementation of {@link Realm} that does not communicate. */
 public class FakeRealm extends AbstractRealm {
@@ -31,16 +30,6 @@
   }
 
   @Override
-  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who) {
-    return who;
-  }
-
-  @Override
-  public AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who) {
-    return who;
-  }
-
-  @Override
   public void onCreateAccount(AuthRequest who, Account account) {
     // Do nothing.
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
new file mode 100644
index 0000000..8339baf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class GeneralPreferencesLoader {
+  private static final Logger log =
+      LoggerFactory.getLogger(GeneralPreferencesLoader.class);
+
+  private final GitRepositoryManager gitMgr;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public GeneralPreferencesLoader(GitRepositoryManager gitMgr,
+      AllUsersName allUsersName) {
+    this.gitMgr = gitMgr;
+    this.allUsersName = allUsersName;
+  }
+
+  public GeneralPreferencesInfo load(Account.Id id)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    return read(id, null);
+  }
+
+  public GeneralPreferencesInfo merge(Account.Id id,
+      GeneralPreferencesInfo in) throws IOException,
+          ConfigInvalidException, RepositoryNotFoundException {
+    return read(id, in);
+  }
+
+  private GeneralPreferencesInfo read(Account.Id id,
+      GeneralPreferencesInfo in) throws IOException,
+          ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository allUsers = gitMgr.openRepository(allUsersName)) {
+      // Load all users default prefs
+      VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
+      dp.load(allUsers);
+      GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+      loadSection(dp.getConfig(), UserConfigSections.GENERAL, null, allUserPrefs,
+          GeneralPreferencesInfo.defaults(), in);
+
+      // Load user prefs
+      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
+      p.load(allUsers);
+      GeneralPreferencesInfo r =
+          loadSection(p.getConfig(), UserConfigSections.GENERAL, null,
+          new GeneralPreferencesInfo(),
+          updateDefaults(allUserPrefs), in);
+
+      return loadMyMenusAndUrlAliases(r, p, dp);
+    }
+  }
+
+  private GeneralPreferencesInfo updateDefaults(GeneralPreferencesInfo input) {
+    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.error(
+          "Cannot get default general preferences from " + allUsersName.get(),
+          e);
+      return GeneralPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  public GeneralPreferencesInfo loadMyMenusAndUrlAliases(
+      GeneralPreferencesInfo r, VersionedAccountPreferences v, VersionedAccountPreferences d) {
+    r.my = my(v);
+    if (r.my.isEmpty() && !v.isDefaults()) {
+      r.my = my(d);
+    }
+    if (r.my.isEmpty()) {
+      r.my.add(new MenuItem("Changes", "#/dashboard/self", null));
+      r.my.add(new MenuItem("Drafts", "#/q/owner:self+is:draft", null));
+      r.my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
+      r.my.add(new MenuItem("Edits", "#/q/has:edit", null));
+      r.my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open",
+          null));
+      r.my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      r.my.add(new MenuItem("Groups", "#/groups/self", null));
+    }
+
+    r.urlAliases = urlAliases(v);
+    if (r.urlAliases == null && !v.isDefaults()) {
+      r.urlAliases = urlAliases(d);
+    }
+    return r;
+  }
+
+  private static List<MenuItem> my(VersionedAccountPreferences v) {
+    List<MenuItem> my = new ArrayList<>();
+    Config cfg = v.getConfig();
+    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
+      String url = my(cfg, subsection, KEY_URL, "#/");
+      String target = my(cfg, subsection, KEY_TARGET,
+          url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(
+          subsection, url, target,
+          my(cfg, subsection, KEY_ID, null)));
+    }
+    return my;
+  }
+
+  private static String my(Config cfg, String subsection, String key,
+      String defaultValue) {
+    String val = cfg.getString(UserConfigSections.MY, subsection, key);
+    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
+  }
+
+  private static Map<String, String> urlAliases(VersionedAccountPreferences v) {
+    HashMap<String, String> urlAliases = new HashMap<>();
+    Config cfg = v.getConfig();
+    for (String subsection : cfg.getSubsections(URL_ALIAS)) {
+      urlAliases.put(cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
+         cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+    }
+    return !urlAliases.isEmpty() ? urlAliases : null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
new file mode 100644
index 0000000..9e1201a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@Singleton
+public class GetAgreements implements RestReadView<AccountResource> {
+  private static final Logger log =
+      LoggerFactory.getLogger(GetAgreements.class);
+
+  private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
+  private final boolean agreementsEnabled;
+
+  @Inject
+  GetAgreements(Provider<CurrentUser> self,
+      ProjectCache projectCache,
+      @GerritServerConfig Config config) {
+    this.self = self;
+    this.projectCache = projectCache;
+    this.agreementsEnabled =
+        config.getBoolean("auth", "contributorAgreements", false);
+  }
+
+  @Override
+  public List<AgreementInfo> apply(AccountResource resource)
+      throws RestApiException {
+    if (!agreementsEnabled) {
+      throw new MethodNotAllowedException("contributor agreements disabled");
+    }
+
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("not allowed to get contributor agreements");
+    }
+
+    IdentifiedUser user = self.get().asIdentifiedUser();
+    if (user != resource.getUser()) {
+      throw new AuthException("not allowed to get contributor agreements");
+    }
+
+    List<AgreementInfo> results = new ArrayList<>();
+    Collection<ContributorAgreement> cas =
+        projectCache.getAllProjects().getConfig().getContributorAgreements();
+    for (ContributorAgreement ca : cas) {
+      List<AccountGroup.UUID> groupIds = new ArrayList<>();
+      for (PermissionRule rule : ca.getAccepted()) {
+        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
+          if (rule.getGroup().getUUID() != null) {
+            groupIds.add(rule.getGroup().getUUID());
+          } else {
+            log.warn("group \"" + rule.getGroup().getName() + "\" does not " +
+                " exist, referenced in CLA \"" + ca.getName() + "\"");
+          }
+        }
+      }
+
+      if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
+        AgreementInfo info = new AgreementInfo();
+        info.name = ca.getName();
+        info.description = ca.getDescription();
+        info.url = ca.getAgreementUrl();
+        results.add(info);
+      }
+    }
+    return results;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
index a96e713..1953c63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
@@ -27,12 +27,16 @@
 
 import java.util.concurrent.TimeUnit;
 
-class GetAvatar implements RestReadView<AccountResource> {
+public class GetAvatar implements RestReadView<AccountResource> {
   private final DynamicItem<AvatarProvider> avatarProvider;
 
+  private int size;
+
   @Option(name = "--size", aliases = {"-s"},
       usage = "recommended size in pixels, height and width")
-  private int size;
+  public void setSize(int s) {
+    size = s;
+  }
 
   @Inject
   GetAvatar(DynamicItem<AvatarProvider> avatarProvider) {
@@ -52,8 +56,7 @@
     if (Strings.isNullOrEmpty(url)) {
       throw (new ResourceNotFoundException())
           .caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
-    } else {
-      return Response.redirect(url);
     }
+    return Response.redirect(url);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
index ccff183..ec020fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
@@ -42,8 +42,7 @@
     String url = impl.getChangeAvatarUrl(rsrc.getUser());
     if (Strings.isNullOrEmpty(url)) {
       throw new ResourceNotFoundException();
-    } else {
-      return url;
     }
+    return url;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
index d231767..cbd0e32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
@@ -33,8 +33,6 @@
 import static com.google.gerrit.common.data.GlobalCapability.VIEW_QUEUE;
 
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
@@ -54,7 +52,9 @@
 
 import org.kohsuke.args4j.Option;
 
+import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
 
@@ -62,7 +62,7 @@
   @Option(name = "-q", metaVar = "CAP", usage = "Capability to inspect")
   void addQuery(String name) {
     if (query == null) {
-      query = Sets.newHashSet();
+      query = new HashSet<>();
     }
     Iterables.addAll(query, OptionUtil.splitOptionValue(name));
   }
@@ -86,7 +86,7 @@
     }
 
     CapabilityControl cc = resource.getUser().getCapabilities();
-    Map<String, Object> have = Maps.newLinkedHashMap();
+    Map<String, Object> have = new LinkedHashMap<>();
     for (String name : GlobalCapability.getAllNames()) {
       if (!name.equals(PRIORITY) && want(name) && cc.canPerform(name)) {
         if (GlobalCapability.hasRange(name)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
index be87ae7..2c4a840 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -31,11 +32,17 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.lang.reflect.Field;
 
 @Singleton
 public class GetDiffPreferences implements RestReadView<AccountResource> {
+  private static final Logger log =
+      LoggerFactory.getLogger(GetDiffPreferences.class);
+
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
   private final GitRepositoryManager gitMgr;
@@ -66,13 +73,41 @@
       DiffPreferencesInfo in)
       throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
+      // Load all users prefs.
+      VersionedAccountPreferences dp =
+          VersionedAccountPreferences.forDefault();
+      dp.load(git);
+      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+      loadSection(dp.getConfig(), UserConfigSections.DIFF, null, allUserPrefs,
+          DiffPreferencesInfo.defaults(), in);
+
+      // Load user prefs
       VersionedAccountPreferences p =
           VersionedAccountPreferences.forUser(id);
       p.load(git);
       DiffPreferencesInfo prefs = new DiffPreferencesInfo();
       loadSection(p.getConfig(), UserConfigSections.DIFF, null, prefs,
-          DiffPreferencesInfo.defaults(), in);
+          updateDefaults(allUserPrefs), in);
       return prefs;
     }
   }
+
+  private static DiffPreferencesInfo updateDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Cannot get default diff preferences from All-Users", e);
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
index d99b68f..02cfaa0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
@@ -54,7 +54,7 @@
       IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("restricted to members of Modify Accounts");
+      throw new AuthException("requires Modify Account capability");
     }
 
     return readFromGit(
@@ -74,4 +74,4 @@
           new EditPreferencesInfo(), EditPreferencesInfo.defaults(), in);
     }
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
index a4a6bd0..6763578b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
@@ -16,10 +16,15 @@
 
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.GetEmails.EmailInfo;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetEmail implements RestReadView<AccountResource.Email> {
+  @Inject
+  public GetEmail() {
+  }
+
   @Override
   public EmailInfo apply(AccountResource.Email rsrc) {
     EmailInfo e = new EmailInfo();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
index bf9c9ec..14cc74e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
@@ -14,37 +14,20 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
 @Singleton
 public class GetEmails implements RestReadView<AccountResource> {
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  public GetEmails(Provider<CurrentUser> self) {
-    this.self = self;
-  }
 
   @Override
-  public List<EmailInfo> apply(AccountResource rsrc) throws AuthException,
-      OrmException {
-    if (self.get() != rsrc.getUser()
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to list email addresses");
-    }
-
-    List<EmailInfo> emails = Lists.newArrayList();
+  public List<EmailInfo> apply(AccountResource rsrc) {
+    List<EmailInfo> emails = new ArrayList<>();
     for (String email : rsrc.getUser().getEmailAddresses()) {
       if (email != null) {
         EmailInfo e = new EmailInfo();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
index f777145..5b71e0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -26,6 +25,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
 import java.util.List;
 
 @Singleton
@@ -43,7 +43,7 @@
   public List<GroupInfo> apply(AccountResource resource) throws OrmException {
     IdentifiedUser user = resource.getUser();
     Account.Id userId = user.getAccountId();
-    List<GroupInfo> groups = Lists.newArrayList();
+    List<GroupInfo> groups = new ArrayList<>();
     for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
       GroupControl ctl;
       try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
new file mode 100644
index 0000000..5d343c4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+@Singleton
+class GetOAuthToken implements RestReadView<AccountResource>{
+
+  private static final String BEARER_TYPE = "bearer";
+
+  private final Provider<CurrentUser> self;
+  private final OAuthTokenCache tokenCache;
+  private final String hostName;
+
+  @Inject
+  GetOAuthToken(Provider<CurrentUser> self,
+      OAuthTokenCache tokenCache,
+      @CanonicalWebUrl Provider<String> urlProvider) {
+    this.self = self;
+    this.tokenCache = tokenCache;
+    this.hostName = getHostName(urlProvider.get());
+  }
+
+  @Override
+  public OAuthTokenInfo apply(AccountResource rsrc) throws AuthException,
+      ResourceNotFoundException {
+    if (self.get() != rsrc.getUser()) {
+      throw new AuthException("not allowed to get access token");
+    }
+    Account a = rsrc.getUser().getAccount();
+    OAuthToken accessToken = tokenCache.get(a.getId());
+    if (accessToken == null) {
+      throw new ResourceNotFoundException();
+    }
+    OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
+    accessTokenInfo.username = a.getUserName();
+    accessTokenInfo.resourceHost = hostName;
+    accessTokenInfo.accessToken = accessToken.getToken();
+    accessTokenInfo.providerId = accessToken.getProviderId();
+    accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
+    accessTokenInfo.type = BEARER_TYPE;
+    return accessTokenInfo;
+  }
+
+  private static String getHostName(String canonicalWebUrl) {
+    try {
+      return new URI(canonicalWebUrl).getHost();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
+
+  public static class OAuthTokenInfo {
+    public String username;
+    public String resourceHost;
+    public String accessToken;
+    public String providerId;
+    public String expiresAt;
+    public String type;
+  }
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 08bf83e..3e83f4c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -14,182 +14,36 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 @Singleton
 public class GetPreferences implements RestReadView<AccountResource> {
-  private static final Logger log = LoggerFactory.getLogger(GetPreferences.class);
-
-  public static final String KEY_URL = "url";
-  public static final String KEY_TARGET = "target";
-  public static final String KEY_ID = "id";
-  public static final String URL_ALIAS = "urlAlias";
-  public static final String KEY_MATCH = "match";
-  public static final String KEY_TOKEN = "token";
-
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> db;
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitMgr;
+  private final AccountCache accountCache;
 
   @Inject
-  GetPreferences(Provider<CurrentUser> self, Provider<ReviewDb> db,
-      AllUsersName allUsersName,
-      GitRepositoryManager gitMgr) {
+  GetPreferences(Provider<CurrentUser> self,
+      AccountCache accountCache) {
     this.self = self;
-    this.db = db;
-    this.allUsersName = allUsersName;
-    this.gitMgr = gitMgr;
+    this.accountCache = accountCache;
   }
 
   @Override
-  public PreferenceInfo apply(AccountResource rsrc)
-      throws AuthException,
-      ResourceNotFoundException,
-      OrmException,
-      IOException,
-      ConfigInvalidException {
+  public GeneralPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException {
     if (self.get() != rsrc.getUser()
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
-    }
-    Account a = db.get().accounts().get(rsrc.getUser().getAccountId());
-    if (a == null) {
-      throw new ResourceNotFoundException();
+        && !self.get().getCapabilities().canModifyAccount()) {
+      throw new AuthException("requires Modify Account capability");
     }
 
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p =
-          VersionedAccountPreferences.forUser(rsrc.getUser().getAccountId());
-      p.load(git);
-      return new PreferenceInfo(a.getGeneralPreferences(), p, git);
-    }
-  }
-
-  public static class PreferenceInfo {
-    Short changesPerPage;
-    Boolean showSiteHeader;
-    Boolean useFlashClipboard;
-    String downloadScheme;
-    DownloadCommand downloadCommand;
-    Boolean copySelfOnEmail;
-    DateFormat dateFormat;
-    TimeFormat timeFormat;
-    Boolean relativeDateInChangeTable;
-    Boolean sizeBarInChangeTable;
-    Boolean legacycidInChangeTable;
-    Boolean muteCommonPathPrefixes;
-    ReviewCategoryStrategy reviewCategoryStrategy;
-    DiffView diffView;
-    List<TopMenu.MenuItem> my;
-    Map<String, String> urlAliases;
-
-    public PreferenceInfo(AccountGeneralPreferences p,
-        VersionedAccountPreferences v, Repository allUsers) {
-      if (p != null) {
-        changesPerPage = p.getMaximumPageSize();
-        showSiteHeader = p.isShowSiteHeader() ? true : null;
-        useFlashClipboard = p.isUseFlashClipboard() ? true : null;
-        downloadScheme = p.getDownloadUrl();
-        downloadCommand = p.getDownloadCommand();
-        copySelfOnEmail = p.isCopySelfOnEmails() ? true : null;
-        dateFormat = p.getDateFormat();
-        timeFormat = p.getTimeFormat();
-        relativeDateInChangeTable = p.isRelativeDateInChangeTable() ? true : null;
-        sizeBarInChangeTable = p.isSizeBarInChangeTable() ? true : null;
-        legacycidInChangeTable = p.isLegacycidInChangeTable() ? true : null;
-        muteCommonPathPrefixes = p.isMuteCommonPathPrefixes() ? true : null;
-        reviewCategoryStrategy = p.getReviewCategoryStrategy();
-        diffView = p.getDiffView();
-      }
-      loadFromAllUsers(v, allUsers);
-    }
-
-    private void loadFromAllUsers(VersionedAccountPreferences v,
-        Repository allUsers) {
-      my = my(v);
-      if (my.isEmpty() && !v.isDefaults()) {
-        try {
-          VersionedAccountPreferences d = VersionedAccountPreferences.forDefault();
-          d.load(allUsers);
-          my = my(d);
-        } catch (ConfigInvalidException | IOException e) {
-          log.warn("cannot read default preferences", e);
-        }
-      }
-      if (my.isEmpty()) {
-        my.add(new TopMenu.MenuItem("Changes", "#/dashboard/self", null));
-        my.add(new TopMenu.MenuItem("Drafts", "#/q/owner:self+is:draft", null));
-        my.add(new TopMenu.MenuItem("Draft Comments", "#/q/has:draft", null));
-        my.add(new TopMenu.MenuItem("Edits", "#/q/has:edit", null));
-        my.add(new TopMenu.MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
-        my.add(new TopMenu.MenuItem("Starred Changes", "#/q/is:starred", null));
-        my.add(new TopMenu.MenuItem("Groups", "#/groups/self", null));
-      }
-
-      urlAliases = urlAliases(v);
-    }
-
-    private List<TopMenu.MenuItem> my(VersionedAccountPreferences v) {
-      List<TopMenu.MenuItem> my = new ArrayList<>();
-      Config cfg = v.getConfig();
-      for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
-        String url = my(cfg, subsection, KEY_URL, "#/");
-        String target = my(cfg, subsection, KEY_TARGET,
-            url.startsWith("#") ? null : "_blank");
-        my.add(new TopMenu.MenuItem(
-            subsection, url, target,
-            my(cfg, subsection, KEY_ID, null)));
-      }
-      return my;
-    }
-
-    private static String my(Config cfg, String subsection, String key,
-        String defaultValue) {
-      String val = cfg.getString(UserConfigSections.MY, subsection, key);
-      return !Strings.isNullOrEmpty(val) ? val : defaultValue;
-    }
-
-    private static Map<String, String> urlAliases(VersionedAccountPreferences v) {
-      HashMap<String, String> urlAliases = new HashMap<>();
-      Config cfg = v.getConfig();
-      for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-        urlAliases.put(cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-           cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
-      }
-      return !urlAliases.isEmpty() ? urlAliases : null;
-    }
+    Account.Id id = rsrc.getUser().getAccountId();
+    return accountCache.get(id).getAccount().getGeneralPreferencesInfo();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java
index a7700cf..ee75432 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKey.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResource.SshKey;
-import com.google.gerrit.server.account.GetSshKeys.SshKeyInfo;
 import com.google.inject.Singleton;
 
 @Singleton
@@ -24,6 +24,6 @@
 
   @Override
   public SshKeyInfo apply(SshKey rsrc) {
-    return new SshKeyInfo(rsrc.getSshKey());
+    return GetSshKeys.newSshKeyInfo(rsrc.getSshKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index 6846470..bf1a3af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
@@ -27,23 +28,29 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+import java.io.IOException;
 import java.util.List;
 
 @Singleton
 public class GetSshKeys implements RestReadView<AccountResource> {
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  GetSshKeys(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+  GetSshKeys(Provider<CurrentUser> self,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.authorizedKeys = authorizedKeys;
   }
 
   @Override
-  public List<SshKeyInfo> apply(AccountResource rsrc) throws AuthException,
-      OrmException {
+  public List<SshKeyInfo> apply(AccountResource rsrc)
+      throws AuthException, OrmException, RepositoryNotFoundException,
+      IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to get SSH keys");
@@ -51,30 +58,25 @@
     return apply(rsrc.getUser());
   }
 
-  public List<SshKeyInfo> apply(IdentifiedUser user) throws OrmException {
-    List<SshKeyInfo> sshKeys = Lists.newArrayList();
-    for (AccountSshKey sshKey : dbProvider.get().accountSshKeys()
-        .byAccount(user.getAccountId()).toList()) {
-      sshKeys.add(new SshKeyInfo(sshKey));
-    }
-    return sshKeys;
+  public List<SshKeyInfo> apply(IdentifiedUser user)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()),
+        new Function<AccountSshKey, SshKeyInfo>() {
+          @Override
+          public SshKeyInfo apply(AccountSshKey key) {
+            return newSshKeyInfo(key);
+          }
+        });
   }
 
-  public static class SshKeyInfo {
-    public SshKeyInfo(AccountSshKey sshKey) {
-      seq = sshKey.getKey().get();
-      sshPublicKey = sshKey.getSshPublicKey();
-      encodedKey = sshKey.getEncodedKey();
-      algorithm = sshKey.getAlgorithm();
-      comment = Strings.emptyToNull(sshKey.getComment());
-      valid = sshKey.isValid();
-    }
-
-    public int seq;
-    public String sshPublicKey;
-    public String encodedKey;
-    public String algorithm;
-    public String comment;
-    public boolean valid;
+  public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
+    SshKeyInfo info = new SshKeyInfo();
+    info.seq = sshKey.getKey().get();
+    info.sshPublicKey = sshKey.getSshPublicKey();
+    info.encodedKey = sshKey.getEncodedKey();
+    info.algorithm = sshKey.getAlgorithm();
+    info.comment = Strings.emptyToNull(sshKey.getComment());
+    info.valid = sshKey.isValid();
+    return info;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
index 41622cf..a5f271d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
@@ -17,28 +17,18 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetUsername implements RestReadView<AccountResource> {
-
-  private final Provider<CurrentUser> self;
-
   @Inject
-  GetUsername(Provider<CurrentUser> self) {
-    this.self = self;
+  public GetUsername() {
   }
 
   @Override
   public String apply(AccountResource rsrc) throws AuthException,
       ResourceNotFoundException {
-    if (self.get() != rsrc.getUser()
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to get username");
-    }
     String username = rsrc.getUser().getAccount().getUserName();
     if (username == null) {
       throw new ResourceNotFoundException();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
new file mode 100644
index 0000000..3748e17
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ComparisonChain;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class GetWatchedProjects implements RestReadView<AccountResource> {
+
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<IdentifiedUser> self;
+  private final boolean readFromGit;
+  private final WatchConfig.Accessor watchConfig;
+
+  @Inject
+  public GetWatchedProjects(Provider<ReviewDb> dbProvider,
+      Provider<IdentifiedUser> self,
+      @GerritServerConfig Config cfg,
+      WatchConfig.Accessor watchConfig) {
+    this.dbProvider = dbProvider;
+    this.self = self;
+    this.readFromGit =
+        cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
+    this.watchConfig = watchConfig;
+  }
+
+  @Override
+  public List<ProjectWatchInfo> apply(AccountResource rsrc)
+      throws OrmException, AuthException, IOException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()
+        && !self.get().getCapabilities().canAdministrateServer()) {
+      throw new AuthException("It is not allowed to list project watches "
+          + "of other users");
+    }
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+        readFromGit
+            ? watchConfig.getProjectWatches(accountId)
+            : readProjectWatchesFromDb(dbProvider.get(), accountId);
+
+    List<ProjectWatchInfo> projectWatchInfos = new LinkedList<>();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches
+        .entrySet()) {
+      ProjectWatchInfo pwi = new ProjectWatchInfo();
+      pwi.filter = e.getKey().filter();
+      pwi.project = e.getKey().project().get();
+      pwi.notifyAbandonedChanges =
+          toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
+      pwi.notifyNewChanges =
+          toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
+      pwi.notifyNewPatchSets =
+          toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
+      pwi.notifySubmittedChanges =
+          toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
+      pwi.notifyAllComments =
+          toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
+      projectWatchInfos.add(pwi);
+    }
+    Collections.sort(projectWatchInfos, new Comparator<ProjectWatchInfo>() {
+      @Override
+      public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
+        return ComparisonChain.start()
+            .compare(pwi1.project, pwi2.project)
+            .compare(Strings.nullToEmpty(pwi1.filter),
+                Strings.nullToEmpty(pwi2.filter))
+            .result();
+      }
+    });
+    return projectWatchInfos;
+  }
+
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
+
+  public static Map<ProjectWatchKey, Set<NotifyType>> readProjectWatchesFromDb(
+      ReviewDb db, Account.Id who) throws OrmException {
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+        new HashMap<>();
+    for (AccountProjectWatch apw : db.accountProjectWatches().byAccount(who)) {
+      ProjectWatchKey key =
+          ProjectWatchKey.create(apw.getProjectNameKey(), apw.getFilter());
+      Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
+      for (NotifyType notifyType : NotifyType.values()) {
+        if (apw.isNotify(notifyType)) {
+          notifyValues.add(notifyType);
+        }
+      }
+      projectWatches.put(key, notifyValues);
+    }
+    return projectWatches;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
index c1a4e0f..c7a2241 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
@@ -19,9 +19,9 @@
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
-  public AccountGroup get(AccountGroup.Id groupId);
+  AccountGroup get(AccountGroup.Id groupId);
 
-  public AccountGroup get(AccountGroup.NameKey name);
+  AccountGroup get(AccountGroup.NameKey name);
 
   /**
    * Lookup a group definition by its UUID. The returned definition may be null
@@ -29,16 +29,16 @@
    * copied from another server.
    */
   @Nullable
-  public AccountGroup get(AccountGroup.UUID uuid);
+  AccountGroup get(AccountGroup.UUID uuid);
 
   /** @return sorted iteration of groups. */
-  public abstract Iterable<AccountGroup> all();
+  Iterable<AccountGroup> all();
 
   /** Notify the cache that a new group was constructed. */
-  public void onCreateGroup(AccountGroup.NameKey newGroupName);
+  void onCreateGroup(AccountGroup.NameKey newGroupName);
 
-  public void evict(AccountGroup group);
+  void evict(AccountGroup group);
 
-  public void evictAfterRename(final AccountGroup.NameKey oldName,
+  void evictAfterRename(final AccountGroup.NameKey oldName,
       final AccountGroup.NameKey newName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
index bf04234..e5e2f99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -95,7 +95,7 @@
       Optional<AccountGroup> g = byId.get(groupId);
       return g.isPresent() ? g.get() : missing(groupId);
     } catch (ExecutionException e) {
-      log.warn("Cannot load group "+groupId, e);
+      log.warn("Cannot load group " + groupId, e);
       return missing(groupId);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
index 9c806de..94feb7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.GroupInfoCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -43,18 +44,19 @@
   private final GroupCache groupCache;
   private final GroupBackend groupBackend;
   private final AccountInfoCacheFactory aic;
-  private final GroupInfoCacheFactory gic;
+  private final GroupInfoCache gic;
 
   private final AccountGroup.Id groupId;
   private GroupControl control;
 
   @Inject
-  GroupDetailFactory(final ReviewDb db,
-      final GroupControl.Factory groupControl, final GroupCache groupCache,
-      final GroupBackend groupBackend,
-      final AccountInfoCacheFactory.Factory accountInfoCacheFactory,
-      final GroupInfoCacheFactory.Factory groupInfoCacheFactory,
-      @Assisted final AccountGroup.Id groupId) {
+  GroupDetailFactory(ReviewDb db,
+      GroupControl.Factory groupControl,
+      GroupCache groupCache,
+      GroupBackend groupBackend,
+      AccountInfoCacheFactory.Factory accountInfoCacheFactory,
+      GroupInfoCache.Factory groupInfoCacheFactory,
+      @Assisted AccountGroup.Id groupId) {
     this.db = db;
     this.groupControl = groupControl;
     this.groupCache = groupCache;
@@ -68,8 +70,8 @@
   @Override
   public GroupDetail call() throws OrmException, NoSuchGroupException {
     control = groupControl.validateFor(groupId);
-    final AccountGroup group = groupCache.get(groupId);
-    final GroupDetail detail = new GroupDetail();
+    AccountGroup group = groupCache.get(groupId);
+    GroupDetail detail = new GroupDetail();
     detail.setGroup(group);
     GroupDescription.Basic ownerGroup = groupBackend.get(group.getOwnerGroupUUID());
     if (ownerGroup != null) {
@@ -84,7 +86,7 @@
 
   private List<AccountGroupMember> loadMembers() throws OrmException {
     List<AccountGroupMember> members = new ArrayList<>();
-    for (final AccountGroupMember m : db.accountGroupMembers().byGroup(groupId)) {
+    for (AccountGroupMember m : db.accountGroupMembers().byGroup(groupId)) {
       if (control.canSeeMember(m.getAccountId())) {
         aic.want(m.getAccountId());
         members.add(m);
@@ -93,10 +95,9 @@
 
     Collections.sort(members, new Comparator<AccountGroupMember>() {
       @Override
-      public int compare(final AccountGroupMember o1,
-          final AccountGroupMember o2) {
-        final Account a = aic.get(o1.getAccountId());
-        final Account b = aic.get(o2.getAccountId());
+      public int compare(AccountGroupMember o1, AccountGroupMember o2) {
+        Account a = aic.get(o1.getAccountId());
+        Account b = aic.get(o2.getAccountId());
         return n(a).compareTo(n(b));
       }
 
@@ -120,7 +121,7 @@
   private List<AccountGroupById> loadIncludes() throws OrmException {
     List<AccountGroupById> groups = new ArrayList<>();
 
-    for (final AccountGroupById m : db.accountGroupById().byGroup(groupId)) {
+    for (AccountGroupById m : db.accountGroupById().byGroup(groupId)) {
       if (control.canSeeGroup()) {
         gic.want(m.getIncludeUUID());
         groups.add(m);
@@ -129,8 +130,7 @@
 
     Collections.sort(groups, new Comparator<AccountGroupById>() {
       @Override
-      public int compare(final AccountGroupById o1,
-          final AccountGroupById o2) {
+      public int compare(AccountGroupById o1, AccountGroupById o2) {
         GroupDescription.Basic a = gic.get(o1.getIncludeUUID());
         GroupDescription.Basic b = gic.get(o2.getIncludeUUID());
         return n(a).compareTo(n(b));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 6ba6bf0..9971301 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -21,14 +21,14 @@
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
   /** @return groups directly a member of the passed group. */
-  public Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
+  Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
 
   /** @return any groups the passed group belongs to. */
-  public Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+  Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
   /** @return set of any UUIDs that are not internal groups. */
-  public Set<AccountGroup.UUID> allExternalMembers();
+  Set<AccountGroup.UUID> allExternalMembers();
 
-  public void evictSubgroupsOf(AccountGroup.UUID groupId);
-  public void evictParentGroupsOf(AccountGroup.UUID groupId);
+  void evictSubgroupsOf(AccountGroup.UUID groupId);
+  void evictParentGroupsOf(AccountGroup.UUID groupId);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 4b56b81..9bd6b30 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -17,7 +17,6 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -33,6 +32,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -150,7 +150,7 @@
           return Collections.emptySet();
         }
 
-        Set<AccountGroup.UUID> ids = Sets.newHashSet();
+        Set<AccountGroup.UUID> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById()
             .byGroup(group.get(0).getId())) {
           ids.add(agi.getIncludeUUID());
@@ -172,13 +172,13 @@
     @Override
     public Set<AccountGroup.UUID> load(AccountGroup.UUID key) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<AccountGroup.Id> ids = Sets.newHashSet();
+        Set<AccountGroup.Id> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById()
             .byIncludeUUID(key)) {
           ids.add(agi.getGroupId());
         }
 
-        Set<AccountGroup.UUID> groupArray = Sets.newHashSet();
+        Set<AccountGroup.UUID> groupArray = new HashSet<>();
         for (AccountGroup g : db.accountGroups().get(ids)) {
           groupArray.add(g.getGroupUUID());
         }
@@ -199,7 +199,7 @@
     @Override
     public Set<AccountGroup.UUID> load(String key) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<AccountGroup.UUID> ids = Sets.newHashSet();
+        Set<AccountGroup.UUID> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById().all()) {
           if (!AccountGroup.isInternalGroup(agi.getIncludeUUID())) {
             ids.add(agi.getIncludeUUID());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupInfoCacheFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupInfoCacheFactory.java
deleted file mode 100644
index e346801..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupInfoCacheFactory.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupInfoCache;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.inject.Inject;
-
-import java.util.Map;
-
-/** Efficiently builds a {@link GroupInfoCache}. */
-public class GroupInfoCacheFactory {
-  public interface Factory {
-    GroupInfoCacheFactory create();
-  }
-
-  private final GroupBackend groupBackend;
-  private final Map<AccountGroup.UUID, GroupDescription.Basic> out;
-
-  @Inject
-  GroupInfoCacheFactory(GroupBackend groupBackend) {
-    this.groupBackend = groupBackend;
-    this.out = Maps.newHashMap();
-  }
-
-  /**
-   * Indicate a group will be needed later on.
-   *
-   * @param uuid identity that will be needed in the future; may be null.
-   */
-  public void want(final AccountGroup.UUID uuid) {
-    if (uuid != null && !out.containsKey(uuid)) {
-      out.put(uuid, groupBackend.get(uuid));
-    }
-  }
-
-  /** Indicate one or more groups will be needed later on. */
-  public void want(final Iterable<AccountGroup.UUID> uuids) {
-    for (final AccountGroup.UUID uuid : uuids) {
-      want(uuid);
-    }
-  }
-
-  public GroupDescription.Basic get(final AccountGroup.UUID uuid) {
-    want(uuid);
-    return out.get(uuid);
-  }
-}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
index d3d6504..61f13d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
@@ -69,14 +69,12 @@
       throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
     if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
       return getProjectOwners(project, seen);
-    } else {
-      AccountGroup group = groupCache.get(groupUUID);
-      if (group != null) {
-        return getGroupMembers(group, project, seen);
-      } else {
-        return Collections.emptySet();
-      }
     }
+    AccountGroup group = groupCache.get(groupUUID);
+    if (group != null) {
+      return getGroupMembers(group, project, seen);
+    }
+    return Collections.emptySet();
   }
 
   private Set<Account> getProjectOwners(final Project.NameKey project,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
index d7a97fb..c45b7b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
@@ -24,7 +24,7 @@
  * the presence of a user in a particular group.
  */
 public interface GroupMembership {
-  public static final GroupMembership EMPTY =
+  GroupMembership EMPTY =
       new ListGroupMembership(Collections.<AccountGroup.UUID>emptySet());
 
   /**
@@ -46,7 +46,7 @@
    * Implementors may implement the method as:
    *
    * <pre>
-   * Set&lt;AccountGroup.UUID&gt; r = Sets.newHashSet();
+   * Set&lt;AccountGroup.UUID&gt; r = new HashSet&lt;&gt;();
    * for (AccountGroup.UUID id : groupIds)
    *   if (contains(id)) r.add(id);
    * </pre>
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index b8a67ff..3eaeebe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -102,7 +103,7 @@
 
   @Override
   public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupIds) {
-    Set<AccountGroup.UUID> r = Sets.newHashSet();
+    Set<AccountGroup.UUID> r = new HashSet<>();
     for (AccountGroup.UUID id : groupIds) {
       if (contains(id)) {
         r.add(id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
new file mode 100644
index 0000000..b8cdf76
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -0,0 +1,56 @@
+//Copyright (C) 2016 The Android Open Source Project
+//
+//Licensed under the Apache License, Version 2.0 (the "License");
+//you may not use this file except in compliance with the License.
+//You may obtain a copy of the License at
+//
+//http://www.apache.org/licenses/LICENSE-2.0
+//
+//Unless required by applicable law or agreed to in writing, software
+//distributed under the License is distributed on an "AS IS" BASIS,
+//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//See the License for the specific language governing permissions and
+//limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.Index.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+@Singleton
+public class Index implements RestModifyView<AccountResource, Input> {
+  public static class Input {
+  }
+
+  private final AccountCache accountCache;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  Index(AccountCache accountCache,
+      Provider<CurrentUser> self) {
+    this.accountCache = accountCache;
+    this.self = self;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, OrmException {
+    if (self.get() != rsrc.getUser()
+        && !self.get().getCapabilities().canModifyAccount()) {
+      throw new AuthException("not allowed to index account");
+    }
+
+    // evicting the account from the cache, reindexes the account
+    accountCache.evict(rsrc.getUser().getAccountId());
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 4f8eacd..78a801e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -12,28 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.avatar.AvatarProvider;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.List;
 import java.util.Set;
 
 @Singleton
@@ -48,17 +46,14 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
   private final AccountCache accountCache;
   private final DynamicItem<AvatarProvider> avatar;
   private final IdentifiedUser.GenericFactory userFactory;
 
   @Inject
-  InternalAccountDirectory(Provider<ReviewDb> db,
-      AccountCache accountCache,
+  InternalAccountDirectory(AccountCache accountCache,
       DynamicItem<AvatarProvider> avatar,
       IdentifiedUser.GenericFactory userFactory) {
-    this.db = db;
     this.accountCache = accountCache;
     this.avatar = avatar;
     this.userFactory = userFactory;
@@ -72,35 +67,16 @@
     if (options.equals(ID_ONLY)) {
       return;
     }
-    Multimap<Account.Id, AccountInfo> missing = ArrayListMultimap.create();
     for (AccountInfo info : in) {
       Account.Id id = new Account.Id(info._accountId);
-      AccountState state = accountCache.getIfPresent(id);
-      if (state != null) {
-        fill(info, state.getAccount(), options);
-      } else {
-        missing.put(id, info);
-      }
-    }
-    if (!missing.isEmpty()) {
-      try {
-        for (Account account : db.get().accounts().get(missing.keySet())) {
-          if (options.contains(FillOptions.USERNAME)) {
-            account.setUserName(AccountState.getUserName(
-                db.get().accountExternalIds().byAccount(account.getId()).toList()));
-          }
-          for (AccountInfo info : missing.get(account.getId())) {
-            fill(info, account, options);
-          }
-        }
-      } catch (OrmException e) {
-        throw new DirectoryException(e);
-      }
+      AccountState state = accountCache.get(id);
+      fill(info, state.getAccount(), state.getExternalIds(), options);
     }
   }
 
   private void fill(AccountInfo info,
       Account account,
+      @Nullable Collection<AccountExternalId> externalIds,
       Set<FillOptions> options) {
     if (options.contains(FillOptions.ID)) {
       info._accountId = account.getId().get();
@@ -117,23 +93,59 @@
     if (options.contains(FillOptions.EMAIL)) {
       info.email = account.getPreferredEmail();
     }
+    if (options.contains(FillOptions.SECONDARY_EMAILS)) {
+      info.secondaryEmails = externalIds != null
+          ? getSecondaryEmails(account, externalIds)
+          : null;
+    }
     if (options.contains(FillOptions.USERNAME)) {
-      info.username = account.getUserName();
+      info.username = externalIds != null
+          ? AccountState.getUserName(externalIds)
+          : null;
     }
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
-        info.avatars = Lists.newArrayListWithCapacity(1);
-        String u = ap.getUrl(
-            userFactory.create(account.getId()),
-            AvatarInfo.DEFAULT_SIZE);
-        if (u != null) {
-          AvatarInfo a = new AvatarInfo();
-          a.url = u;
-          a.height = AvatarInfo.DEFAULT_SIZE;
-          info.avatars.add(a);
+        info.avatars = new ArrayList<>(3);
+        IdentifiedUser user = userFactory.create(account.getId());
+
+        // GWT UI uses DEFAULT_SIZE (26px).
+        addAvatar(ap, info, user, AvatarInfo.DEFAULT_SIZE);
+
+        // PolyGerrit UI prefers 32px and 100px.
+        if (!info.avatars.isEmpty()) {
+          if (32 != AvatarInfo.DEFAULT_SIZE) {
+            addAvatar(ap, info, user, 32);
+          }
+          if (100 != AvatarInfo.DEFAULT_SIZE) {
+            addAvatar(ap, info, user, 100);
+          }
         }
       }
     }
   }
+
+  public List<String> getSecondaryEmails(Account account,
+      Collection<AccountExternalId> externalIds) {
+    List<String> emails = new ArrayList<>(AccountState.getEmails(externalIds));
+    if (account.getPreferredEmail() != null) {
+      emails.remove(account.getPreferredEmail());
+    }
+    Collections.sort(emails);
+    return emails;
+  }
+
+  private static void addAvatar(
+      AvatarProvider provider,
+      AccountInfo account,
+      IdentifiedUser user,
+      int size) {
+    String url = provider.getUrl(user, size);
+    if (url != null) {
+      AvatarInfo avatar = new AvatarInfo();
+      avatar.url = url;
+      avatar.height = size;
+      account.avatars.add(avatar);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 861d3e9..c47d6f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -27,6 +27,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.lib.ObjectId;
+
 import java.util.Collection;
 
 /** Implementation of GroupBackend for the internal group system. */
@@ -55,7 +57,8 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return AccountGroup.isInternalGroup(uuid);
+    // See AccountGroup.isInternalGroup
+    return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
new file mode 100644
index 0000000..d60b7af
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+
+/** Error indicating the SSH user name does not match {@link Account#USER_NAME_PATTERN} pattern. */
+public class InvalidUserNameException extends Exception {
+
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Invalid user name.";
+
+  public InvalidUserNameException() {
+    super(MESSAGE);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 54d4cc0..8c5228f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND;
 import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
 import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
+import static com.google.gerrit.server.account.AccountResource.Star.STAR_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -34,10 +35,12 @@
     DynamicMap.mapOf(binder(), EMAIL_KIND);
     DynamicMap.mapOf(binder(), SSH_KEY_KIND);
     DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
+    DynamicMap.mapOf(binder(), STAR_KIND);
 
     put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "detail").to(GetDetail.class);
+    post(ACCOUNT_KIND, "index").to(Index.class);
     get(ACCOUNT_KIND, "name").to(GetName.class);
     put(ACCOUNT_KIND, "name").to(PutName.class);
     delete(ACCOUNT_KIND, "name").to(PutName.class);
@@ -56,10 +59,16 @@
     delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
     post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
+    get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects:delete")
+        .to(DeleteWatchedProjects.class);
 
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
@@ -74,11 +83,18 @@
     put(ACCOUNT_KIND, "preferences.edit").to(SetEditPreferences.class);
     get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
 
+    get(ACCOUNT_KIND, "agreements").to(GetAgreements.class);
+    put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
+
     child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
     put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
     bind(StarredChanges.Create.class);
 
+    child(ACCOUNT_KIND, "stars.changes").to(Stars.class);
+    get(STAR_KIND).to(Stars.Get.class);
+    post(STAR_KIND).to(Stars.Post.class);
+
     factory(CreateAccount.Factory.class);
     factory(CreateEmail.Factory.class);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
new file mode 100644
index 0000000..d54ec50
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.project.ProjectsCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class PostWatchedProjects
+    implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<IdentifiedUser> self;
+  private final GetWatchedProjects getWatchedProjects;
+  private final ProjectsCollection projectsCollection;
+  private final AccountCache accountCache;
+  private final WatchConfig.Accessor watchConfig;
+
+  @Inject
+  public PostWatchedProjects(Provider<ReviewDb> dbProvider,
+      Provider<IdentifiedUser> self,
+      GetWatchedProjects getWatchedProjects,
+      ProjectsCollection projectsCollection,
+      AccountCache accountCache,
+      WatchConfig.Accessor watchConfig) {
+    this.dbProvider = dbProvider;
+    this.self = self;
+    this.getWatchedProjects = getWatchedProjects;
+    this.projectsCollection = projectsCollection;
+    this.accountCache = accountCache;
+    this.watchConfig = watchConfig;
+  }
+
+  @Override
+  public List<ProjectWatchInfo> apply(AccountResource rsrc,
+      List<ProjectWatchInfo> input) throws OrmException, RestApiException,
+          IOException, ConfigInvalidException {
+    if (self.get() != rsrc.getUser()
+        && !self.get().getCapabilities().canAdministrateServer()) {
+      throw new AuthException("not allowed to edit project watches");
+    }
+    Account.Id accountId = rsrc.getUser().getAccountId();
+    updateInDb(accountId, input);
+    updateInGit(accountId, input);
+    accountCache.evict(accountId);
+    return getWatchedProjects.apply(rsrc);
+  }
+
+  private void updateInDb(Account.Id accountId, List<ProjectWatchInfo> input)
+      throws BadRequestException, UnprocessableEntityException, IOException,
+      OrmException {
+    Set<AccountProjectWatch.Key> keys = new HashSet<>();
+    List<AccountProjectWatch> watchedProjects = new LinkedList<>();
+    for (ProjectWatchInfo a : input) {
+      if (a.project == null) {
+        throw new BadRequestException("project name must be specified");
+      }
+
+      Project.NameKey projectKey =
+          projectsCollection.parse(a.project).getNameKey();
+      AccountProjectWatch.Key key =
+          new AccountProjectWatch.Key(accountId, projectKey, a.filter);
+      if (!keys.add(key)) {
+        throw new BadRequestException("duplicate entry for project "
+            + format(key.getProjectName().get(), key.getFilter().get()));
+      }
+      AccountProjectWatch apw = new AccountProjectWatch(key);
+      apw.setNotify(AccountProjectWatch.NotifyType.ABANDONED_CHANGES,
+          toBoolean(a.notifyAbandonedChanges));
+      apw.setNotify(AccountProjectWatch.NotifyType.ALL_COMMENTS,
+          toBoolean(a.notifyAllComments));
+      apw.setNotify(AccountProjectWatch.NotifyType.NEW_CHANGES,
+          toBoolean(a.notifyNewChanges));
+      apw.setNotify(AccountProjectWatch.NotifyType.NEW_PATCHSETS,
+          toBoolean(a.notifyNewPatchSets));
+      apw.setNotify(AccountProjectWatch.NotifyType.SUBMITTED_CHANGES,
+          toBoolean(a.notifySubmittedChanges));
+      watchedProjects.add(apw);
+    }
+    dbProvider.get().accountProjectWatches().upsert(watchedProjects);
+  }
+
+  private void updateInGit(Account.Id accountId, List<ProjectWatchInfo> input)
+      throws BadRequestException, UnprocessableEntityException, IOException,
+      ConfigInvalidException {
+    watchConfig.upsertProjectWatches(accountId, asMap(input));
+  }
+
+  private Map<ProjectWatchKey, Set<NotifyType>> asMap(
+      List<ProjectWatchInfo> input) throws BadRequestException,
+          UnprocessableEntityException, IOException {
+    Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
+    for (ProjectWatchInfo info : input) {
+      if (info.project == null) {
+        throw new BadRequestException("project name must be specified");
+      }
+
+      ProjectWatchKey key = ProjectWatchKey.create(
+          projectsCollection.parse(info.project).getNameKey(), info.filter);
+      if (m.containsKey(key)) {
+        throw new BadRequestException(
+            "duplicate entry for project " + format(info.project, info.filter));
+      }
+
+      Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
+      if (toBoolean(info.notifyAbandonedChanges)) {
+        notifyValues.add(NotifyType.ABANDONED_CHANGES);
+      }
+      if (toBoolean(info.notifyAllComments)) {
+        notifyValues.add(NotifyType.ALL_COMMENTS);
+      }
+      if (toBoolean(info.notifyNewChanges)) {
+        notifyValues.add(NotifyType.NEW_CHANGES);
+      }
+      if (toBoolean(info.notifyNewPatchSets)) {
+        notifyValues.add(NotifyType.NEW_PATCHSETS);
+      }
+      if (toBoolean(info.notifySubmittedChanges)) {
+        notifyValues.add(NotifyType.SUBMITTED_CHANGES);
+      }
+
+      m.put(key, notifyValues);
+    }
+    return m;
+  }
+
+  private boolean toBoolean(Boolean b) {
+    return b == null ? false : b;
+  }
+
+  private static String format(String project, String filter) {
+    return project
+        + (filter != null && !AccountProjectWatch.FILTER_ALL.equals(filter)
+            ? " and filter " + filter
+            : "");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
index 17e177f..9197011 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.CreateAccount.Input;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutAccount implements RestModifyView<AccountResource, Input> {
+public class PutAccount
+    implements RestModifyView<AccountResource, AccountInput> {
   @Override
-  public Object apply(AccountResource resource, Input input)
+  public Object apply(AccountResource resource, AccountInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("account exists");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index c7a63e5..8cc134f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -27,6 +27,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -46,7 +47,7 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
new file mode 100644
index 0000000..2fdf666
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.AgreementSignup;
+import com.google.gerrit.server.group.AddMembers;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+
+@Singleton
+public class PutAgreement
+    implements RestModifyView<AccountResource, AgreementInput> {
+  private final ProjectCache projectCache;
+  private final GroupCache groupCache;
+  private final Provider<IdentifiedUser> self;
+  private final AgreementSignup agreementSignup;
+  private final AddMembers addMembers;
+  private final boolean agreementsEnabled;
+
+  @Inject
+  PutAgreement(ProjectCache projectCache,
+      GroupCache groupCache,
+      Provider<IdentifiedUser> self,
+      AgreementSignup agreementSignup,
+      AddMembers addMembers,
+      @GerritServerConfig Config config) {
+    this.projectCache = projectCache;
+    this.groupCache = groupCache;
+    this.self = self;
+    this.agreementSignup = agreementSignup;
+    this.addMembers = addMembers;
+    this.agreementsEnabled =
+        config.getBoolean("auth", "contributorAgreements", false);
+  }
+
+  @Override
+  public Object apply(AccountResource resource, AgreementInput input)
+      throws IOException, OrmException, RestApiException {
+    if (!agreementsEnabled) {
+      throw new MethodNotAllowedException("contributor agreements disabled");
+    }
+
+    if (self.get() != resource.getUser()) {
+      throw new AuthException("not allowed to enter contributor agreement");
+    }
+
+    String agreementName = Strings.nullToEmpty(input.name);
+    ContributorAgreement ca = projectCache.getAllProjects().getConfig()
+        .getContributorAgreement(agreementName);
+    if (ca == null) {
+      throw new UnprocessableEntityException("contributor agreement not found");
+    }
+
+    if (ca.getAutoVerify() == null) {
+      throw new BadRequestException("cannot enter a non-autoVerify agreement");
+    }
+
+    AccountGroup.UUID uuid = ca.getAutoVerify().getUUID();
+    if (uuid == null) {
+      throw new ResourceConflictException("autoverify group uuid not found");
+    }
+
+    AccountGroup group = groupCache.get(uuid);
+    if (group == null) {
+      throw new ResourceConflictException("autoverify group not found");
+    }
+
+    Account account = self.get().getAccount();
+    addMembers.addMembers(group.getId(), ImmutableList.of(account.getId()));
+    agreementSignup.fire(account, agreementName);
+
+    return agreementName;
+  }
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 64fd11a..0cd93f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -34,6 +34,7 @@
 
 import org.apache.commons.codec.binary.Base64;
 
+import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.util.Collections;
@@ -69,8 +70,9 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input) throws AuthException,
-      ResourceNotFoundException, ResourceConflictException, OrmException {
+  public Response<String> apply(AccountResource rsrc, Input input)
+      throws AuthException, ResourceNotFoundException,
+      ResourceConflictException, OrmException, IOException {
     if (input == null) {
       input = new Input();
     }
@@ -101,7 +103,8 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException {
+      throws ResourceNotFoundException, ResourceConflictException, OrmException,
+      IOException {
     if (user.getUserName() == null) {
       throw new ResourceConflictException("username must be set");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index 601ee76..e0b69a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
-
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -25,17 +23,16 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutName.Input;
-import com.google.gerrit.server.auth.ldap.LdapRealm;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
@@ -62,7 +59,7 @@
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException,
-      ResourceNotFoundException, OrmException {
+      ResourceNotFoundException, OrmException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to change name");
@@ -71,22 +68,20 @@
   }
 
   public Response<String> apply(IdentifiedUser user, Input input)
-      throws MethodNotAllowedException, ResourceNotFoundException, OrmException {
+      throws MethodNotAllowedException, ResourceNotFoundException, OrmException,
+      IOException {
     if (input == null) {
       input = new Input();
     }
-    ReviewDb db = dbProvider.get();
-    Account a = db.accounts().get(user.getAccountId());
-    if (a == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
 
-    if (!realm.allowsEdit(FieldName.FULL_NAME)
-        && !(realm instanceof LdapRealm && db.accountExternalIds().get(
-            new AccountExternalId.Key(SCHEME_GERRIT, a.getUserName())) == null)) {
+    if (!realm.allowsEdit(FieldName.FULL_NAME)) {
       throw new MethodNotAllowedException("realm does not allow editing name");
     }
 
+    Account a = dbProvider.get().accounts().get(user.getAccountId());
+    if (a == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
     a.setFullName(input.name);
     dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index c49e3be..92357b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -28,6 +28,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
@@ -50,7 +51,8 @@
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException {
+      throws AuthException, ResourceNotFoundException, OrmException,
+      IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to set preferred email address");
@@ -59,7 +61,7 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(user.getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index 9506b01..e9dc393 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.errors.InvalidUserNameException;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -31,6 +30,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class PutUsername implements RestModifyView<AccountResource, Input> {
   public static class Input {
@@ -57,7 +58,7 @@
   @Override
   public String apply(AccountResource rsrc, Input input) throws AuthException,
       MethodNotAllowedException, UnprocessableEntityException,
-      ResourceConflictException, OrmException {
+      ResourceConflictException, OrmException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to set username");
@@ -76,9 +77,8 @@
     } catch (IllegalStateException e) {
       if (ChangeUserName.USERNAME_CANNOT_BE_CHANGED.equals(e.getMessage())) {
         throw new MethodNotAllowedException(e.getMessage());
-      } else {
-        throw e;
       }
+      throw e;
     } catch (InvalidUserNameException e) {
       throw new UnprocessableEntityException("invalid username");
     } catch (NameAlreadyUsedException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
new file mode 100644
index 0000000..000637a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
@@ -0,0 +1,279 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryResult;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gerrit.server.query.account.AccountQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class QueryAccounts implements RestReadView<TopLevelResource> {
+  private static final int MAX_SUGGEST_RESULTS = 100;
+  private static final String MAX_SUFFIX = "\u9fa5";
+
+  private final AccountControl accountControl;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AccountCache accountCache;
+  private final AccountIndexCollection indexes;
+  private final AccountQueryBuilder queryBuilder;
+  private final AccountQueryProcessor queryProcessor;
+  private final ReviewDb db;
+  private final boolean suggestConfig;
+  private final int suggestFrom;
+
+  private AccountLoader accountLoader;
+  private boolean suggest;
+  private int suggestLimit = 10;
+  private String query;
+  private Integer start;
+  private EnumSet<ListAccountsOption> options;
+
+  @Option(name = "--suggest", metaVar = "SUGGEST", usage = "suggest users")
+  public void setSuggest(boolean suggest) {
+    this.suggest = suggest;
+  }
+
+  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return")
+  public void setLimit(int n) {
+    queryProcessor.setLimit(n);
+
+    if (n < 0) {
+      suggestLimit = 10;
+    } else if (n == 0) {
+      suggestLimit = MAX_SUGGEST_RESULTS;
+    } else {
+      suggestLimit = Math.min(n, MAX_SUGGEST_RESULTS);
+    }
+  }
+
+  @Option(name = "-o", usage = "Output options per account")
+  public void addOption(ListAccountsOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListAccountsOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users")
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(name = "--start", aliases = {"-S"}, metaVar = "CNT",
+      usage = "Number of accounts to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Inject
+  QueryAccounts(AccountControl.Factory accountControlFactory,
+      AccountLoader.Factory accountLoaderFactory,
+      AccountCache accountCache,
+      AccountIndexCollection indexes,
+      AccountQueryBuilder queryBuilder,
+      AccountQueryProcessor queryProcessor,
+      ReviewDb db,
+      @GerritServerConfig Config cfg) {
+    this.accountControl = accountControlFactory.get();
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.accountCache = accountCache;
+    this.indexes = indexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.db = db;
+    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
+    this.options = EnumSet.noneOf(ListAccountsOption.class);
+
+    if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
+      suggestConfig = false;
+    } else {
+      boolean suggest;
+      try {
+        AccountVisibility av =
+            cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
+        suggest = (av != AccountVisibility.NONE);
+      } catch (IllegalArgumentException err) {
+        suggest = cfg.getBoolean("suggest", null, "accounts", true);
+      }
+      this.suggestConfig = suggest;
+    }
+  }
+
+  @Override
+  public List<AccountInfo> apply(TopLevelResource rsrc)
+      throws OrmException, BadRequestException, MethodNotAllowedException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
+      return Collections.emptyList();
+    }
+
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
+    if (options.contains(ListAccountsOption.DETAILS)) {
+      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    }
+    if (options.contains(ListAccountsOption.ALL_EMAILS)) {
+      fillOptions.add(FillOptions.EMAIL);
+      fillOptions.add(FillOptions.SECONDARY_EMAILS);
+    }
+    if (suggest) {
+      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+      fillOptions.add(FillOptions.EMAIL);
+      fillOptions.add(FillOptions.SECONDARY_EMAILS);
+    }
+    accountLoader = accountLoaderFactory.create(fillOptions);
+
+    AccountIndex searchIndex = indexes.getSearchIndex();
+    if (searchIndex != null) {
+      return queryFromIndex();
+    }
+
+    if (!suggest) {
+      throw new MethodNotAllowedException();
+    }
+    if (start != null) {
+      throw new MethodNotAllowedException("option start not allowed");
+    }
+    return queryFromDb();
+  }
+
+  public List<AccountInfo> queryFromIndex()
+      throws BadRequestException, MethodNotAllowedException, OrmException {
+    if (queryProcessor.isDisabled()) {
+      throw new MethodNotAllowedException("query disabled");
+    }
+
+    if (start != null) {
+      queryProcessor.setStart(start);
+    }
+
+    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
+    try {
+      Predicate<AccountState> queryPred;
+      if (suggest) {
+        queryPred = queryBuilder.defaultQuery(query);
+        queryProcessor.setLimit(suggestLimit);
+      } else {
+        queryPred = queryBuilder.parse(query);
+      }
+      QueryResult<AccountState> result = queryProcessor.query(queryPred);
+      for (AccountState accountState : result.entities()) {
+        Account.Id id = accountState.getAccount().getId();
+        matches.put(id, accountLoader.get(id));
+      }
+
+      accountLoader.fill();
+
+      List<AccountInfo> sorted =
+          AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches.values());
+      if (!sorted.isEmpty() && result.more()) {
+        sorted.get(sorted.size() - 1)._moreAccounts = true;
+      }
+      return sorted;
+    } catch (QueryParseException e) {
+      if (suggest) {
+        return ImmutableList.of();
+      }
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  public List<AccountInfo> queryFromDb() throws OrmException {
+    String a = query;
+    String b = a + MAX_SUFFIX;
+
+    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
+    Map<Account.Id, String> queryEmail = new HashMap<>();
+
+    for (Account p : db.accounts().suggestByFullName(a, b, suggestLimit)) {
+      addSuggestion(matches, p);
+    }
+    if (matches.size() < suggestLimit) {
+      for (Account p : db.accounts()
+          .suggestByPreferredEmail(a, b, suggestLimit - matches.size())) {
+        addSuggestion(matches, p);
+      }
+    }
+    if (matches.size() < suggestLimit) {
+      for (AccountExternalId e : db.accountExternalIds()
+          .suggestByEmailAddress(a, b, suggestLimit - matches.size())) {
+        if (addSuggestion(matches, e.getAccountId())) {
+          queryEmail.put(e.getAccountId(), e.getEmailAddress());
+        }
+      }
+    }
+
+    accountLoader.fill();
+    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
+      AccountInfo info = matches.get(p.getKey());
+      if (info != null) {
+        info.email = p.getValue();
+      }
+    }
+
+    return AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches.values());
+  }
+
+  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account a) {
+    if (!a.isActive()) {
+      return false;
+    }
+    Account.Id id = a.getId();
+    if (!map.containsKey(id) && accountControl.canSee(id)) {
+      map.put(id, accountLoader.get(id));
+      return true;
+    }
+    return false;
+  }
+
+  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account.Id id) {
+    Account a = accountCache.get(id).getAccount();
+    return addSuggestion(map, a);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
index 056fa85b..85fde4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
@@ -15,33 +15,26 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 
 import java.util.Set;
 
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
-  public boolean allowsEdit(Account.FieldName field);
+  boolean allowsEdit(Account.FieldName field);
 
   /** Returns the account fields that the end-user can modify. */
-  public Set<Account.FieldName> getEditableFields();
+  Set<Account.FieldName> getEditableFields();
 
-  public AuthRequest authenticate(AuthRequest who) throws AccountException;
+  AuthRequest authenticate(AuthRequest who) throws AccountException;
 
-  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who)
-      throws AccountException;
-
-  public AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who)
-      throws AccountException;
-
-  public void onCreateAccount(AuthRequest who, Account account);
+  void onCreateAccount(AuthRequest who, Account account);
 
   /** @return true if the user has the given email address. */
-  public boolean hasEmailAddress(IdentifiedUser who, String email);
+  boolean hasEmailAddress(IdentifiedUser who, String email);
 
   /** @return all known email addresses for the identified user. */
-  public Set<String> getEmailAddresses(IdentifiedUser who);
+  Set<String> getEmailAddresses(IdentifiedUser who);
 
   /**
    * Locate an account whose local username is the given account name.
@@ -51,5 +44,5 @@
    * how to convert the accountName into an email address, and then locate the
    * user by that email address.
    */
-  public Account.Id lookup(String accountName);
+  Account.Id lookup(String accountName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
index e14791c..c408155 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -60,10 +59,10 @@
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
       throws AuthException, BadRequestException, ConfigInvalidException,
-      RepositoryNotFoundException, IOException, OrmException {
+      RepositoryNotFoundException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("restricted to members of Modify Accounts");
+      throw new AuthException("requires Modify Account capability");
     }
 
     if (in == null) {
@@ -77,10 +76,8 @@
   private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in,
       Account.Id userId) throws RepositoryNotFoundException, IOException,
           ConfigInvalidException {
-    MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName);
-
     DiffPreferencesInfo out = new DiffPreferencesInfo();
-    try {
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(
           userId);
       prefs.load(md);
@@ -90,8 +87,6 @@
       prefs.commit(md);
       loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out,
           DiffPreferencesInfo.defaults(), null);
-    } finally {
-      md.close();
     }
     return out;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
index eabe31d..7bff42b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.account.GetEditPreferences.readFromGit;
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
 
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
@@ -58,12 +58,12 @@
   }
 
   @Override
-  public Response<?> apply(AccountResource rsrc, EditPreferencesInfo in)
+  public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
       throws AuthException, BadRequestException, RepositoryNotFoundException,
       IOException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("restricted to members of Modify Accounts");
+      throw new AuthException("requires Modify Account capability");
     }
 
     if (in == null) {
@@ -71,20 +71,20 @@
     }
 
     Account.Id accountId = rsrc.getUser().getAccountId();
-    MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName);
 
     VersionedAccountPreferences prefs;
-    try {
+    EditPreferencesInfo out = new EditPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       prefs = VersionedAccountPreferences.forUser(accountId);
       prefs.load(md);
       storeSection(prefs.getConfig(), UserConfigSections.EDIT, null,
           readFromGit(accountId, gitMgr, allUsersName, in),
           EditPreferencesInfo.defaults());
       prefs.commit(md);
-    } finally {
-      md.close();
+      out = loadSection(prefs.getConfig(), UserConfigSections.EDIT, null, out,
+          EditPreferencesInfo.defaults(), null);
     }
 
-    return Response.none();
+    return out;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index 569d128..b70cabd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -14,72 +14,46 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.GetPreferences.KEY_ID;
-import static com.google.gerrit.server.account.GetPreferences.KEY_MATCH;
-import static com.google.gerrit.server.account.GetPreferences.KEY_TARGET;
-import static com.google.gerrit.server.account.GetPreferences.KEY_TOKEN;
-import static com.google.gerrit.server.account.GetPreferences.KEY_URL;
-import static com.google.gerrit.server.account.GetPreferences.URL_ALIAS;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TOKEN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.SetPreferences.Input;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 
 import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 
 @Singleton
-public class SetPreferences implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public Short changesPerPage;
-    public Boolean showSiteHeader;
-    public Boolean useFlashClipboard;
-    public String downloadScheme;
-    public DownloadCommand downloadCommand;
-    public Boolean copySelfOnEmail;
-    public DateFormat dateFormat;
-    public TimeFormat timeFormat;
-    public Boolean relativeDateInChangeTable;
-    public Boolean sizeBarInChangeTable;
-    public Boolean legacycidInChangeTable;
-    public Boolean muteCommonPathPrefixes;
-    public ReviewCategoryStrategy reviewCategoryStrategy;
-    public DiffView diffView;
-    public List<TopMenu.MenuItem> my;
-    public Map<String, String> urlAliases;
-  }
-
+public class SetPreferences implements
+    RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final AccountCache cache;
-  private final Provider<ReviewDb> db;
+  private final GeneralPreferencesLoader loader;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
   private final DynamicMap<DownloadScheme> downloadSchemes;
@@ -87,113 +61,63 @@
   @Inject
   SetPreferences(Provider<CurrentUser> self,
       AccountCache cache,
-      Provider<ReviewDb> db,
+      GeneralPreferencesLoader loader,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
       DynamicMap<DownloadScheme> downloadSchemes) {
     this.self = self;
+    this.loader = loader;
     this.cache = cache;
-    this.db = db;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
     this.downloadSchemes = downloadSchemes;
   }
 
   @Override
-  public GetPreferences.PreferenceInfo apply(AccountResource rsrc, Input i)
-      throws AuthException, ResourceNotFoundException, BadRequestException,
-      OrmException, IOException, ConfigInvalidException {
+  public GeneralPreferencesInfo apply(AccountResource rsrc,
+      GeneralPreferencesInfo i)
+          throws AuthException, BadRequestException, IOException,
+          ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("restricted to members of Modify Accounts");
-    }
-    if (i == null) {
-      i = new Input();
+      throw new AuthException("requires Modify Account capability");
     }
 
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    AccountGeneralPreferences p;
-    VersionedAccountPreferences versionedPrefs;
-    MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName);
-    db.get().accounts().beginTransaction(accountId);
-    try {
-      Account a = db.get().accounts().get(accountId);
-      if (a == null) {
-        throw new ResourceNotFoundException();
-      }
+    checkDownloadScheme(i.downloadScheme);
+    Account.Id id = rsrc.getUser().getAccountId();
+    GeneralPreferencesInfo n = loader.merge(id, i);
 
-      versionedPrefs = VersionedAccountPreferences.forUser(accountId);
-      versionedPrefs.load(md);
+    n.my = i.my;
+    n.urlAliases = i.urlAliases;
 
-      p = a.getGeneralPreferences();
-      if (p == null) {
-        p = new AccountGeneralPreferences();
-        a.setGeneralPreferences(p);
-      }
+    writeToGit(id, n);
 
-      if (i.changesPerPage != null) {
-        p.setMaximumPageSize(i.changesPerPage);
-      }
-      if (i.showSiteHeader != null) {
-        p.setShowSiteHeader(i.showSiteHeader);
-      }
-      if (i.useFlashClipboard != null) {
-        p.setUseFlashClipboard(i.useFlashClipboard);
-      }
-      if (i.downloadScheme != null) {
-        setDownloadScheme(p, i.downloadScheme);
-      }
-      if (i.downloadCommand != null) {
-        p.setDownloadCommand(i.downloadCommand);
-      }
-      if (i.copySelfOnEmail != null) {
-        p.setCopySelfOnEmails(i.copySelfOnEmail);
-      }
-      if (i.dateFormat != null) {
-        p.setDateFormat(i.dateFormat);
-      }
-      if (i.timeFormat != null) {
-        p.setTimeFormat(i.timeFormat);
-      }
-      if (i.relativeDateInChangeTable != null) {
-        p.setRelativeDateInChangeTable(i.relativeDateInChangeTable);
-      }
-      if (i.sizeBarInChangeTable != null) {
-        p.setSizeBarInChangeTable(i.sizeBarInChangeTable);
-      }
-      if (i.legacycidInChangeTable != null) {
-        p.setLegacycidInChangeTable(i.legacycidInChangeTable);
-      }
-      if (i.muteCommonPathPrefixes != null) {
-        p.setMuteCommonPathPrefixes(i.muteCommonPathPrefixes);
-      }
-      if (i.reviewCategoryStrategy != null) {
-        p.setReviewCategoryStrategy(i.reviewCategoryStrategy);
-      }
-      if (i.diffView != null) {
-        p.setDiffView(i.diffView);
-      }
+    return cache.get(id).getAccount().getGeneralPreferencesInfo();
+  }
 
-      db.get().accounts().update(Collections.singleton(a));
-      db.get().commit();
-      storeMyMenus(versionedPrefs, i.my);
-      storeUrlAliases(versionedPrefs, i.urlAliases);
-      versionedPrefs.commit(md);
-      cache.evict(accountId);
-      return new GetPreferences.PreferenceInfo(
-          p, versionedPrefs, md.getRepository());
-    } finally {
-      md.close();
-      db.get().rollback();
+  private void writeToGit(Account.Id id, GeneralPreferencesInfo i)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    VersionedAccountPreferences prefs;
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      prefs = VersionedAccountPreferences.forUser(id);
+      prefs.load(md);
+
+      storeSection(prefs.getConfig(), UserConfigSections.GENERAL, null, i,
+          GeneralPreferencesInfo.defaults());
+
+      storeMyMenus(prefs, i.my);
+      storeUrlAliases(prefs, i.urlAliases);
+      prefs.commit(md);
+      cache.evict(id);
     }
   }
 
   public static void storeMyMenus(VersionedAccountPreferences prefs,
-      List<TopMenu.MenuItem> my) {
+      List<MenuItem> my) {
     Config cfg = prefs.getConfig();
     if (my != null) {
       unsetSection(cfg, UserConfigSections.MY);
-      for (TopMenu.MenuItem item : my) {
+      for (MenuItem item : my) {
         set(cfg, item.name, KEY_URL, item.url);
         set(cfg, item.name, KEY_TARGET, item.target);
         set(cfg, item.name, KEY_ID, item.id);
@@ -233,15 +157,19 @@
     }
   }
 
-  private void setDownloadScheme(AccountGeneralPreferences p, String scheme)
+  private void checkDownloadScheme(String downloadScheme)
       throws BadRequestException {
+    if (Strings.isNullOrEmpty(downloadScheme)) {
+      return;
+    }
+
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
-      if (e.getExportName().equals(scheme)
+      if (e.getExportName().equals(downloadScheme)
           && e.getProvider().get().isEnabled()) {
-        p.setDownloadUrl(scheme);
         return;
       }
     }
-    throw new BadRequestException("Unsupported download scheme: " + scheme);
+    throw new BadRequestException(
+        "Unsupported download scheme: " + downloadScheme);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
index b35c03e..44a3192 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
@@ -28,22 +27,26 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+
 @Singleton
 public class SshKeys implements
     ChildCollection<AccountResource, AccountResource.SshKey> {
   private final DynamicMap<RestView<AccountResource.SshKey>> views;
   private final GetSshKeys list;
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
   SshKeys(DynamicMap<RestView<AccountResource.SshKey>> views,
       GetSshKeys list, Provider<CurrentUser> self,
-      Provider<ReviewDb> dbProvider) {
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.views = views;
     this.list = list;
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.authorizedKeys = authorizedKeys;
   }
 
   @Override
@@ -53,7 +56,8 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException,
+      ConfigInvalidException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new ResourceNotFoundException();
@@ -62,12 +66,10 @@
   }
 
   public AccountResource.SshKey parse(IdentifiedUser user, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
     try {
       int seq = Integer.parseInt(id.get(), 10);
-      AccountSshKey sshKey =
-          dbProvider.get().accountSshKeys()
-              .get(new AccountSshKey.Id(user.getAccountId(), seq));
+      AccountSshKey sshKey = authorizedKeys.getKey(user.getAccountId(), seq);
       if (sshKey == null) {
         throw new ResourceNotFoundException(id);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
index a3c0d37..b71fc68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -27,10 +27,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.query.change.QueryChanges;
@@ -43,7 +42,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
+import java.io.IOException;
 
 @Singleton
 public class StarredChanges implements
@@ -54,31 +53,29 @@
   private final ChangesCollection changes;
   private final DynamicMap<RestView<AccountResource.StarredChange>> views;
   private final Provider<Create> createProvider;
+  private final StarredChangesUtil starredChangesUtil;
 
   @Inject
   StarredChanges(ChangesCollection changes,
       DynamicMap<RestView<AccountResource.StarredChange>> views,
-      Provider<Create> createProvider) {
+      Provider<Create> createProvider,
+      StarredChangesUtil starredChangesUtil) {
     this.changes = changes;
     this.views = views;
     this.createProvider = createProvider;
+    this.starredChangesUtil = starredChangesUtil;
   }
 
   @Override
   public AccountResource.StarredChange parse(AccountResource parent, IdString id)
       throws ResourceNotFoundException, OrmException {
     IdentifiedUser user = parent.getUser();
-    try {
-      user.asyncStarredChanges();
-
-      ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-      if (user.getStarredChanges().contains(change.getChange().getId())) {
-        return new AccountResource.StarredChange(user, change);
-      }
-      throw new ResourceNotFoundException(id);
-    } finally {
-      user.abortStarredChanges();
+    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
+    if (starredChangesUtil.getLabels(user.getAccountId(), change.getId())
+        .contains(StarredChangesUtil.DEFAULT_LABEL)) {
+      return new AccountResource.StarredChange(user, change);
     }
+    throw new ResourceNotFoundException(id);
   }
 
   @Override
@@ -102,7 +99,7 @@
   @SuppressWarnings("unchecked")
   @Override
   public RestModifyView<AccountResource, EmptyInput> create(
-      AccountResource parent, IdString id) throws UnprocessableEntityException{
+      AccountResource parent, IdString id) throws UnprocessableEntityException {
     try {
       return createProvider.get()
           .setChange(changes.parse(TopLevelResource.INSTANCE, id));
@@ -117,13 +114,13 @@
   @Singleton
   public static class Create implements RestModifyView<AccountResource, EmptyInput> {
     private final Provider<CurrentUser> self;
-    private final Provider<ReviewDb> dbProvider;
+    private final StarredChangesUtil starredChangesUtil;
     private ChangeResource change;
 
     @Inject
-    Create(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
-      this.dbProvider = dbProvider;
+      this.starredChangesUtil = starredChangesUtil;
     }
 
     public Create setChange(ChangeResource change) {
@@ -133,15 +130,13 @@
 
     @Override
     public Response<?> apply(AccountResource rsrc, EmptyInput in)
-        throws AuthException, OrmException {
+        throws AuthException, OrmException, IOException {
       if (self.get() != rsrc.getUser()) {
         throw new AuthException("not allowed to add starred change");
       }
       try {
-        dbProvider.get().starredChanges().insert(Collections.singleton(
-            new StarredChange(new StarredChange.Key(
-                rsrc.getUser().getAccountId(),
-                change.getChange().getId()))));
+        starredChangesUtil.star(self.get().getAccountId(), change.getProject(),
+            change.getId(), StarredChangesUtil.DEFAULT_LABELS, null);
       } catch (OrmDuplicateKeyException e) {
         return Response.none();
       }
@@ -173,24 +168,23 @@
   public static class Delete implements
       RestModifyView<AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
-    private final Provider<ReviewDb> dbProvider;
+    private final StarredChangesUtil starredChangesUtil;
 
     @Inject
-    Delete(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+    Delete(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
-      this.dbProvider = dbProvider;
+      this.starredChangesUtil = starredChangesUtil;
     }
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc,
-        EmptyInput in) throws AuthException, OrmException {
+        EmptyInput in) throws AuthException, OrmException, IOException {
       if (self.get() != rsrc.getUser()) {
         throw new AuthException("not allowed remove starred change");
       }
-      dbProvider.get().starredChanges().delete(Collections.singleton(
-          new StarredChange(new StarredChange.Key(
-              rsrc.getUser().getAccountId(),
-              rsrc.getChange().getId()))));
+      starredChangesUtil.star(self.get().getAccountId(),
+          rsrc.getChange().getProject(), rsrc.getChange().getId(), null,
+          StarredChangesUtil.DEFAULT_LABELS);
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
new file mode 100644
index 0000000..fddbb6a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.account.AccountResource.Star;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.query.change.QueryChanges;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+
+@Singleton
+public class Stars implements
+    ChildCollection<AccountResource, AccountResource.Star> {
+
+  private final ChangesCollection changes;
+  private final ListStarredChanges listStarredChanges;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicMap<RestView<AccountResource.Star>> views;
+
+  @Inject
+  Stars(ChangesCollection changes,
+      ListStarredChanges listStarredChanges,
+      StarredChangesUtil starredChangesUtil,
+      DynamicMap<RestView<AccountResource.Star>> views) {
+    this.changes = changes;
+    this.listStarredChanges = listStarredChanges;
+    this.starredChangesUtil = starredChangesUtil;
+    this.views = views;
+  }
+
+  @Override
+  public Star parse(AccountResource parent, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    IdentifiedUser user = parent.getUser();
+    ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
+    Set<String> labels =
+        starredChangesUtil.getLabels(user.getAccountId(), change.getId());
+    return new AccountResource.Star(user, change, labels);
+  }
+
+  @Override
+  public DynamicMap<RestView<Star>> views() {
+    return views;
+  }
+
+  @Override
+  public ListStarredChanges list() {
+    return listStarredChanges;
+  }
+
+  @Singleton
+  public static class ListStarredChanges
+      implements RestReadView<AccountResource> {
+    private final Provider<CurrentUser> self;
+    private final ChangesCollection changes;
+
+    @Inject
+    ListStarredChanges(Provider<CurrentUser> self,
+        ChangesCollection changes) {
+      this.self = self;
+      this.changes = changes;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public List<ChangeInfo> apply(AccountResource rsrc)
+        throws BadRequestException, AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException(
+            "not allowed to list stars of another account");
+      }
+      QueryChanges query = changes.list();
+      query.addQuery("has:stars");
+      return (List<ChangeInfo>) query.apply(TopLevelResource.INSTANCE);
+    }
+  }
+
+  @Singleton
+  public static class Get implements
+      RestReadView<AccountResource.Star> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+
+    @Inject
+    Get(Provider<CurrentUser> self,
+        StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    @Override
+    public SortedSet<String> apply(AccountResource.Star rsrc)
+        throws AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to get stars of another account");
+      }
+      return starredChangesUtil.getLabels(self.get().getAccountId(),
+          rsrc.getChange().getId());
+    }
+  }
+
+  @Singleton
+  public static class Post implements
+      RestModifyView<AccountResource.Star, StarsInput> {
+    private final Provider<CurrentUser> self;
+    private final StarredChangesUtil starredChangesUtil;
+
+    @Inject
+    Post(Provider<CurrentUser> self,
+        StarredChangesUtil starredChangesUtil) {
+      this.self = self;
+      this.starredChangesUtil = starredChangesUtil;
+    }
+
+    @Override
+    public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
+        throws AuthException, BadRequestException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException(
+            "not allowed to update stars of another account");
+      }
+      try {
+        return starredChangesUtil.star(self.get().getAccountId(),
+            rsrc.getChange().getProject(), rsrc.getChange().getId(), in.add,
+            in.remove);
+      } catch (IllegalLabelException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
deleted file mode 100644
index 837e1ed..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-public class SuggestAccounts implements RestReadView<TopLevelResource> {
-  private static final int MAX_RESULTS = 100;
-  private static final String MAX_SUFFIX = "\u9fa5";
-
-  private final AccountControl accountControl;
-  private final AccountLoader accountLoader;
-  private final AccountCache accountCache;
-  private final ReviewDb db;
-  private final boolean suggest;
-  private final int suggestFrom;
-
-  private int limit = 10;
-  private String query;
-
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return")
-  public void setLimit(int n) {
-    if (n < 0) {
-      limit = 10;
-    } else if (n == 0) {
-      limit = MAX_RESULTS;
-    } else {
-      limit = Math.min(n, MAX_RESULTS);
-    }
-  }
-
-  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users")
-  public void setQuery(String query) {
-    this.query = query;
-  }
-
-  @Inject
-  SuggestAccounts(AccountControl.Factory accountControlFactory,
-      AccountLoader.Factory accountLoaderFactory,
-      AccountCache accountCache,
-      ReviewDb db,
-      @GerritServerConfig Config cfg) {
-    accountControl = accountControlFactory.get();
-    accountLoader = accountLoaderFactory.create(true);
-    this.accountCache = accountCache;
-    this.db = db;
-    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
-
-    if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
-      suggest = false;
-    } else {
-      boolean suggest;
-      try {
-        AccountVisibility av =
-            cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
-        suggest = (av != AccountVisibility.NONE);
-      } catch (IllegalArgumentException err) {
-        suggest = cfg.getBoolean("suggest", null, "accounts", true);
-      }
-      this.suggest = suggest;
-    }
-  }
-
-  @Override
-  public List<AccountInfo> apply(TopLevelResource rsrc)
-      throws OrmException, BadRequestException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (!suggest || query.length() < suggestFrom) {
-      return Collections.emptyList();
-    }
-
-    String a = query;
-    String b = a + MAX_SUFFIX;
-
-    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-    Map<Account.Id, String> queryEmail = new HashMap<>();
-
-    for (Account p : db.accounts().suggestByFullName(a, b, limit)) {
-      addSuggestion(matches, p);
-    }
-    if (matches.size() < limit) {
-      for (Account p : db.accounts()
-          .suggestByPreferredEmail(a, b, limit - matches.size())) {
-        addSuggestion(matches, p);
-      }
-    }
-    if (matches.size() < limit) {
-      for (AccountExternalId e : db.accountExternalIds()
-          .suggestByEmailAddress(a, b, limit - matches.size())) {
-        if (addSuggestion(matches, e.getAccountId())) {
-          queryEmail.put(e.getAccountId(), e.getEmailAddress());
-        }
-      }
-    }
-
-    accountLoader.fill();
-    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
-      AccountInfo info = matches.get(p.getKey());
-      if (info != null) {
-        info.email = p.getValue();
-      }
-    }
-
-    List<AccountInfo> m = new ArrayList<>(matches.values());
-    Collections.sort(m, new Comparator<AccountInfo>() {
-      @Override
-      public int compare(AccountInfo a, AccountInfo b) {
-        return ComparisonChain.start()
-          .compare(a.name, b.name, Ordering.natural().nullsLast())
-          .compare(a.email, b.email, Ordering.natural().nullsLast())
-          .result();
-      }
-    });
-    return m;
-  }
-
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account a) {
-    if (!a.isActive()) {
-      return false;
-    }
-    Account.Id id = a.getId();
-    if (!map.containsKey(id) && accountControl.canSee(id)) {
-      map.put(id, accountLoader.get(id));
-      return true;
-    }
-    return false;
-  }
-
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account.Id id) {
-    Account a = accountCache.get(id).getAccount();
-    return addSuggestion(map, a);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 4a652b4..3fccacce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -35,6 +35,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -78,7 +79,7 @@
     }
     GroupBackend b = backend(uuid);
     if (b == null) {
-      log.warn("Unknown GroupBackend for UUID: " + uuid);
+      log.debug("Unknown GroupBackend for UUID: " + uuid);
       return null;
     }
     return b.get(uuid);
@@ -129,7 +130,7 @@
      }
      GroupMembership m = membership(uuid);
      if (m == null) {
-       log.warn("Unknown GroupMembership for UUID: " + uuid);
+       log.debug("Unknown GroupMembership for UUID: " + uuid);
        return false;
      }
      return m.contains(uuid);
@@ -145,7 +146,7 @@
         }
         GroupMembership m = membership(uuid);
         if (m == null) {
-          log.warn("Unknown GroupMembership for UUID: " + uuid);
+          log.debug("Unknown GroupMembership for UUID: " + uuid);
           continue;
         }
         lookups.put(m, uuid);
@@ -175,12 +176,12 @@
         }
         GroupMembership m = membership(uuid);
         if (m == null) {
-          log.warn("Unknown GroupMembership for UUID: " + uuid);
+          log.debug("Unknown GroupMembership for UUID: " + uuid);
           continue;
         }
         lookups.put(m, uuid);
       }
-      Set<AccountGroup.UUID> groups = Sets.newHashSet();
+      Set<AccountGroup.UUID> groups = new HashSet<>();
       for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry
           : lookups.asMap().entrySet()) {
         groups.addAll(entry.getKey().intersection(entry.getValue()));
@@ -190,7 +191,7 @@
 
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
-      Set<AccountGroup.UUID> groups = Sets.newHashSet();
+      Set<AccountGroup.UUID> groups = new HashSet<>();
       for (GroupMembership m : memberships.values()) {
         groups.addAll(m.getKnownGroups());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
index da7d141..2273426 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
@@ -27,7 +27,7 @@
 
 /** Preferences for user accounts. */
 public class VersionedAccountPreferences extends VersionedMetaData {
-  private static final String PREFERENCES = "preferences.config";
+  public static final String PREFERENCES = "preferences.config";
 
   public static VersionedAccountPreferences forUser(Account.Id id) {
     return new VersionedAccountPreferences(RefNames.refsUsers(id));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
new file mode 100644
index 0000000..bb744ce
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -0,0 +1,298 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.client.AccountSshKey.Id;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.ssh.SshKeyCreator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 'authorized_keys' file in the refs/users/CD/ABCD branches of the All-Users
+ * repository.
+ *
+ * The `authorized_keys' files stores the public SSH keys of the user. The file
+ * format matches the standard SSH file format, which means that each key is
+ * stored on a separate line (see
+ * https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys).
+ *
+ * The order of the keys in the file determines the sequence numbers of the
+ * keys. The first line corresponds to sequence number 1.
+ *
+ * Invalid keys are marked with the prefix <code># INVALID</code>.
+ *
+ * To keep the sequence numbers intact when a key is deleted, a
+ * <code># DELETED</code> line is inserted at the position where the key was
+ * deleted.
+ *
+ * Other comment lines are ignored on read, and are not written back when the
+ * file is modified.
+ */
+public class VersionedAuthorizedKeys extends VersionedMetaData {
+  @Singleton
+  public static class Accessor {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final VersionedAuthorizedKeys.Factory authorizedKeysFactory;
+    private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+    private final IdentifiedUser.GenericFactory userFactory;
+
+    @Inject
+    Accessor(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        VersionedAuthorizedKeys.Factory authorizedKeysFactory,
+        Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+        IdentifiedUser.GenericFactory userFactory) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.authorizedKeysFactory = authorizedKeysFactory;
+      this.metaDataUpdateFactory = metaDataUpdateFactory;
+      this.userFactory = userFactory;
+    }
+
+    public List<AccountSshKey> getKeys(Account.Id accountId)
+        throws IOException, ConfigInvalidException {
+      return read(accountId).getKeys();
+    }
+
+    public AccountSshKey getKey(Account.Id accountId, int seq)
+        throws IOException, ConfigInvalidException {
+      return read(accountId).getKey(seq);
+    }
+
+    public synchronized AccountSshKey addKey(Account.Id accountId, String pub)
+        throws IOException, ConfigInvalidException, InvalidSshKeyException {
+      VersionedAuthorizedKeys authorizedKeys = read(accountId);
+      AccountSshKey key = authorizedKeys.addKey(pub);
+      commit(authorizedKeys);
+      return key;
+    }
+
+    public synchronized void deleteKey(Account.Id accountId, int seq)
+        throws IOException, ConfigInvalidException {
+      VersionedAuthorizedKeys authorizedKeys = read(accountId);
+      if (authorizedKeys.deleteKey(seq)) {
+        commit(authorizedKeys);
+      }
+    }
+
+    public synchronized void markKeyInvalid(Account.Id accountId, int seq)
+        throws IOException, ConfigInvalidException {
+      VersionedAuthorizedKeys authorizedKeys = read(accountId);
+      if (authorizedKeys.markKeyInvalid(seq)) {
+        commit(authorizedKeys);
+      }
+    }
+
+    private VersionedAuthorizedKeys read(Account.Id accountId)
+        throws IOException, ConfigInvalidException {
+      try (Repository git = repoManager.openRepository(allUsersName)) {
+        VersionedAuthorizedKeys authorizedKeys =
+            authorizedKeysFactory.create(accountId);
+        authorizedKeys.load(git);
+        return authorizedKeys;
+      }
+    }
+
+    private void commit(VersionedAuthorizedKeys authorizedKeys)
+        throws IOException {
+      try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName,
+          userFactory.create(authorizedKeys.accountId))) {
+        authorizedKeys.commit(md);
+      }
+    }
+  }
+
+  public static class SimpleSshKeyCreator implements SshKeyCreator {
+    @Override
+    public AccountSshKey create(Id id, String encoded) {
+      return new AccountSshKey(id, encoded);
+    }
+  }
+
+  public interface Factory {
+    VersionedAuthorizedKeys create(Account.Id accountId);
+  }
+
+  private final SshKeyCreator sshKeyCreator;
+  private final Account.Id accountId;
+  private final String ref;
+  private List<Optional<AccountSshKey>> keys;
+
+  @Inject
+  public VersionedAuthorizedKeys(
+      SshKeyCreator sshKeyCreator,
+      @Assisted Account.Id accountId) {
+    this.sshKeyCreator = sshKeyCreator;
+    this.accountId = accountId;
+    this.ref = RefNames.refsUsers(accountId);
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException {
+    keys = AuthorizedKeys.parse(accountId, readUTF8(AuthorizedKeys.FILE_NAME));
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Updated SSH keys\n");
+    }
+
+    saveUTF8(AuthorizedKeys.FILE_NAME, AuthorizedKeys.serialize(keys));
+    return true;
+  }
+
+  /** Returns all SSH keys. */
+  private List<AccountSshKey> getKeys() {
+    checkLoaded();
+    return Lists.newArrayList(Optional.presentInstances(keys));
+  }
+
+  /**
+   * Returns the SSH key with the given sequence number.
+   *
+   * @param seq sequence number
+   * @return the SSH key, <code>null</code> if there is no SSH key with this
+   *         sequence number, or if the SSH key with this sequence number has
+   *         been deleted
+   */
+  private AccountSshKey getKey(int seq) {
+    checkLoaded();
+    Optional<AccountSshKey> key = keys.get(seq - 1);
+    return key.orNull();
+  }
+
+  /**
+   * Adds a new public SSH key.
+   *
+   * If the specified public key exists already, the existing key is returned.
+   *
+   * @param pub the public SSH key to be added
+   * @return the new SSH key
+   * @throws InvalidSshKeyException
+   */
+  private AccountSshKey addKey(String pub) throws InvalidSshKeyException {
+    checkLoaded();
+
+    for (Optional<AccountSshKey> key : keys) {
+      if (key.isPresent()
+          && key.get().getSshPublicKey().trim().equals(pub.trim())) {
+        return key.get();
+      }
+    }
+
+    int seq = keys.size() + 1;
+    AccountSshKey.Id keyId = new AccountSshKey.Id(accountId, seq);
+    AccountSshKey key = sshKeyCreator.create(keyId, pub);
+    keys.add(Optional.of(key));
+    return key;
+  }
+
+  /**
+   * Deletes the SSH key with the given sequence number.
+   *
+   * @param seq the sequence number
+   * @return <code>true</code> if a key with this sequence number was found and
+   *         deleted, <code>false</code> if no key with the given sequence
+   *         number exists
+   */
+  private boolean deleteKey(int seq) {
+    checkLoaded();
+    if (seq <= keys.size() && keys.get(seq - 1).isPresent()) {
+      keys.set(seq - 1, Optional.<AccountSshKey> absent());
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Marks the SSH key with the given sequence number as invalid.
+   *
+   * @param seq the sequence number
+   * @return <code>true</code> if a key with this sequence number was found and
+   *         marked as invalid, <code>false</code> if no key with the given
+   *         sequence number exists or if the key was already marked as invalid
+   */
+  private boolean markKeyInvalid(int seq) {
+    checkLoaded();
+    AccountSshKey key = getKey(seq);
+    if (key != null && key.isValid()) {
+      key.setInvalid();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Sets new SSH keys.
+   *
+   * The existing SSH keys are overwritten.
+   *
+   * @param newKeys the new public SSH keys
+   */
+  public void setKeys(Collection<AccountSshKey> newKeys) {
+    Ordering<AccountSshKey> o =
+        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
+          @Override
+          public Integer apply(AccountSshKey sshKey) {
+            return sshKey.getKey().get();
+          }
+        });
+    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).getKey().get(),
+        Optional.<AccountSshKey> absent()));
+    for (AccountSshKey key : newKeys) {
+      keys.set(key.getKey().get() - 1, Optional.of(key));
+    }
+  }
+
+  private void checkLoaded() {
+    checkState(keys != null, "SSH keys not loaded yet");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
new file mode 100644
index 0000000..f24ef2e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
@@ -0,0 +1,371 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
+import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * ‘watch.config’ file in the user branch in the All-Users repository that
+ * contains the watch configuration of the user.
+ * <p>
+ * The 'watch.config' file is a git config file that has one 'project' section
+ * for all project watches of a project.
+ * <p>
+ * The project name is used as subsection name and the filters with the notify
+ * types that decide for which events email notifications should be sent are
+ * represented as 'notify' values in the subsection. A 'notify' value is
+ * formatted as {@code <filter> [<comma-separated-list-of-notify-types>]}:
+ *
+ * <pre>
+ *   [project "foo"]
+ *     notify = * [ALL_COMMENTS]
+ *     notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
+ *     notify = branch:master owner:self [SUBMITTED_CHANGES]
+ * </pre>
+ * <p>
+ * If two notify values in the same subsection have the same filter they are
+ * merged on the next save, taking the union of the notify types.
+ * <p>
+ * For watch configurations that notify on no event the list of notify types is
+ * empty:
+ *
+ * <pre>
+ *   [project "foo"]
+ *     notify = branch:master []
+ * </pre>
+ * <p>
+ * Unknown notify types are ignored and removed on save.
+ */
+public class WatchConfig extends VersionedMetaData
+    implements ValidationError.Sink {
+  @Singleton
+  public static class Accessor {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+    private final IdentifiedUser.GenericFactory userFactory;
+
+    @Inject
+    Accessor(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+        IdentifiedUser.GenericFactory userFactory) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.metaDataUpdateFactory = metaDataUpdateFactory;
+      this.userFactory = userFactory;
+    }
+
+    public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches(
+        Account.Id accountId) throws IOException, ConfigInvalidException {
+      try (Repository git = repoManager.openRepository(allUsersName)) {
+        WatchConfig watchConfig = new WatchConfig(accountId);
+        watchConfig.load(git);
+        return watchConfig.getProjectWatches();
+      }
+    }
+
+    public synchronized void upsertProjectWatches(Account.Id accountId,
+        Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
+        throws IOException, ConfigInvalidException {
+      WatchConfig watchConfig = read(accountId);
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+          watchConfig.getProjectWatches();
+      projectWatches.putAll(newProjectWatches);
+      commit(watchConfig);
+    }
+
+    public synchronized void deleteProjectWatches(Account.Id accountId,
+        Collection<ProjectWatchKey> projectWatchKeys)
+            throws IOException, ConfigInvalidException {
+      WatchConfig watchConfig = read(accountId);
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+          watchConfig.getProjectWatches();
+      boolean commit = false;
+      for (ProjectWatchKey key : projectWatchKeys) {
+        if (projectWatches.remove(key) != null) {
+          commit = true;
+        }
+      }
+      if (commit) {
+        commit(watchConfig);
+      }
+    }
+
+    public synchronized void deleteAllProjectWatches(Account.Id accountId)
+        throws IOException, ConfigInvalidException {
+      WatchConfig watchConfig = read(accountId);
+      boolean commit = false;
+      if (!watchConfig.getProjectWatches().isEmpty()) {
+        watchConfig.getProjectWatches().clear();
+        commit = true;
+      }
+      if (commit) {
+        commit(watchConfig);
+      }
+    }
+
+    private WatchConfig read(Account.Id accountId)
+        throws IOException, ConfigInvalidException {
+      try (Repository git = repoManager.openRepository(allUsersName)) {
+        WatchConfig watchConfig = new WatchConfig(accountId);
+        watchConfig.load(git);
+        return watchConfig;
+      }
+    }
+
+    private void commit(WatchConfig watchConfig)
+        throws IOException {
+      try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName,
+          userFactory.create(watchConfig.accountId))) {
+        watchConfig.commit(md);
+      }
+    }
+  }
+
+  @AutoValue
+  public abstract static class ProjectWatchKey {
+    public static ProjectWatchKey create(Project.NameKey project,
+        @Nullable String filter) {
+      return new AutoValue_WatchConfig_ProjectWatchKey(project,
+          Strings.emptyToNull(filter));
+    }
+
+    public abstract Project.NameKey project();
+    public abstract @Nullable String filter();
+  }
+
+  public static final String WATCH_CONFIG = "watch.config";
+  public static final String PROJECT = "project";
+  public static final String KEY_NOTIFY = "notify";
+
+  private final Account.Id accountId;
+  private final String ref;
+
+  private Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
+  private List<ValidationError> validationErrors;
+
+  public WatchConfig(Account.Id accountId) {
+    this.accountId = accountId;
+    this.ref = RefNames.refsUsers(accountId);
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    Config cfg = readConfig(WATCH_CONFIG);
+    projectWatches = parse(accountId, cfg, this);
+  }
+
+  @VisibleForTesting
+  public static Map<ProjectWatchKey, Set<NotifyType>> parse(
+      Account.Id accountId, Config cfg,
+      ValidationError.Sink validationErrorSink) {
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+    for (String projectName : cfg.getSubsections(PROJECT)) {
+      String[] notifyValues =
+          cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
+      for (String nv : notifyValues) {
+        if (Strings.isNullOrEmpty(nv)) {
+          continue;
+        }
+
+        NotifyValue notifyValue =
+            NotifyValue.parse(accountId, projectName, nv, validationErrorSink);
+        if (notifyValue == null) {
+          continue;
+        }
+
+        ProjectWatchKey key = ProjectWatchKey
+            .create(new Project.NameKey(projectName), notifyValue.filter());
+        if (!projectWatches.containsKey(key)) {
+          projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
+        }
+        projectWatches.get(key).addAll(notifyValue.notifyTypes());
+      }
+    }
+    return projectWatches;
+  }
+
+  Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
+    checkLoaded();
+    return projectWatches;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit)
+      throws IOException, ConfigInvalidException {
+    checkLoaded();
+
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Updated watch configuration\n");
+    }
+
+    Config cfg = readConfig(WATCH_CONFIG);
+
+    for (String projectName : cfg.getSubsections(PROJECT)) {
+      cfg.unset(PROJECT, projectName, KEY_NOTIFY);
+    }
+
+    Multimap<String, String> notifyValuesByProject = ArrayListMultimap.create();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches
+        .entrySet()) {
+      NotifyValue notifyValue =
+          NotifyValue.create(e.getKey().filter(), e.getValue());
+      notifyValuesByProject.put(e.getKey().project().get(),
+          notifyValue.toString());
+    }
+
+    for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap()
+        .entrySet()) {
+      cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY,
+          new ArrayList<>(e.getValue()));
+    }
+
+    saveConfig(WATCH_CONFIG, cfg);
+    return true;
+  }
+
+  private void checkLoaded() {
+    checkState(projectWatches != null, "project watches not loaded yet");
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+
+  /**
+   * Get the validation errors, if any were discovered during load.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return ImmutableList.copyOf(validationErrors);
+    }
+    return ImmutableList.of();
+  }
+
+  @AutoValue
+  public abstract static class NotifyValue {
+    public static NotifyValue parse(Account.Id accountId, String project,
+        String notifyValue, ValidationError.Sink validationErrorSink) {
+      notifyValue = notifyValue.trim();
+      int i = notifyValue.lastIndexOf('[');
+      if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
+        validationErrorSink.error(new ValidationError(WATCH_CONFIG,
+            String.format(
+                "Invalid project watch of account %d for project %s: %s",
+                accountId.get(), project, notifyValue)));
+        return null;
+      }
+      String filter = notifyValue.substring(0, i).trim();
+      if (filter.isEmpty() || AccountProjectWatch.FILTER_ALL.equals(filter)) {
+        filter = null;
+      }
+
+      Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
+      if (i + 1 < notifyValue.length() - 2) {
+        for (String nt : Splitter.on(',').trimResults().splitToList(
+            notifyValue.substring(i + 1, notifyValue.length() - 1))) {
+          Optional<NotifyType> notifyType =
+              Enums.getIfPresent(NotifyType.class, nt);
+          if (!notifyType.isPresent()) {
+            validationErrorSink.error(new ValidationError(WATCH_CONFIG,
+                String.format(
+                    "Invalid notify type %s in project watch "
+                        + "of account %d for project %s: %s",
+                    nt, accountId.get(), project, notifyValue)));
+            continue;
+          }
+          notifyTypes.add(notifyType.get());
+        }
+      }
+      return create(filter, notifyTypes);
+    }
+
+    public static NotifyValue create(@Nullable String filter,
+        Set<NotifyType> notifyTypes) {
+      return new AutoValue_WatchConfig_NotifyValue(Strings.emptyToNull(filter),
+          Sets.immutableEnumSet(notifyTypes));
+    }
+
+    public abstract @Nullable String filter();
+    public abstract ImmutableSet<NotifyType> notifyTypes();
+
+    @Override
+    public String toString() {
+      List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
+      StringBuilder notifyValue = new StringBuilder();
+      notifyValue.append(firstNonNull(filter(), AccountProjectWatch.FILTER_ALL))
+          .append(" [");
+      Joiner.on(", ").appendTo(notifyValue, notifyTypes);
+      notifyValue.append("]");
+      return notifyValue.toString();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index c6e4ad1..1bca929 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -14,28 +14,60 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.extensions.common.AgreementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AddSshKey;
 import com.google.gerrit.server.account.CreateEmail;
+import com.google.gerrit.server.account.DeleteSshKey;
+import com.google.gerrit.server.account.DeleteWatchedProjects;
+import com.google.gerrit.server.account.GetAgreements;
+import com.google.gerrit.server.account.GetAvatar;
+import com.google.gerrit.server.account.GetDiffPreferences;
+import com.google.gerrit.server.account.GetEditPreferences;
+import com.google.gerrit.server.account.GetPreferences;
+import com.google.gerrit.server.account.GetSshKeys;
+import com.google.gerrit.server.account.GetWatchedProjects;
+import com.google.gerrit.server.account.Index;
+import com.google.gerrit.server.account.PostWatchedProjects;
+import com.google.gerrit.server.account.PutAgreement;
+import com.google.gerrit.server.account.SetDiffPreferences;
+import com.google.gerrit.server.account.SetEditPreferences;
+import com.google.gerrit.server.account.SetPreferences;
+import com.google.gerrit.server.account.SshKeys;
 import com.google.gerrit.server.account.StarredChanges;
+import com.google.gerrit.server.account.Stars;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.SortedSet;
 
 public class AccountApiImpl implements AccountApi {
   interface Factory {
@@ -45,26 +77,86 @@
   private final AccountResource account;
   private final ChangesCollection changes;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final GetAvatar getAvatar;
+  private final GetPreferences getPreferences;
+  private final SetPreferences setPreferences;
+  private final GetDiffPreferences getDiffPreferences;
+  private final SetDiffPreferences setDiffPreferences;
+  private final GetEditPreferences getEditPreferences;
+  private final SetEditPreferences setEditPreferences;
+  private final GetWatchedProjects getWatchedProjects;
+  private final PostWatchedProjects postWatchedProjects;
+  private final DeleteWatchedProjects deleteWatchedProjects;
   private final StarredChanges.Create starredChangesCreate;
   private final StarredChanges.Delete starredChangesDelete;
+  private final Stars stars;
+  private final Stars.Get starsGet;
+  private final Stars.Post starsPost;
   private final CreateEmail.Factory createEmailFactory;
   private final GpgApiAdapter gpgApiAdapter;
+  private final GetSshKeys getSshKeys;
+  private final AddSshKey addSshKey;
+  private final DeleteSshKey deleteSshKey;
+  private final SshKeys sshKeys;
+  private final GetAgreements getAgreements;
+  private final PutAgreement putAgreement;
+  private final Index index;
 
   @Inject
   AccountApiImpl(AccountLoader.Factory ailf,
       ChangesCollection changes,
+      GetAvatar getAvatar,
+      GetPreferences getPreferences,
+      SetPreferences setPreferences,
+      GetDiffPreferences getDiffPreferences,
+      SetDiffPreferences setDiffPreferences,
+      GetEditPreferences getEditPreferences,
+      SetEditPreferences setEditPreferences,
+      GetWatchedProjects getWatchedProjects,
+      PostWatchedProjects postWatchedProjects,
+      DeleteWatchedProjects deleteWatchedProjects,
       StarredChanges.Create starredChangesCreate,
       StarredChanges.Delete starredChangesDelete,
+      Stars stars,
+      Stars.Get starsGet,
+      Stars.Post starsPost,
       CreateEmail.Factory createEmailFactory,
       GpgApiAdapter gpgApiAdapter,
+      GetSshKeys getSshKeys,
+      AddSshKey addSshKey,
+      DeleteSshKey deleteSshKey,
+      SshKeys sshKeys,
+      GetAgreements getAgreements,
+      PutAgreement putAgreement,
+      Index index,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
     this.changes = changes;
+    this.getAvatar = getAvatar;
+    this.getPreferences = getPreferences;
+    this.setPreferences = setPreferences;
+    this.getDiffPreferences = getDiffPreferences;
+    this.setDiffPreferences = setDiffPreferences;
+    this.getEditPreferences = getEditPreferences;
+    this.setEditPreferences = setEditPreferences;
+    this.getWatchedProjects = getWatchedProjects;
+    this.postWatchedProjects = postWatchedProjects;
+    this.deleteWatchedProjects = deleteWatchedProjects;
     this.starredChangesCreate = starredChangesCreate;
     this.starredChangesDelete = starredChangesDelete;
+    this.stars = stars;
+    this.starsGet = starsGet;
+    this.starsPost = starsPost;
     this.createEmailFactory = createEmailFactory;
+    this.getSshKeys = getSshKeys;
+    this.addSshKey = addSshKey;
+    this.deleteSshKey = deleteSshKey;
+    this.sshKeys = sshKeys;
     this.gpgApiAdapter = gpgApiAdapter;
+    this.getAgreements = getAgreements;
+    this.putAgreement = putAgreement;
+    this.index = index;
   }
 
   @Override
@@ -81,44 +173,195 @@
   }
 
   @Override
-  public void starChange(String id) throws RestApiException {
+  public String getAvatarUrl(int size) throws RestApiException {
+    getAvatar.setSize(size);
+    return getAvatar.apply(account).location();
+  }
+
+  @Override
+  public GeneralPreferencesInfo getPreferences() throws RestApiException {
+    return getPreferences.apply(account);
+  }
+
+  @Override
+  public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setPreferences.apply(account, in);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot set preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
+    try {
+      return getDiffPreferences.apply(account);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot query diff preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setDiffPreferences.apply(account, in);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot set diff preferences", e);
+    }
+  }
+
+  @Override
+  public EditPreferencesInfo getEditPreferences() throws RestApiException {
+    try {
+      return getEditPreferences.apply(account);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot query edit preferences", e);
+    }
+  }
+
+  @Override
+  public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setEditPreferences.apply(account, in);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot set edit preferences", e);
+    }
+  }
+
+  @Override
+  public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
+    try {
+      return getWatchedProjects.apply(account);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot get watched projects", e);
+    }
+  }
+
+  @Override
+  public List<ProjectWatchInfo> setWatchedProjects(
+      List<ProjectWatchInfo> in) throws RestApiException {
+    try {
+      return postWatchedProjects.apply(account, in);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot update watched projects", e);
+    }
+  }
+
+  @Override
+  public void deleteWatchedProjects(List<ProjectWatchInfo> in)
+      throws RestApiException {
+    try {
+      deleteWatchedProjects.apply(account, in);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot delete watched projects", e);
+    }
+  }
+
+  @Override
+  public void starChange(String changeId) throws RestApiException {
     try {
       ChangeResource rsrc = changes.parse(
         TopLevelResource.INSTANCE,
-        IdString.fromUrl(id));
+        IdString.fromUrl(changeId));
       starredChangesCreate.setChange(rsrc);
       starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot star change", e);
     }
   }
 
   @Override
-  public void unstarChange(String id) throws RestApiException {
+  public void unstarChange(String changeId) throws RestApiException {
     try {
       ChangeResource rsrc =
-          changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id));
+          changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
       starredChangesDelete.apply(starredChange,
           new StarredChanges.EmptyInput());
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot unstar change", e);
     }
   }
 
   @Override
+  public void setStars(String changeId, StarsInput input)
+      throws RestApiException {
+    try {
+      AccountResource.Star rsrc =
+          stars.parse(account, IdString.fromUrl(changeId));
+      starsPost.apply(rsrc, input);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot post stars", e);
+    }
+  }
+
+  @Override
+  public SortedSet<String> getStars(String changeId) throws RestApiException {
+    try {
+      AccountResource.Star rsrc =
+          stars.parse(account, IdString.fromUrl(changeId));
+      return starsGet.apply(rsrc);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get stars", e);
+    }
+  }
+
+  @Override
+  public List<ChangeInfo> getStarredChanges() throws RestApiException {
+    try {
+      return stars.list().apply(account);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get starred changes", e);
+    }
+  }
+
+  @Override
   public void addEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc =
         new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException e) {
+    } catch (EmailException | OrmException | IOException e) {
       throw new RestApiException("Cannot add email", e);
     }
   }
 
   @Override
+  public List<SshKeyInfo> listSshKeys() throws RestApiException {
+    try {
+      return getSshKeys.apply(account);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot list SSH keys", e);
+    }
+  }
+
+  @Override
+  public SshKeyInfo addSshKey(String key) throws RestApiException {
+    AddSshKey.Input in = new AddSshKey.Input();
+    in.raw = RawInputUtil.create(key);
+    try {
+      return addSshKey.apply(account, in).value();
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot add SSH key", e);
+    }
+  }
+
+  @Override
+  public void deleteSshKey(int seq) throws RestApiException {
+    try {
+      AccountResource.SshKey sshKeyRes =
+          sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
+      deleteSshKey.apply(sshKeyRes, null);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot delete SSH key", e);
+    }
+  }
+
+  @Override
   public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
     try {
       return gpgApiAdapter.listGpgKeys(account);
@@ -145,4 +388,29 @@
       throw new RestApiException("Cannot get PGP key", e);
     }
   }
+
+  @Override
+  public List<AgreementInfo> listAgreements() throws RestApiException {
+    return getAgreements.apply(account);
+  }
+
+  @Override
+  public void signAgreement(String agreementName) throws RestApiException {
+    try {
+      AgreementInput input = new AgreementInput();
+      input.name = agreementName;
+      putAgreement.apply(account, input);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot sign agreement", e);
+    }
+  }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(account, new Index.Input());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot index account", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
new file mode 100644
index 0000000..a0b9b4e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.accounts;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+
+import java.util.List;
+
+public interface AccountExternalIdCreator {
+
+  /**
+   * Returns additional external identifiers to assign to a given
+   * user when creating an account.
+   *
+   * @param id the identifier of the account.
+   * @param username the name of the user.
+   * @param email an optional email address to assign to the external
+   * identifiers, or {@code null}.
+   *
+   * @return a list of external identifiers, or an empty list.
+   */
+  List<AccountExternalId> create(Account.Id id, String username,
+      String email);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
new file mode 100644
index 0000000..f89e5ca
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.accounts;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.Comparator;
+
+public class AccountInfoComparator extends Ordering<AccountInfo>
+    implements Comparator<AccountInfo> {
+  public static final AccountInfoComparator ORDER_NULLS_FIRST =
+      new AccountInfoComparator();
+  public static final AccountInfoComparator ORDER_NULLS_LAST =
+      new AccountInfoComparator().setNullsLast();
+
+  private boolean nullsLast;
+
+  private AccountInfoComparator() {
+  }
+
+  private AccountInfoComparator setNullsLast() {
+    this.nullsLast = true;
+    return this;
+  }
+
+  @Override
+  public int compare(AccountInfo a, AccountInfo b) {
+    return ComparisonChain.start()
+        .compare(a.name, b.name, createOrdering())
+        .compare(a.email, b.email, createOrdering())
+        .compare(a._accountId, b._accountId, createOrdering())
+        .result();
+  }
+
+  private <S extends Comparable<?>> Ordering<S> createOrdering() {
+    if (nullsLast) {
+      return Ordering.natural().nullsLast();
+    }
+    return Ordering.natural().nullsFirst();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 7be8299..6a248f7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -14,22 +14,32 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+
 import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.account.SuggestAccounts;
+import com.google.gerrit.server.account.CreateAccount;
+import com.google.gerrit.server.account.QueryAccounts;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
 import java.util.List;
 
 @Singleton
@@ -37,17 +47,20 @@
   private final AccountsCollection accounts;
   private final AccountApiImpl.Factory api;
   private final Provider<CurrentUser> self;
-  private final Provider<SuggestAccounts> suggestAccountsProvider;
+  private final CreateAccount.Factory createAccount;
+  private final Provider<QueryAccounts> queryAccountsProvider;
 
   @Inject
   AccountsImpl(AccountsCollection accounts,
       AccountApiImpl.Factory api,
       Provider<CurrentUser> self,
-      Provider<SuggestAccounts> suggestAccountsProvider) {
+      CreateAccount.Factory createAccount,
+      Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
     this.self = self;
-    this.suggestAccountsProvider = suggestAccountsProvider;
+    this.createAccount = createAccount;
+    this.queryAccountsProvider = queryAccountsProvider;
   }
 
   @Override
@@ -61,6 +74,11 @@
   }
 
   @Override
+  public AccountApi id(int id) throws RestApiException {
+    return id(String.valueOf(id));
+  }
+
+  @Override
   public AccountApi self() throws RestApiException {
     if (!self.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
@@ -69,6 +87,28 @@
   }
 
   @Override
+  public AccountApi create(String username) throws RestApiException {
+    AccountInput in = new AccountInput();
+    in.username = username;
+    return create(in);
+  }
+
+  @Override
+  public AccountApi create(AccountInput in) throws RestApiException {
+    if (checkNotNull(in, "AccountInput").username == null) {
+      throw new BadRequestException("AccountInput must specify username");
+    }
+    checkRequiresCapability(self, null, CreateAccount.class);
+    try {
+      AccountInfo info = createAccount.create(in.username)
+          .apply(TopLevelResource.INSTANCE, in).value();
+      return id(info._accountId);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot create account " + in.username, e);
+    }
+  }
+
+  @Override
   public SuggestAccountsRequest suggestAccounts() throws RestApiException {
     return new SuggestAccountsRequest() {
       @Override
@@ -87,10 +127,42 @@
   private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r)
     throws RestApiException {
     try {
-      SuggestAccounts mySuggestAccounts = suggestAccountsProvider.get();
-      mySuggestAccounts.setQuery(r.getQuery());
-      mySuggestAccounts.setLimit(r.getLimit());
-      return mySuggestAccounts.apply(TopLevelResource.INSTANCE);
+      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
+      myQueryAccounts.setSuggest(true);
+      myQueryAccounts.setQuery(r.getQuery());
+      myQueryAccounts.setLimit(r.getLimit());
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve suggested accounts", e);
+    }
+  }
+
+  @Override
+  public QueryRequest query() throws RestApiException {
+    return new QueryRequest() {
+      @Override
+      public List<AccountInfo> get() throws RestApiException {
+        return AccountsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) throws RestApiException {
+    return query().withQuery(query);
+  }
+
+  private List<AccountInfo> query(QueryRequest r)
+    throws RestApiException {
+    try {
+      QueryAccounts myQueryAccounts = queryAccountsProvider.get();
+      myQueryAccounts.setQuery(r.getQuery());
+      myQueryAccounts.setLimit(r.getLimit());
+      myQueryAccounts.setStart(r.getStart());
+      for (ListAccountsOption option : r.getOptions()) {
+        myQueryAccounts.addOption(option);
+      }
+      return myQueryAccounts.apply(TopLevelResource.INSTANCE);
     } catch (OrmException e) {
       throw new RestApiException("Cannot retrieve suggested accounts", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
index 7d65ce9..a83110c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
@@ -27,6 +27,8 @@
 import java.util.Map;
 
 public interface GpgApiAdapter {
+  boolean isEnabled();
+
   Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index d4f6851..9bfb342 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -20,9 +20,13 @@
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -31,28 +35,32 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeEdits;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Check;
+import com.google.gerrit.server.change.DeleteDraftChange;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetTopic;
+import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
 import com.google.gerrit.server.change.ListChangeDrafts;
+import com.google.gerrit.server.change.Move;
 import com.google.gerrit.server.change.PostHashtags;
 import com.google.gerrit.server.change.PostReviewers;
+import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
+import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import java.io.IOException;
@@ -66,16 +74,20 @@
     ChangeApiImpl create(ChangeResource change);
   }
 
-  private final Provider<CurrentUser> user;
   private final Changes changeApi;
+  private final Reviewers reviewers;
   private final Revisions revisions;
+  private final ReviewerApiImpl.Factory reviewerApi;
   private final RevisionApiImpl.Factory revisionApi;
-  private final Provider<SuggestChangeReviewers> suggestReviewers;
+  private final SuggestChangeReviewers suggestReviewers;
   private final ChangeResource change;
   private final Abandon abandon;
   private final Revert revert;
   private final Restore restore;
   private final SubmittedTogether submittedTogether;
+  private final PublishDraftPatchSet.CurrentRevision
+    publishDraftChange;
+  private final DeleteDraftChange deleteDraftChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
   private final PostReviewers postReviewers;
@@ -85,18 +97,23 @@
   private final ListChangeComments listComments;
   private final ListChangeDrafts listDrafts;
   private final Check check;
+  private final Index index;
   private final ChangeEdits.Detail editDetail;
+  private final Move move;
 
   @Inject
-  ChangeApiImpl(Provider<CurrentUser> user,
-      Changes changeApi,
+  ChangeApiImpl(Changes changeApi,
+      Reviewers reviewers,
       Revisions revisions,
+      ReviewerApiImpl.Factory reviewerApi,
       RevisionApiImpl.Factory revisionApi,
-      Provider<SuggestChangeReviewers> suggestReviewers,
+      SuggestChangeReviewers suggestReviewers,
       Abandon abandon,
       Revert revert,
       Restore restore,
       SubmittedTogether submittedTogether,
+      PublishDraftPatchSet.CurrentRevision publishDraftChange,
+      DeleteDraftChange deleteDraftChange,
       GetTopic getTopic,
       PutTopic putTopic,
       PostReviewers postReviewers,
@@ -106,17 +123,22 @@
       ListChangeComments listComments,
       ListChangeDrafts listDrafts,
       Check check,
+      Index index,
       ChangeEdits.Detail editDetail,
+      Move move,
       @Assisted ChangeResource change) {
-    this.user = user;
     this.changeApi = changeApi;
     this.revert = revert;
+    this.reviewers = reviewers;
     this.revisions = revisions;
+    this.reviewerApi = reviewerApi;
     this.revisionApi = revisionApi;
     this.suggestReviewers = suggestReviewers;
     this.abandon = abandon;
     this.restore = restore;
     this.submittedTogether = submittedTogether;
+    this.publishDraftChange = publishDraftChange;
+    this.deleteDraftChange = deleteDraftChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
     this.postReviewers = postReviewers;
@@ -126,13 +148,15 @@
     this.listComments = listComments;
     this.listDrafts = listDrafts;
     this.check = check;
+    this.index = index;
     this.editDetail = editDetail;
+    this.move = move;
     this.change = change;
   }
 
   @Override
   public String id() {
-    return Integer.toString(change.getChange().getId().get());
+    return Integer.toString(change.getId().get());
   }
 
   @Override
@@ -156,6 +180,16 @@
   }
 
   @Override
+  public ReviewerApi reviewer(String id) throws RestApiException {
+    try {
+      return reviewerApi.create(
+          reviewers.parse(change, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot parse reviewer", e);
+    }
+  }
+
+  @Override
   public void abandon() throws RestApiException {
     abandon(new AbandonInput());
   }
@@ -184,6 +218,22 @@
   }
 
   @Override
+  public void move(String destination) throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    move(in);
+  }
+
+  @Override
+  public void move(MoveInput in) throws RestApiException {
+    try {
+      move.apply(change, in);
+    } catch (OrmException | UpdateException e) {
+      throw new RestApiException("Cannot move change", e);
+    }
+  }
+
+  @Override
   public ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
   }
@@ -192,21 +242,51 @@
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
       return changeApi.id(revert.apply(change, in)._number);
-    } catch (OrmException | IOException | UpdateException e) {
+    } catch (OrmException | IOException | UpdateException
+        | NoSuchChangeException e) {
       throw new RestApiException("Cannot revert change", e);
     }
   }
 
+  @SuppressWarnings("unchecked")
   @Override
   public List<ChangeInfo> submittedTogether() throws RestApiException {
     try {
-      return submittedTogether.apply(change);
-    } catch (Exception e) {
+      return (List<ChangeInfo>) submittedTogether.apply(change);
+    } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot query submittedTogether", e);
     }
   }
 
   @Override
+  public SubmittedTogetherInfo submittedTogether(
+      EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+    try {
+      return submittedTogether.apply(change, options);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot query submittedTogether", e);
+    }
+  }
+
+  @Override
+  public void publish() throws RestApiException {
+    try {
+      publishDraftChange.apply(change, null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot publish change", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteDraftChange.apply(change, null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete change", e);
+    }
+  }
+
+  @Override
   public String topic() throws RestApiException {
     return getTopic.apply(change);
   }
@@ -233,7 +313,7 @@
   public void addReviewer(AddReviewerInput in) throws RestApiException {
     try {
       postReviewers.apply(change, in);
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | UpdateException e) {
       throw new RestApiException("Cannot add change reviewer", e);
     }
   }
@@ -257,10 +337,9 @@
   private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
       throws RestApiException {
     try {
-      SuggestChangeReviewers mySuggestReviewers = suggestReviewers.get();
-      mySuggestReviewers.setQuery(r.getQuery());
-      mySuggestReviewers.setLimit(r.getLimit());
-      return mySuggestReviewers.apply(change);
+      suggestReviewers.setQuery(r.getQuery());
+      suggestReviewers.setLimit(r.getLimit());
+      return suggestReviewers.apply(change);
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot retrieve suggested reviewers", e);
     }
@@ -270,10 +349,6 @@
   public ChangeInfo get(EnumSet<ListChangesOption> s)
       throws RestApiException {
     try {
-      CurrentUser u = user.get();
-      if (u.isIdentifiedUser()) {
-        u.asIdentifiedUser().clearStarredChanges();
-      }
       return changeJson.create(s).format(change);
     } catch (OrmException e) {
       throw new RestApiException("Cannot retrieve change", e);
@@ -353,4 +428,13 @@
       throw new RestApiException("Cannot check change", e);
     }
   }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(change, new Index.Input());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot index change", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index acda1ee..bb2eea7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -23,12 +23,13 @@
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
 import com.google.gerrit.server.git.UpdateException;
@@ -44,19 +45,16 @@
 
 @Singleton
 class ChangesImpl implements Changes {
-  private final Provider<CurrentUser> user;
   private final ChangesCollection changes;
   private final ChangeApiImpl.Factory api;
   private final CreateChange createChange;
   private final Provider<QueryChanges> queryProvider;
 
   @Inject
-  ChangesImpl(Provider<CurrentUser> user,
-      ChangesCollection changes,
+  ChangesImpl(ChangesCollection changes,
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       Provider<QueryChanges> queryProvider) {
-    this.user = user;
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
@@ -89,12 +87,11 @@
   }
 
   @Override
-  public ChangeApi create(ChangeInfo in) throws RestApiException {
+  public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(
           TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(TopLevelResource.INSTANCE,
-          IdString.fromUrl(out.changeId)));
+      return api.create(changes.parse(new Change.Id(out._number)));
     } catch (OrmException | IOException | InvalidChangeOperationException
         | UpdateException e) {
       throw new RestApiException("Cannot create change", e);
@@ -128,10 +125,6 @@
     }
 
     try {
-      CurrentUser u = user.get();
-      if (u.isIdentifiedUser()) {
-        u.asIdentifiedUser().clearStarredChanges();
-      }
       List<?> result = qc.apply(TopLevelResource.INSTANCE);
       if (result.isEmpty()) {
         return ImmutableList.of();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 647f577..2e2dfcc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -22,12 +22,11 @@
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.GetDraftComment;
 import com.google.gerrit.server.change.PutDraftComment;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
-import java.io.IOException;
-
 class DraftApiImpl implements DraftApi {
   interface Factory {
     DraftApiImpl create(DraftCommentResource d);
@@ -62,7 +61,7 @@
   public CommentInfo update(DraftInput in) throws RestApiException {
     try {
       return putDraft.apply(draft, in).value();
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException | OrmException e) {
       throw new RestApiException("Cannot update draft", e);
     }
   }
@@ -71,7 +70,7 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(draft, null);
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException e) {
       throw new RestApiException("Cannot delete draft", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index c09890f..e6ca18df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import java.io.IOException;
@@ -36,12 +35,12 @@
   }
 
   private final GetContent getContent;
-  private final Provider<GetDiff> getDiff;
+  private final GetDiff getDiff;
   private final FileResource file;
 
   @Inject
   FileApiImpl(GetContent getContent,
-      Provider<GetDiff> getDiff,
+      GetDiff getDiff,
       @Assisted FileResource file) {
     this.getContent = getContent;
     this.getDiff = getDiff;
@@ -60,7 +59,7 @@
   @Override
   public DiffInfo diff() throws RestApiException {
     try {
-      return getDiff.get().apply(file).value();
+      return getDiff.apply(file).value();
     } catch (IOException | InvalidChangeOperationException | OrmException e) {
       throw new RestApiException("Cannot retrieve diff", e);
     }
@@ -69,7 +68,46 @@
   @Override
   public DiffInfo diff(String base) throws RestApiException {
     try {
-      return getDiff.get().setBase(base).apply(file).value();
+      return getDiff.setBase(base).apply(file).value();
+    } catch (IOException | InvalidChangeOperationException | OrmException e) {
+      throw new RestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
+  public DiffInfo diff(int parent) throws RestApiException {
+    try {
+      return getDiff.setParent(parent).apply(file).value();
+    } catch (OrmException | InvalidChangeOperationException | IOException e) {
+      throw new RestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
+  public DiffRequest diffRequest() {
+    return new DiffRequest() {
+      @Override
+      public DiffInfo get() throws RestApiException {
+        return FileApiImpl.this.get(this);
+      }
+    };
+  }
+
+  private DiffInfo get(DiffRequest r) throws RestApiException {
+    if (r.getBase() != null) {
+      getDiff.setBase(r.getBase());
+    }
+    if (r.getContext() != null) {
+      getDiff.setContext(r.getContext());
+    }
+    if (r.getIntraline() != null) {
+      getDiff.setIntraline(r.getIntraline());
+    }
+    if (r.getWhitespace() != null) {
+      getDiff.setWhitespace(r.getWhitespace());
+    }
+    try {
+      return getDiff.apply(file).value();
     } catch (IOException | InvalidChangeOperationException | OrmException e) {
       throw new RestApiException("Cannot retrieve diff", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
index a5e584e..228dad6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
@@ -27,5 +27,6 @@
     factory(DraftApiImpl.Factory.class);
     factory(RevisionApiImpl.Factory.class);
     factory(FileApiImpl.Factory.class);
+    factory(ReviewerApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
new file mode 100644
index 0000000..a18c575
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.DeleteReviewer;
+import com.google.gerrit.server.change.DeleteVote;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.change.Votes;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import java.util.Map;
+
+public class ReviewerApiImpl implements ReviewerApi {
+  interface Factory {
+    ReviewerApiImpl create(ReviewerResource r);
+  }
+
+  private final ReviewerResource reviewer;
+  private final Votes.List listVotes;
+  private final DeleteVote deleteVote;
+  private final DeleteReviewer deleteReviewer;
+
+  @Inject
+  ReviewerApiImpl(Votes.List listVotes,
+      DeleteVote deleteVote,
+      DeleteReviewer deleteReviewer,
+      @Assisted ReviewerResource reviewer) {
+    this.listVotes = listVotes;
+    this.deleteVote = deleteVote;
+    this.deleteReviewer = deleteReviewer;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public Map<String, Short> votes() throws RestApiException {
+    try {
+      return listVotes.apply(reviewer);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot list votes", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(String label) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, label), null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(DeleteVoteInput input) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void remove() throws RestApiException {
+    try {
+      deleteReviewer.apply(reviewer, null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot remove reviewer", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 0926142..6b5e83c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -27,10 +27,12 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -54,12 +56,17 @@
 import com.google.gerrit.server.change.Reviewed;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Submit;
+import com.google.gerrit.server.change.TestSubmitType;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -70,6 +77,7 @@
     RevisionApiImpl create(RevisionResource r);
   }
 
+  private final GitRepositoryManager repoManager;
   private final Changes changes;
   private final CherryPick cherryPick;
   private final DeleteDraftPatchSet deleteDraft;
@@ -80,11 +88,11 @@
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
-  private final Provider<Files> files;
-  private final Provider<Files.ListFiles> listFiles;
-  private final Provider<GetPatch> getPatch;
-  private final Provider<PostReview> review;
-  private final Provider<Mergeable> mergeable;
+  private final Files files;
+  private final Files.ListFiles listFiles;
+  private final GetPatch getPatch;
+  private final PostReview review;
+  private final Mergeable mergeable;
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
   private final ListRevisionDrafts listDrafts;
@@ -94,9 +102,12 @@
   private final Comments comments;
   private final CommentApiImpl.Factory commentFactory;
   private final GetRevisionActions revisionActions;
+  private final TestSubmitType testSubmitType;
+  private final TestSubmitType.Get getSubmitType;
 
   @Inject
-  RevisionApiImpl(Changes changes,
+  RevisionApiImpl(GitRepositoryManager repoManager,
+      Changes changes,
       CherryPick cherryPick,
       DeleteDraftPatchSet deleteDraft,
       Rebase rebase,
@@ -105,11 +116,11 @@
       PublishDraftPatchSet publish,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
-      Provider<Files> files,
-      Provider<Files.ListFiles> listFiles,
-      Provider<GetPatch> getPatch,
-      Provider<PostReview> review,
-      Provider<Mergeable> mergeable,
+      Files files,
+      Files.ListFiles listFiles,
+      GetPatch getPatch,
+      PostReview review,
+      Mergeable mergeable,
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
       ListRevisionDrafts listDrafts,
@@ -119,7 +130,10 @@
       Comments comments,
       CommentApiImpl.Factory commentFactory,
       GetRevisionActions revisionActions,
+      TestSubmitType testSubmitType,
+      TestSubmitType.Get getSubmitType,
       @Assisted RevisionResource r) {
+    this.repoManager = repoManager;
     this.changes = changes;
     this.cherryPick = cherryPick;
     this.deleteDraft = deleteDraft;
@@ -143,14 +157,16 @@
     this.comments = comments;
     this.commentFactory = commentFactory;
     this.revisionActions = revisionActions;
+    this.testSubmitType = testSubmitType;
+    this.getSubmitType = getSubmitType;
     this.revision = r;
   }
 
   @Override
   public void review(ReviewInput in) throws RestApiException {
     try {
-      review.get().apply(revision, in);
-    } catch (OrmException | UpdateException e) {
+      review.apply(revision, in);
+    } catch (OrmException | UpdateException | IOException e) {
       throw new RestApiException("Cannot post review", e);
     }
   }
@@ -183,7 +199,7 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(revision, null);
-    } catch (OrmException | IOException e) {
+    } catch (UpdateException e) {
       throw new RestApiException("Cannot delete draft ps", e);
     }
   }
@@ -198,14 +214,21 @@
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
-    } catch (OrmException | EmailException | UpdateException | IOException e) {
+    } catch (OrmException | EmailException | UpdateException | IOException
+        | NoSuchChangeException e) {
       throw new RestApiException("Cannot rebase ps", e);
     }
   }
 
   @Override
-  public boolean canRebase() {
-    return rebaseUtil.canRebase(revision);
+  public boolean canRebase() throws RestApiException {
+    try (Repository repo = repoManager.openRepository(revision.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      return rebaseUtil.canRebase(
+          revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot check if rebase is possible", e);
+    }
   }
 
   @Override
@@ -227,7 +250,7 @@
         view = deleteReviewed;
       }
       view.apply(
-          files.get().parse(revision, IdString.fromDecoded(path)),
+          files.parse(revision, IdString.fromDecoded(path)),
           new Reviewed.Input());
     } catch (Exception e) {
       throw new RestApiException("Cannot update reviewed flag", e);
@@ -239,7 +262,7 @@
   public Set<String> reviewed() throws RestApiException {
     try {
       return ImmutableSet.copyOf((Iterable<String>) listFiles
-          .get().setReviewed(true)
+          .setReviewed(true)
           .apply(revision).value());
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot list reviewed files", e);
@@ -249,7 +272,7 @@
   @Override
   public MergeableInfo mergeable() throws RestApiException {
     try {
-      return mergeable.get().apply(revision);
+      return mergeable.apply(revision);
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot check mergeability", e);
     }
@@ -258,9 +281,8 @@
   @Override
   public MergeableInfo mergeableOtherBranches() throws RestApiException {
     try {
-      Mergeable m = mergeable.get();
-      m.setOtherBranches(true);
-      return m.apply(revision);
+      mergeable.setOtherBranches(true);
+      return mergeable.apply(revision);
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot check mergeability", e);
     }
@@ -270,7 +292,7 @@
   @Override
   public Map<String, FileInfo> files() throws RestApiException {
     try {
-      return (Map<String, FileInfo>)listFiles.get().apply(revision).value();
+      return (Map<String, FileInfo>)listFiles.apply(revision).value();
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
@@ -280,7 +302,18 @@
   @Override
   public Map<String, FileInfo> files(String base) throws RestApiException {
     try {
-      return (Map<String, FileInfo>) listFiles.get().setBase(base)
+      return (Map<String, FileInfo>) listFiles.setBase(base)
+          .apply(revision).value();
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return (Map<String, FileInfo>) listFiles.setParent(parentNum)
           .apply(revision).value();
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot retrieve files", e);
@@ -289,7 +322,7 @@
 
   @Override
   public FileApi file(String path) {
-    return fileApi.create(files.get().parse(revision,
+    return fileApi.create(files.parse(revision,
         IdString.fromDecoded(path)));
   }
 
@@ -347,7 +380,7 @@
       return changes.id(revision.getChange().getId().get())
           .revision(revision.getPatchSet().getId().get())
           .draft(id);
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException | OrmException e) {
       throw new RestApiException("Cannot create draft", e);
     }
   }
@@ -365,7 +398,7 @@
   @Override
   public BinaryResult patch() throws RestApiException {
     try {
-      return getPatch.get().apply(revision);
+      return getPatch.apply(revision);
     } catch (IOException e) {
       throw new RestApiException("Cannot get patch", e);
     }
@@ -375,4 +408,23 @@
   public Map<String, ActionInfo> actions() throws RestApiException {
     return revisionActions.apply(revision).value();
   }
+
+  @Override
+  public SubmitType submitType() throws RestApiException {
+    try {
+      return getSubmitType.apply(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get submit type", e);
+    }
+  }
+
+  @Override
+  public SubmitType testSubmitType(TestSubmitRuleInput in)
+      throws RestApiException {
+    try {
+      return testSubmitType.apply(revision, in);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot test submit type", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index a8ee60d..8339ecf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -16,13 +16,81 @@
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.GetDiffPreferences;
+import com.google.gerrit.server.config.GetPreferences;
+import com.google.gerrit.server.config.SetDiffPreferences;
+import com.google.gerrit.server.config.SetPreferences;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+
 @Singleton
 public class ServerImpl implements Server {
+  private final GetPreferences getPreferences;
+  private final SetPreferences setPreferences;
+  private final GetDiffPreferences getDiffPreferences;
+  private final SetDiffPreferences setDiffPreferences;
+
+  @Inject
+  ServerImpl(GetPreferences getPreferences,
+      SetPreferences setPreferences,
+      GetDiffPreferences getDiffPreferences,
+      SetDiffPreferences setDiffPreferences) {
+    this.getPreferences = getPreferences;
+    this.setPreferences = setPreferences;
+    this.getDiffPreferences = getDiffPreferences;
+    this.setDiffPreferences = setDiffPreferences;
+  }
+
   @Override
   public String getVersion() throws RestApiException {
     return Version.getVersion();
   }
+
+  @Override
+  public GeneralPreferencesInfo getDefaultPreferences()
+      throws RestApiException {
+    try {
+      return getPreferences.apply(new ConfigResource());
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot get default general preferences", e);
+    }
+  }
+
+  @Override
+  public GeneralPreferencesInfo setDefaultPreferences(
+      GeneralPreferencesInfo in) throws RestApiException {
+    try {
+      return setPreferences.apply(new ConfigResource(), in);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot set default general preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo getDefaultDiffPreferences()
+      throws RestApiException {
+    try {
+      return getDiffPreferences.apply(new ConfigResource());
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot get default diff preferences", e);
+    }
+  }
+
+  @Override
+  public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
+      throws RestApiException {
+    try {
+      return setDiffPreferences.apply(new ConfigResource(), in);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot set default diff preferences", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index f11ed86..5660176 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -41,10 +41,10 @@
 import com.google.gerrit.server.group.PutOptions;
 import com.google.gerrit.server.group.PutOwner;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 
@@ -63,7 +63,7 @@
   private final PutDescription putDescription;
   private final GetOptions getOptions;
   private final PutOptions putOptions;
-  private final Provider<ListMembers> listMembers;
+  private final ListMembers listMembers;
   private final AddMembers addMembers;
   private final DeleteMembers deleteMembers;
   private final ListIncludedGroups listGroups;
@@ -84,7 +84,7 @@
       PutDescription putDescription,
       GetOptions getOptions,
       PutOptions putOptions,
-      Provider<ListMembers> listMembers,
+      ListMembers listMembers,
       AddMembers addMembers,
       DeleteMembers deleteMembers,
       ListIncludedGroups listGroups,
@@ -205,10 +205,9 @@
 
   @Override
   public List<AccountInfo> members(boolean recursive) throws RestApiException {
-    ListMembers list = listMembers.get();
-    list.setRecursive(recursive);
+    listMembers.setRecursive(recursive);
     try {
-      return list.apply(rsrc);
+      return listMembers.apply(rsrc);
     } catch (OrmException e) {
       throw new RestApiException("Cannot list group members", e);
     }
@@ -219,7 +218,7 @@
     try {
       addMembers.apply(
           rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot add group members", e);
     }
   }
@@ -229,7 +228,7 @@
     try {
       deleteMembers.apply(
           rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot remove group members", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 3d2c960..b509c55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -90,7 +90,7 @@
       GroupInfo info = createGroup.create(in.name)
           .apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot create group " + in.name, e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 0bb395f..7fbb1f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -17,12 +17,16 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.BranchesCollection;
 import com.google.gerrit.server.project.CreateBranch;
 import com.google.gerrit.server.project.DeleteBranch;
+import com.google.gerrit.server.project.FileResource;
+import com.google.gerrit.server.project.FilesCollection;
+import com.google.gerrit.server.project.GetContent;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -38,6 +42,8 @@
   private final BranchesCollection branches;
   private final CreateBranch.Factory createBranchFactory;
   private final DeleteBranch deleteBranch;
+  private final FilesCollection filesCollection;
+  private final GetContent getContent;
   private final String ref;
   private final ProjectResource project;
 
@@ -45,21 +51,22 @@
   BranchApiImpl(BranchesCollection branches,
       CreateBranch.Factory createBranchFactory,
       DeleteBranch deleteBranch,
+      FilesCollection filesCollection,
+      GetContent getContent,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
     this.createBranchFactory = createBranchFactory;
     this.deleteBranch = deleteBranch;
+    this.filesCollection = filesCollection;
+    this.getContent = getContent;
     this.project = project;
     this.ref = ref;
   }
 
   @Override
-  public BranchApi create(BranchInput in) throws RestApiException {
+  public BranchApi create(BranchInput input) throws RestApiException {
     try {
-      CreateBranch.Input input = new CreateBranch.Input();
-      input.ref = ref;
-      input.revision = in.revision;
       createBranchFactory.create(ref).apply(project, input);
       return this;
     } catch (IOException e) {
@@ -85,6 +92,17 @@
     }
   }
 
+  @Override
+  public BinaryResult file(String path) throws RestApiException {
+    try {
+      FileResource resource = filesCollection.parse(resource(),
+        IdString.fromDecoded(path));
+      return getContent.apply(resource);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot retrieve file", e);
+    }
+  }
+
   private BranchResource resource() throws RestApiException, IOException {
     return branches.parse(project, IdString.fromDecoded(ref));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 02dc919..b972f5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.GetChildProject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -28,14 +27,14 @@
     ChildProjectApiImpl create(ChildProjectResource rsrc);
   }
 
-  private final Provider<GetChildProject> getProvider;
+  private final GetChildProject getChildProject;
   private final ChildProjectResource rsrc;
 
   @AssistedInject
   ChildProjectApiImpl(
-      Provider<GetChildProject> getProvider,
+      GetChildProject getChildProject,
       @Assisted ChildProjectResource rsrc) {
-    this.getProvider = getProvider;
+    this.getChildProject = getChildProject;
     this.rsrc = rsrc;
   }
 
@@ -46,8 +45,7 @@
 
   @Override
   public ProjectInfo get(boolean recursive) throws RestApiException {
-    GetChildProject get = getProvider.get();
-    get.setRecursive(recursive);
-    return get.apply(rsrc);
+    getChildProject.setRecursive(recursive);
+    return getChildProject.apply(rsrc);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index dbd246c..b28258c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -16,12 +16,17 @@
 
 import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
 
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -34,6 +39,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ChildProjectsCollection;
 import com.google.gerrit.server.project.CreateProject;
+import com.google.gerrit.server.project.DeleteBranches;
+import com.google.gerrit.server.project.GetAccess;
+import com.google.gerrit.server.project.GetConfig;
 import com.google.gerrit.server.project.GetDescription;
 import com.google.gerrit.server.project.ListBranches;
 import com.google.gerrit.server.project.ListChildProjects;
@@ -41,8 +49,10 @@
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
+import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
-import com.google.inject.Provider;
+import com.google.gerrit.server.project.SetAccess;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -57,8 +67,8 @@
     ProjectApiImpl create(String name);
   }
 
-  private final Provider<CurrentUser> user;
-  private final Provider<CreateProject.Factory> createProjectFactory;
+  private final CurrentUser user;
+  private final CreateProject.Factory createProjectFactory;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
   private final GetDescription getDescription;
@@ -70,12 +80,17 @@
   private final String name;
   private final BranchApiImpl.Factory branchApi;
   private final TagApiImpl.Factory tagApi;
-  private final Provider<ListBranches> listBranchesProvider;
-  private final Provider<ListTags> listTagsProvider;
+  private final GetAccess getAccess;
+  private final SetAccess setAccess;
+  private final GetConfig getConfig;
+  private final PutConfig putConfig;
+  private final ListBranches listBranches;
+  private final ListTags listTags;
+  private final DeleteBranches deleteBranches;
 
   @AssistedInject
-  ProjectApiImpl(Provider<CurrentUser> user,
-      Provider<CreateProject.Factory> createProjectFactory,
+  ProjectApiImpl(CurrentUser user,
+      CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -85,17 +100,23 @@
       ProjectJson projectJson,
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
-      Provider<ListBranches> listBranchesProvider,
-      Provider<ListTags> listTagsProvider,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      ListBranches listBranches,
+      ListTags listTags,
+      DeleteBranches deleteBranches,
       @Assisted ProjectResource project) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, listBranchesProvider, listTagsProvider, project, null);
+        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
+        listTags, deleteBranches, project, null);
   }
 
   @AssistedInject
-  ProjectApiImpl(Provider<CurrentUser> user,
-      Provider<CreateProject.Factory> createProjectFactory,
+  ProjectApiImpl(CurrentUser user,
+      CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -105,16 +126,22 @@
       ProjectJson projectJson,
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
-      Provider<ListBranches> listBranchesProvider,
-      Provider<ListTags> listTagsProvider,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      ListBranches listBranches,
+      ListTags listTags,
+      DeleteBranches deleteBranches,
       @Assisted String name) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, listBranchesProvider, listTagsProvider, null, name);
+        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
+        listTags, deleteBranches, null, name);
   }
 
-  private ProjectApiImpl(Provider<CurrentUser> user,
-      Provider<CreateProject.Factory> createProjectFactory,
+  private ProjectApiImpl(CurrentUser user,
+      CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
       GetDescription getDescription,
@@ -124,8 +151,13 @@
       ProjectJson projectJson,
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
-      Provider<ListBranches> listBranchesProvider,
-      Provider<ListTags> listTagsProvider,
+      GetAccess getAccess,
+      SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
+      ListBranches listBranches,
+      ListTags listTags,
+      DeleteBranches deleteBranches,
       ProjectResource project,
       String name) {
     this.user = user;
@@ -141,8 +173,13 @@
     this.name = name;
     this.branchApi = branchApiFactory;
     this.tagApi = tagApiFactory;
-    this.listBranchesProvider = listBranchesProvider;
-    this.listTagsProvider = listTagsProvider;
+    this.getAccess = getAccess;
+    this.setAccess = setAccess;
+    this.getConfig = getConfig;
+    this.putConfig = putConfig;
+    this.listBranches = listBranches;
+    this.listTags = listTags;
+    this.deleteBranches = deleteBranches;
   }
 
   @Override
@@ -160,7 +197,7 @@
         throw new BadRequestException("name must match input.name");
       }
       checkRequiresCapability(user, null, CreateProject.class);
-      createProjectFactory.get().create(name)
+      createProjectFactory.create(name)
           .apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
     } catch (IOException | ConfigInvalidException e) {
@@ -182,7 +219,26 @@
   }
 
   @Override
-  public void description(PutDescriptionInput in)
+  public ProjectAccessInfo access() throws RestApiException {
+    try {
+      return getAccess.apply(checkExists());
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get access rights", e);
+    }
+  }
+
+  @Override
+  public ProjectAccessInfo access(ProjectAccessInput p)
+      throws RestApiException {
+    try {
+      return setAccess.apply(checkExists(), p);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot put access rights", e);
+    }
+  }
+
+  @Override
+  public void description(DescriptionInput in)
       throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
@@ -192,6 +248,16 @@
   }
 
   @Override
+  public ConfigInfo config() throws RestApiException {
+    return getConfig.apply(checkExists());
+  }
+
+  @Override
+  public ConfigInfo config(ConfigInput in) throws RestApiException {
+    return putConfig.apply(checkExists(), in);
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
     return new ListRefsRequest<BranchInfo>() {
       @Override
@@ -203,13 +269,12 @@
 
   private List<BranchInfo> listBranches(ListRefsRequest<BranchInfo> request)
       throws RestApiException {
-    ListBranches list = listBranchesProvider.get();
-    list.setLimit(request.getLimit());
-    list.setStart(request.getStart());
-    list.setMatchSubstring(request.getSubstring());
-    list.setMatchRegex(request.getRegex());
+    listBranches.setLimit(request.getLimit());
+    listBranches.setStart(request.getStart());
+    listBranches.setMatchSubstring(request.getSubstring());
+    listBranches.setMatchRegex(request.getRegex());
     try {
-      return list.apply(checkExists());
+      return listBranches.apply(checkExists());
     } catch (IOException e) {
       throw new RestApiException("Cannot list branches", e);
     }
@@ -227,13 +292,12 @@
 
   private List<TagInfo> listTags(ListRefsRequest<TagInfo> request)
       throws RestApiException {
-    ListTags list = listTagsProvider.get();
-    list.setLimit(request.getLimit());
-    list.setStart(request.getStart());
-    list.setMatchSubstring(request.getSubstring());
-    list.setMatchRegex(request.getRegex());
+    listTags.setLimit(request.getLimit());
+    listTags.setStart(request.getStart());
+    listTags.setMatchSubstring(request.getSubstring());
+    listTags.setMatchRegex(request.getRegex());
     try {
-      return list.apply(checkExists());
+      return listTags.apply(checkExists());
     } catch (IOException e) {
       throw new RestApiException("Cannot list tags", e);
     }
@@ -245,7 +309,8 @@
   }
 
   @Override
-  public List<ProjectInfo> children(boolean recursive) throws RestApiException {
+  public List<ProjectInfo> children(boolean recursive)
+      throws RestApiException {
     ListChildProjects list = children.list();
     list.setRecursive(recursive);
     return list.apply(checkExists());
@@ -271,6 +336,15 @@
     return tagApi.create(checkExists(), ref);
   }
 
+  @Override
+  public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+    try {
+      deleteBranches.apply(checkExists(), in);
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot delete branches", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index db31d42..5d21c45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -66,6 +66,9 @@
 
   @Override
   public ProjectApi create(ProjectInput in) throws RestApiException {
+    if (in.name == null) {
+      throw new BadRequestException("input.name is required");
+    }
     return name(in.name).create(in);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 086447d..3adfd00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -16,8 +16,10 @@
 
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.CreateTag;
 import com.google.gerrit.server.project.ListTags;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -31,19 +33,32 @@
   }
 
   private final ListTags listTags;
+  private final CreateTag.Factory createTagFactory;
   private final String ref;
   private final ProjectResource project;
 
   @Inject
   TagApiImpl(ListTags listTags,
+      CreateTag.Factory createTagFactory,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.listTags = listTags;
+    this.createTagFactory = createTagFactory;
     this.project = project;
     this.ref = ref;
   }
 
   @Override
+  public TagApi create(TagInput input) throws RestApiException {
+    try {
+      createTagFactory.create(ref).apply(project, input);
+      return this;
+    } catch (IOException e) {
+      throw new RestApiException("Cannot create tag", e);
+    }
+  }
+
+  @Override
   public TagInfo get() throws RestApiException {
     try {
       return listTags.get(project, IdString.fromDecoded(ref));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 8e71b88..7f5f2d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
@@ -23,6 +24,7 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.kohsuke.args4j.CmdLineException;
@@ -32,30 +34,37 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
+import java.io.IOException;
+
 public class AccountIdHandler extends OptionHandler<Account.Id> {
+  private final Provider<ReviewDb> db;
   private final AccountResolver accountResolver;
   private final AccountManager accountManager;
   private final AuthType authType;
 
   @Inject
-  public AccountIdHandler(final AccountResolver accountResolver,
-      final AccountManager accountManager,
-      final AuthConfig authConfig,
-      @Assisted final CmdLineParser parser, @Assisted final OptionDef option,
-      @Assisted final Setter<Account.Id> setter) {
+  public AccountIdHandler(
+      Provider<ReviewDb> db,
+      AccountResolver accountResolver,
+      AccountManager accountManager,
+      AuthConfig authConfig,
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<Account.Id> setter) {
     super(parser, option, setter);
+    this.db = db;
     this.accountResolver = accountResolver;
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
+  public int parseArguments(Parameters params)
       throws CmdLineException {
-    final String token = params.getParameter(0);
-    final Account.Id accountId;
+    String token = params.getParameter(0);
+    Account.Id accountId;
     try {
-      final Account a = accountResolver.find(token);
+      Account a = accountResolver.find(db.get(), token);
       if (a != null) {
         accountId = a.getId();
       } else {
@@ -65,11 +74,18 @@
           case LDAP:
             accountId = createAccountByLdap(token);
             break;
+          case CUSTOM_EXTENSION:
+          case DEVELOPMENT_BECOME_ANY_ACCOUNT:
+          case HTTP:
+          case LDAP_BIND:
+          case OAUTH:
+          case OPENID:
+          case OPENID_SSO:
           default:
             throw new CmdLineException(owner, "user \"" + token + "\" not found");
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new CmdLineException(owner, "database is down");
     }
     setter.addValue(accountId);
@@ -77,7 +93,7 @@
   }
 
   private Account.Id createAccountByLdap(String user)
-      throws CmdLineException {
+      throws CmdLineException, IOException {
     if (!user.matches(Account.USER_NAME_PATTERN)) {
       throw new CmdLineException(owner, "user \"" + user + "\" not found");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
index 87c8af2..76a9fd6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -38,8 +38,8 @@
 
   @Override
   public AuthUser authenticate(final AuthRequest request) throws AuthException {
-    List<AuthUser> authUsers = Lists.newArrayList();
-    List<AuthException> authExs = Lists.newArrayList();
+    List<AuthUser> authUsers = new ArrayList<>();
+    List<AuthException> authExs = new ArrayList<>();
     for (AuthBackend backend : authBackends) {
       try {
         authUsers.add(checkNotNull(backend.authenticate(request)));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
index b3ddae0..3567811 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -133,13 +133,12 @@
     env.put(Context.REFERRAL, referral);
     if ("GSSAPI".equals(authentication)) {
       return kerberosOpen(env);
-    } else {
-      if (username != null) {
-        env.put(Context.SECURITY_PRINCIPAL, username);
-        env.put(Context.SECURITY_CREDENTIALS, password);
-      }
-      return new InitialDirContext(env);
     }
+    if (username != null) {
+      env.put(Context.SECURITY_PRINCIPAL, username);
+      env.put(Context.SECURITY_CREDENTIALS, password);
+    }
+    return new InitialDirContext(env);
   }
 
   private DirContext kerberosOpen(final Properties env) throws LoginException,
@@ -271,9 +270,8 @@
 
     if (actual.isEmpty()) {
       return Collections.emptySet();
-    } else {
-      return ImmutableSet.copyOf(actual);
     }
+    return ImmutableSet.copyOf(actual);
   }
 
   private void recursivelyExpandGroups(final Set<String> groupDNs,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index cd1c4d3..30b08a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -58,7 +58,7 @@
 import javax.security.auth.login.LoginException;
 
 @Singleton
-public class LdapRealm extends AbstractRealm {
+class LdapRealm extends AbstractRealm {
   static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
   static final String LDAP = "com.sun.jndi.ldap.LdapCtxFactory";
   static final String USERNAME = "username";
@@ -283,16 +283,6 @@
   }
 
   @Override
-  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who) {
-    return who;
-  }
-
-  @Override
-  public AuthRequest unlink(ReviewDb db, Account.Id from, AuthRequest who) {
-    return who;
-  }
-
-  @Override
   public void onCreateAccount(final AuthRequest who, final Account account) {
     usernameCache.put(who.getLocalUser(), Optional.of(account.getId()));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
new file mode 100644
index 0000000..c07b4c8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.auth.oauth;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.server.account.AbstractRealm;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+@Singleton
+public class OAuthRealm extends AbstractRealm {
+  private final DynamicMap<OAuthLoginProvider> loginProviders;
+  private final Set<FieldName> editableAccountFields;
+
+  @Inject
+  OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders,
+      @GerritServerConfig Config config) {
+    this.loginProviders = loginProviders;
+    this.editableAccountFields = new HashSet<>();
+    // User name should be always editable, because not all OAuth providers
+    // expose them
+    editableAccountFields.add(FieldName.USER_NAME);
+    if (config.getBoolean("oauth", null, "allowEditFullName", false)) {
+      editableAccountFields.add(FieldName.FULL_NAME);
+    }
+    if (config.getBoolean("oauth", null, "allowRegisterNewEmail", false)) {
+      editableAccountFields.add(FieldName.REGISTER_NEW_EMAIL);
+    }
+  }
+
+  @Override
+  public boolean allowsEdit(FieldName field) {
+    return editableAccountFields.contains(field);
+  }
+
+  /**
+   * Authenticates with the {@link OAuthLoginProvider} specified
+   * in the authentication request.
+   *
+   * {@link AccountManager} calls this method without password
+   * if authenticity of the user has already been established.
+   * In that case we can skip the authentication request to the
+   * {@code OAuthLoginService}.
+   *
+   * @param who the authentication request.
+   *
+   * @return the authentication request with resolved email address
+   * and display name in case the authenticity of the user could
+   * be established; otherwise {@code who} is returned unchanged.
+   *
+   * @throws AccountException if the authentication request with
+   * the OAuth2 server failed or no {@code OAuthLoginProvider} was
+   * available to handle the request.
+   */
+  @Override
+  public AuthRequest authenticate(AuthRequest who) throws AccountException {
+    if (Strings.isNullOrEmpty(who.getPassword())) {
+      return who;
+    }
+
+    if (Strings.isNullOrEmpty(who.getAuthPlugin())
+        || Strings.isNullOrEmpty(who.getAuthProvider())) {
+      throw new AccountException("Cannot authenticate");
+    }
+    OAuthLoginProvider loginProvider =
+        loginProviders.get(who.getAuthPlugin(), who.getAuthProvider());
+    if (loginProvider == null) {
+      throw new AccountException("Cannot authenticate");
+    }
+
+    OAuthUserInfo userInfo;
+    try {
+      userInfo = loginProvider.login(who.getUserName(), who.getPassword());
+    } catch (IOException e) {
+      throw new AccountException("Cannot authenticate", e);
+    }
+    if (userInfo == null) {
+      throw new AccountException("Cannot authenticate");
+    }
+    if (!Strings.isNullOrEmpty(userInfo.getEmailAddress())
+        && (Strings.isNullOrEmpty(who.getUserName())
+            || !allowsEdit(FieldName.REGISTER_NEW_EMAIL))) {
+      who.setEmailAddress(userInfo.getEmailAddress());
+    }
+    if (!Strings.isNullOrEmpty(userInfo.getDisplayName())
+        && (Strings.isNullOrEmpty(who.getDisplayName())
+            || !allowsEdit(FieldName.FULL_NAME))) {
+      who.setDisplayName(userInfo.getDisplayName());
+    }
+    return who;
+  }
+
+  @Override
+  public void onCreateAccount(AuthRequest who, Account account) {
+  }
+
+  @Override
+  public Account.Id lookup(String accountName) {
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
new file mode 100644
index 0000000..94bdb06
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.auth.oauth;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+
+@Singleton
+public class OAuthTokenCache {
+  public static final String OAUTH_TOKENS = "oauth_tokens";
+
+  private final DynamicItem<OAuthTokenEncrypter> encrypter;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class);
+      }
+    };
+  }
+
+  private final Cache<Account.Id, OAuthToken> cache;
+
+  @Inject
+  OAuthTokenCache(@Named(OAUTH_TOKENS) Cache<Account.Id, OAuthToken> cache,
+      DynamicItem<OAuthTokenEncrypter> encrypter) {
+    this.cache = cache;
+    this.encrypter = encrypter;
+  }
+
+  public OAuthToken get(Account.Id id) {
+    OAuthToken accessToken = cache.getIfPresent(id);
+    if (accessToken == null) {
+      return null;
+    }
+    accessToken = decrypt(accessToken);
+    if (accessToken.isExpired()) {
+      cache.invalidate(id);
+      return null;
+    }
+    return accessToken;
+  }
+
+  public void put(Account.Id id, OAuthToken accessToken) {
+    cache.put(id, encrypt(checkNotNull(accessToken)));
+  }
+
+  public void remove(Account.Id id) {
+    cache.invalidate(id);
+  }
+
+  private OAuthToken encrypt(OAuthToken token) {
+    OAuthTokenEncrypter enc = encrypter.get();
+    if (enc == null) {
+      return token;
+    }
+    return enc.encrypt(token);
+  }
+
+  private OAuthToken decrypt(OAuthToken token) {
+    OAuthTokenEncrypter enc = encrypter.get();
+    if (enc == null) {
+      return token;
+    }
+    return enc.decrypt(token);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
index 4b30a8d..571a7e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
@@ -37,7 +37,7 @@
    *         {@code null} is acceptable, and results in the server responding
    *         with a 404. This will hide the avatar image in the web UI.
    */
-  public String getUrl(IdentifiedUser forUser, int imageSize);
+  String getUrl(IdentifiedUser forUser, int imageSize);
 
   /**
    * Gets a URL for a user to modify their avatar image.
@@ -46,5 +46,5 @@
    * @return a URL the user should visit to modify their avatar, or null if
    *         modification is not possible.
    */
-  public String getChangeAvatarUrl(IdentifiedUser forUser);
+  String getChangeAvatarUrl(IdentifiedUser forUser);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
new file mode 100644
index 0000000..ebf8259
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.Set;
+
+@Singleton
+public class CacheMetrics {
+  @Inject
+  public CacheMetrics(MetricMaker metrics,
+      final DynamicMap<Cache<?, ?>> cacheMap) {
+    Field<String> F_NAME = Field.ofString("cache_name");
+
+    final CallbackMetric1<String, Long> memEnt =
+        metrics.newCallbackMetric("caches/memory_cached", Long.class,
+            new Description("Memory entries").setGauge().setUnit("entries"),
+            F_NAME);
+    final CallbackMetric1<String, Double> memHit =
+        metrics.newCallbackMetric("caches/memory_hit_ratio", Double.class,
+            new Description("Memory hit ratio").setGauge().setUnit("percent"),
+            F_NAME);
+    final CallbackMetric1<String, Long> memEvict =
+        metrics.newCallbackMetric("caches/memory_eviction_count", Long.class,
+            new Description("Memory eviction count").setGauge()
+                .setUnit("evicted entries"),
+            F_NAME);
+    final CallbackMetric1<String, Long> perDiskEnt =
+        metrics.newCallbackMetric("caches/disk_cached", Long.class,
+            new Description("Disk entries used by persistent cache").setGauge()
+                .setUnit("entries"),
+            F_NAME);
+    final CallbackMetric1<String, Double> perDiskHit =
+        metrics.newCallbackMetric("caches/disk_hit_ratio", Double.class,
+            new Description("Disk hit ratio for persistent cache").setGauge()
+                .setUnit("percent"),
+            F_NAME);
+
+    final Set<CallbackMetric<?>> cacheMetrics =
+        ImmutableSet.<CallbackMetric<?>> of(memEnt, memHit, memEvict,
+            perDiskEnt, perDiskHit);
+
+    metrics.newTrigger(cacheMetrics, new Runnable() {
+      @Override
+      public void run() {
+        for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+          Cache<?, ?> c = e.getProvider().get();
+          String name = metricNameOf(e);
+          CacheStats cstats = c.stats();
+          memEnt.set(name, c.size());
+          memHit.set(name, cstats.hitRate() * 100);
+          memEvict.set(name, cstats.evictionCount());
+          if (c instanceof PersistentCache) {
+            PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+            perDiskEnt.set(name, d.size());
+            perDiskHit.set(name, hitRatio(d));
+          }
+        }
+        for (CallbackMetric<?> cbm : cacheMetrics) {
+          cbm.prune();
+        }
+      }
+    });
+  }
+
+  private static double hitRatio(PersistentCache.DiskStats d) {
+    if (d.requestCount() <= 0) {
+      return 100;
+    }
+    return ((double) d.hitCount() / d.requestCount() * 100);
+  }
+
+  private static String metricNameOf(DynamicMap.Entry<Cache<?, ?>> e) {
+    if ("gerrit".equals(e.getPluginName())) {
+      return e.getExportName();
+    }
+    return String.format("plugin/%s/%s", e.getPluginName(), e.getExportName());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
index 078f2dc..bdc1220 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
@@ -17,7 +17,7 @@
 import com.google.common.cache.RemovalNotification;
 
 public interface CacheRemovalListener<K,V> {
-  public void onRemoval(String pluginName,
+  void onRemoval(String pluginName,
     String cacheName,
     RemovalNotification<K, V> notification);
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index 69d523b..96b437d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -59,4 +59,4 @@
       l.onRemoval(pluginName, cacheName, notification);
     }
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java
index 62623ea..60c806b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCache.java
@@ -18,7 +18,7 @@
 
   DiskStats diskStats();
 
-  public static class DiskStats {
+  class DiskStats {
     private final long size;
     private final long space;
     private final long hitCount;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index f2d40c8..adbcf22 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -31,13 +31,16 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.mail.AbandonedSender;
 import com.google.gerrit.server.mail.ReplyToChangeSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -47,56 +50,62 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-
 @Singleton
 public class Abandon implements RestModifyView<ChangeResource, AbandonInput>,
     UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Abandon.class);
 
-  private final ChangeHooks hooks;
   private final AbandonedSender.Factory abandonedSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeAbandoned changeAbandoned;
 
   @Inject
-  Abandon(ChangeHooks hooks,
-      AbandonedSender.Factory abandonedSenderFactory,
+  Abandon(AbandonedSender.Factory abandonedSenderFactory,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
-    this.hooks = hooks;
+      PatchSetUtil psUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeAbandoned changeAbandoned) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.changeAbandoned = changeAbandoned;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req,
-      final AbandonInput input)
+  public ChangeInfo apply(ChangeResource req, AbandonInput input)
       throws RestApiException, UpdateException, OrmException {
     ChangeControl control = req.getControl();
-    IdentifiedUser caller = control.getUser().asIdentifiedUser();
-    if (!control.canAbandon()) {
+    if (!control.canAbandon(dbProvider.get())) {
       throw new AuthException("abandon not permitted");
     }
-    Change change = abandon(control, input.message, caller.getAccount());
+    Change change = abandon(control, input.message, input.notify);
     return json.create(ChangeJson.NO_OPTIONS).format(change);
   }
 
-  public Change abandon(ChangeControl control,
-      final String msgTxt, final Account account)
+  public Change abandon(ChangeControl control, String msgTxt)
       throws RestApiException, UpdateException {
-    Op op = new Op(msgTxt, account);
-    Change c = control.getChange();
+    return abandon(control, msgTxt, NotifyHandling.ALL);
+  }
+
+  public Change abandon(ChangeControl control, String msgTxt,
+      NotifyHandling notifyHandling) throws RestApiException, UpdateException {
+    CurrentUser user = control.getUser();
+    Account account = user.isIdentifiedUser()
+        ? user.asIdentifiedUser().getAccount()
+        : null;
+    Op op = new Op(msgTxt, account, notifyHandling);
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        c.getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(c.getId(), op).execute();
+        control.getProject().getNameKey(), user, TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
     }
     return op.change;
   }
@@ -108,32 +117,37 @@
     private Change change;
     private PatchSet patchSet;
     private ChangeMessage message;
+    private NotifyHandling notifyHandling;
 
-    private Op(String msgTxt, Account account) {
+    private Op(String msgTxt, Account account, NotifyHandling notifyHandling) {
       this.account = account;
       this.msgTxt = msgTxt;
+      this.notifyHandling = notifyHandling;
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException,
+    public boolean updateChange(ChangeContext ctx) throws OrmException,
         ResourceConflictException {
       change = ctx.getChange();
-      if (change == null || !change.getStatus().isOpen()) {
+      PatchSet.Id psId = change.currentPatchSetId();
+      ChangeUpdate update = ctx.getUpdate(psId);
+      if (!change.getStatus().isOpen()) {
         throw new ResourceConflictException("change is " + status(change));
       } else if (change.getStatus() == Change.Status.DRAFT) {
         throw new ResourceConflictException(
             "draft changes cannot be abandoned");
       }
-      patchSet = ctx.getDb().patchSets().get(change.currentPatchSetId());
+      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       change.setStatus(Change.Status.ABANDONED);
       change.setLastUpdatedOn(ctx.getWhen());
-      ctx.getDb().changes().update(Collections.singleton(change));
 
-      message = newMessage(ctx.getDb());
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(), message);
+      update.setStatus(change.getStatus());
+      message = newMessage(ctx);
+      cmUtil.addChangeMessage(ctx.getDb(), update, message);
+      return true;
     }
 
-    private ChangeMessage newMessage(ReviewDb db) throws OrmException {
+    private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
       StringBuilder msg = new StringBuilder();
       msg.append("Abandoned");
       if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
@@ -144,9 +158,9 @@
       ChangeMessage message = new ChangeMessage(
           new ChangeMessage.Key(
               change.getId(),
-              ChangeUtil.messageUUID(db)),
+              ChangeUtil.messageUUID(ctx.getDb())),
           account != null ? account.getId() : null,
-          change.getLastUpdatedOn(),
+          ctx.getWhen(),
           change.currentPatchSetId());
       message.setMessage(msg.toString());
       return message;
@@ -155,31 +169,36 @@
     @Override
     public void postUpdate(Context ctx) throws OrmException {
       try {
-        ReplyToChangeSender cm = abandonedSenderFactory.create(change.getId());
+        ReplyToChangeSender cm =
+            abandonedSenderFactory.create(ctx.getProject(), change.getId());
         if (account != null) {
           cm.setFrom(account.getId());
         }
-        cm.setChangeMessage(message);
+        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+        cm.setNotify(notifyHandling);
         cm.send();
       } catch (Exception e) {
         log.error("Cannot email update for change " + change.getId(), e);
       }
-      hooks.doChangeAbandonedHook(change,
-          account,
-          patchSet,
-          Strings.emptyToNull(msgTxt),
-          ctx.getDb());
+      changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(),
+          notifyHandling);
     }
   }
 
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
+    boolean canAbandon = false;
+    try {
+      canAbandon = resource.getControl().canAbandon(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canAbandon status. Assuming false.", e);
+    }
     return new UiAction.Description()
       .setLabel("Abandon")
       .setTitle("Abandon the change")
       .setVisible(resource.getChange().getStatus().isOpen()
           && resource.getChange().getStatus() != Change.Status.DRAFT
-          && resource.getControl().canAbandon());
+          && canAbandon);
   }
 
   private static String status(Change change) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
index b14d4ba..60d9c08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.QueryProcessor;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,25 +37,22 @@
   private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
 
   private final ChangeCleanupConfig cfg;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final QueryProcessor queryProcessor;
+  private final InternalUser.Factory internalUserFactory;
+  private final ChangeQueryProcessor queryProcessor;
   private final ChangeQueryBuilder queryBuilder;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final Abandon abandon;
 
   @Inject
   AbandonUtil(
       ChangeCleanupConfig cfg,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      QueryProcessor queryProcessor,
+      InternalUser.Factory internalUserFactory,
+      ChangeQueryProcessor queryProcessor,
       ChangeQueryBuilder queryBuilder,
-      ChangeControl.GenericFactory changeControlFactory,
       Abandon abandon) {
     this.cfg = cfg;
-    this.identifiedUserFactory = identifiedUserFactory;
+    this.internalUserFactory = internalUserFactory;
     this.queryProcessor = queryProcessor;
     this.queryBuilder = queryBuilder;
-    this.changeControlFactory = changeControlFactory;
     this.abandon = abandon;
   }
 
@@ -74,7 +69,7 @@
         query += " -is:mergeable";
       }
       List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-          .queryChanges(queryBuilder.parse(query)).changes();
+          .query(queryBuilder.parse(query)).entities();
       int count = 0;
       for (ChangeData cd : changesToAbandon) {
         try {
@@ -83,7 +78,7 @@
                 + " more, hence skipping it in clean up", cd, query);
             continue;
           }
-          abandon.abandon(changeControl(cd), cfg.getAbandonMessage(), null);
+          abandon.abandon(changeControl(cd), cfg.getAbandonMessage());
           count++;
         } catch (ResourceConflictException e) {
           // Change was already merged or abandoned.
@@ -104,14 +99,11 @@
       throws OrmException, QueryParseException {
     String newQuery = query + " change:" + cd.getId();
     List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-        .queryChanges(queryBuilder.parse(newQuery)).changes();
+        .query(queryBuilder.parse(newQuery)).entities();
     return changesToAbandon.isEmpty();
   }
 
-  private ChangeControl changeControl(ChangeData cd)
-      throws NoSuchChangeException, OrmException {
-    Change c = cd.change();
-    return changeControlFactory.controlFor(c,
-        identifiedUserFactory.create(c.getOwner()));
+  private ChangeControl changeControl(ChangeData cd) throws OrmException {
+    return cd.changeControl(internalUserFactory.create());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
new file mode 100644
index 0000000..3bb98ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.Collection;
+
+/**
+ * Store for reviewed flags on changes.
+ *
+ * A reviewed flag is a tuple of (patch set ID, file, account ID) and records
+ * whether the user has reviewed a file in a patch set. Each user can easily
+ * have thousands of reviewed flags and the number of reviewed flags is growing
+ * without bound. The store must be able handle this data volume efficiently.
+ *
+ * For a multi-master setup the store must replicate the data between the
+ * masters.
+ */
+public interface AccountPatchReviewStore {
+
+  /**
+   * Represents patch set id with reviewed files.
+   */
+  @AutoValue
+  abstract class PatchSetWithReviewedFiles {
+    abstract PatchSet.Id patchSetId();
+    abstract ImmutableSet<String> files();
+
+    public static PatchSetWithReviewedFiles create(
+        PatchSet.Id id, ImmutableSet<String> files) {
+      return new AutoValue_AccountPatchReviewStore_PatchSetWithReviewedFiles(
+          id, files);
+    }
+  }
+
+  /**
+   * Marks the given file in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @return {@code true} if the reviewed flag was updated, {@code false} if the
+   *         reviewed flag was already set
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException;
+
+  /**
+   * Marks the given files in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param paths file paths
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  void markReviewed(PatchSet.Id psId, Account.Id accountId,
+      Collection<String> paths) throws OrmException;
+
+  /**
+   * Clears the reviewed flag for the given file in the given patch set for the
+   * given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @throws OrmException thrown if clearing the reviewed flag failed
+   */
+  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException;
+
+  /**
+   * Clears the reviewed flags for all files in the given patch set for all
+   * users.
+   *
+   * @param psId patch set ID
+   * @throws OrmException thrown if clearing the reviewed flags failed
+   */
+  void clearReviewed(PatchSet.Id psId) throws OrmException;
+
+  /**
+   * Find the latest patch set, that is smaller or equals to the given patch set,
+   * where at least, one file has been reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @return optionally, all files the have been reviewed by the given user
+   * that belong to the patch set that is smaller or equals to the given patch set
+   * @throws OrmException thrown if accessing the reviewed flags failed
+   */
+  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId,
+      Account.Id accountId) throws OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index d8574f1..4992c8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -35,13 +35,16 @@
 @Singleton
 public class ActionJson {
   private final Revisions revisions;
+  private final ChangeResource.Factory changeResourceFactory;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
 
   @Inject
   ActionJson(
       Revisions revisions,
+      ChangeResource.Factory changeResourceFactory,
       DynamicMap<RestView<ChangeResource>> changeViews) {
     this.revisions = revisions;
+    this.changeResourceFactory = changeResourceFactory;
     this.changeViews = changeViews;
   }
 
@@ -69,7 +72,7 @@
     Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
     for (UiAction.Description d : UiActions.from(
         changeViews,
-        new ChangeResource(ctl),
+        changeResourceFactory.create(ctl),
         userProvider)) {
       out.put(d.getId(), new ActionInfo(d));
     }
@@ -82,6 +85,7 @@
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
       descr.setTitle("Create follow-up change");
+      descr.setLabel("Follow-Up");
       out.put(descr.getId(), new ActionInfo(descr));
     }
     return out;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
index 14fa7d6..335f201 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -31,7 +31,7 @@
   private final ArchiveCommand.Format<?> format;
   private final String mimeType;
 
-  private ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
+  ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
     this.format = format;
     this.mimeType = mimeType;
     ArchiveCommand.registerFormat(name(), format);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index cb3729b..878cc81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -41,12 +41,14 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.edit.UnchangedCommitMessageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
@@ -56,6 +58,9 @@
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
@@ -102,7 +107,7 @@
   @Override
   public ChangeEditResource parse(ChangeResource rsrc, IdString id)
       throws ResourceNotFoundException, AuthException, IOException,
-      InvalidChangeOperationException {
+      InvalidChangeOperationException, OrmException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
     if (!edit.isPresent()) {
       throw new ResourceNotFoundException(id);
@@ -150,6 +155,7 @@
     private final Provider<ReviewDb> db;
     private final ChangeEditUtil editUtil;
     private final ChangeEditModifier editModifier;
+    private final PatchSetUtil psUtil;
     private final Put putEdit;
     private final Change change;
     private final String path;
@@ -158,12 +164,14 @@
     Create(Provider<ReviewDb> db,
         ChangeEditUtil editUtil,
         ChangeEditModifier editModifier,
+        PatchSetUtil psUtil,
         Put putEdit,
         @Assisted Change change,
         @Assisted @Nullable String path) {
       this.db = db;
       this.editUtil = editUtil;
       this.editModifier = editModifier;
+      this.psUtil = psUtil;
       this.putEdit = putEdit;
       this.change = change;
       this.path = path;
@@ -177,9 +185,9 @@
       if (edit.isPresent()) {
         throw new ResourceConflictException(String.format(
             "edit already exists for the change %s",
-            resource.getChange().getChangeId()));
+            resource.getId()));
       }
-      edit = createEdit();
+      edit = createEdit(resource);
       if (!Strings.isNullOrEmpty(path)) {
         putEdit.apply(new ChangeEditResource(resource, edit.get(), path),
             input);
@@ -187,10 +195,11 @@
       return Response.none();
     }
 
-    private Optional<ChangeEdit> createEdit() throws AuthException,
-        IOException, ResourceConflictException, OrmException {
+    private Optional<ChangeEdit> createEdit(ChangeResource resource)
+        throws AuthException, IOException, ResourceConflictException,
+        OrmException {
       editModifier.createEdit(change,
-          db.get().patchSets().get(change.currentPatchSetId()));
+          psUtil.current(db.get(), resource.getNotes()));
       return editUtil.byChange(change);
     }
   }
@@ -206,16 +215,19 @@
 
     private final ChangeEditUtil editUtil;
     private final ChangeEditModifier editModifier;
+    private final PatchSetUtil psUtil;
     private final Provider<ReviewDb> db;
     private final String path;
 
     @Inject
     DeleteFile(ChangeEditUtil editUtil,
         ChangeEditModifier editModifier,
+        PatchSetUtil psUtil,
         Provider<ReviewDb> db,
         @Assisted String path) {
       this.editUtil = editUtil;
       this.editModifier = editModifier;
+      this.psUtil = psUtil;
       this.db = db;
       this.path = path;
     }
@@ -233,8 +245,8 @@
         // Even if the latest patch set changed since the user triggered
         // the operation, deleting the whole file is probably still what
         // they intended.
-        editModifier.createEdit(rsrc.getChange(), db.get().patchSets().get(
-            rsrc.getChange().currentPatchSetId()));
+        editModifier.createEdit(rsrc.getChange(),
+            psUtil.current(db.get(), rsrc.getNotes()));
         edit = editUtil.byChange(rsrc.getChange());
         editModifier.deleteFile(edit.get(), path);
       }
@@ -321,14 +333,17 @@
     private final Provider<ReviewDb> db;
     private final ChangeEditUtil editUtil;
     private final ChangeEditModifier editModifier;
+    private final PatchSetUtil psUtil;
 
     @Inject
     Post(Provider<ReviewDb> db,
         ChangeEditUtil editUtil,
-        ChangeEditModifier editModifier) {
+        ChangeEditModifier editModifier,
+        PatchSetUtil psUtil) {
       this.db = db;
       this.editUtil = editUtil;
       this.editModifier = editModifier;
+      this.psUtil = psUtil;
     }
 
     @Override
@@ -337,7 +352,7 @@
         ResourceConflictException, OrmException {
       Optional<ChangeEdit> edit = editUtil.byChange(resource.getChange());
       if (!edit.isPresent()) {
-        edit = createEdit(resource.getChange());
+        edit = createEdit(resource);
       }
 
       if (input != null) {
@@ -351,12 +366,12 @@
       return Response.none();
     }
 
-    private Optional<ChangeEdit> createEdit(Change change)
+    private Optional<ChangeEdit> createEdit(ChangeResource resource)
         throws AuthException, IOException, ResourceConflictException,
         OrmException {
-      editModifier.createEdit(change,
-          db.get().patchSets().get(change.currentPatchSetId()));
-      return editUtil.byChange(change);
+      editModifier.createEdit(resource.getChange(),
+          psUtil.current(db.get(), resource.getNotes()));
+      return editUtil.byChange(resource.getChange());
     }
   }
 
@@ -392,7 +407,7 @@
             rsrc.getChangeEdit(),
             rsrc.getPath(),
             input.content);
-      } catch(InvalidChangeOperationException | IOException e) {
+      } catch (InvalidChangeOperationException | IOException e) {
         throw new ResourceConflictException(e.getMessage());
       }
       return Response.none();
@@ -423,17 +438,21 @@
         throws AuthException, ResourceConflictException {
       try {
         editModifier.deleteFile(rsrc.getChangeEdit(), rsrc.getPath());
-      } catch(InvalidChangeOperationException | IOException e) {
+      } catch (InvalidChangeOperationException | IOException e) {
         throw new ResourceConflictException(e.getMessage());
       }
       return Response.none();
     }
   }
 
-  @Singleton
   public static class Get implements RestReadView<ChangeEditResource> {
     private final FileContentUtil fileContentUtil;
 
+    @Option(name = "--base", aliases = {"-b"},
+      usage = "whether to load the content on the base revision instead of the"
+        + " change edit")
+    private boolean base;
+
     @Inject
     Get(FileContentUtil fileContentUtil) {
       this.fileContentUtil = fileContentUtil;
@@ -443,9 +462,13 @@
     public Response<?> apply(ChangeEditResource rsrc)
         throws IOException {
       try {
+        ChangeEdit edit = rsrc.getChangeEdit();
         return Response.ok(fileContentUtil.getContent(
               rsrc.getControl().getProjectControl().getProjectState(),
-              ObjectId.fromString(rsrc.getChangeEdit().getRevision().get()),
+              base
+                  ? ObjectId.fromString(
+                      edit.getBasePatchSet().getRevision().get())
+                  : ObjectId.fromString(edit.getRevision().get()),
               rsrc.getPath()));
       } catch (ResourceNotFoundException rnfe) {
         return Response.none();
@@ -496,14 +519,17 @@
     private final Provider<ReviewDb> db;
     private final ChangeEditModifier editModifier;
     private final ChangeEditUtil editUtil;
+    private final PatchSetUtil psUtil;
 
     @Inject
     EditMessage(Provider<ReviewDb> db,
         ChangeEditModifier editModifier,
-        ChangeEditUtil editUtil) {
+        ChangeEditUtil editUtil,
+        PatchSetUtil psUtil) {
       this.db = db;
       this.editModifier = editModifier;
       this.editUtil = editUtil;
+      this.psUtil = psUtil;
     }
 
     @Override
@@ -513,7 +539,7 @@
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
       if (!edit.isPresent()) {
         editModifier.createEdit(rsrc.getChange(),
-            db.get().patchSets().get(rsrc.getChange().currentPatchSetId()));
+            psUtil.current(db.get(), rsrc.getNotes()));
         edit = editUtil.byChange(rsrc.getChange());
       }
 
@@ -531,21 +557,39 @@
     }
   }
 
-  @Singleton
   public static class GetMessage implements RestReadView<ChangeResource> {
+    private final GitRepositoryManager repoManager;
     private final ChangeEditUtil editUtil;
 
+    @Option(name = "--base", aliases = {"-b"},
+        usage = "whether to load the message on the base revision instead"
+        + " of the change edit")
+    private boolean base;
+
     @Inject
-    GetMessage(ChangeEditUtil editUtil) {
+    GetMessage(GitRepositoryManager repoManager,
+        ChangeEditUtil editUtil) {
+      this.repoManager = repoManager;
       this.editUtil = editUtil;
     }
 
     @Override
     public BinaryResult apply(ChangeResource rsrc) throws AuthException,
-        IOException, ResourceNotFoundException {
+        IOException, ResourceNotFoundException, OrmException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+      String msg;
       if (edit.isPresent()) {
-        String msg = edit.get().getEditCommit().getFullMessage();
+        if (base) {
+          try (Repository repo = repoManager.openRepository(rsrc.getProject());
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit = rw.parseCommit(ObjectId.fromString(
+                edit.get().getBasePatchSet().getRevision().get()));
+            msg = commit.getFullMessage();
+          }
+        } else {
+          msg = edit.get().getEditCommit().getFullMessage();
+        }
+
         return BinaryResult.create(msg)
             .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
             .base64();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 47145d5..0d7a1bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -14,25 +14,30 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
 
-import com.google.gerrit.common.ChangeHooks;
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -46,7 +51,9 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -58,106 +65,150 @@
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.ChangeIdUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 
 public class ChangeInserter extends BatchUpdate.InsertChangeOp {
-  public static interface Factory {
-    ChangeInserter create(RefControl ctl, Change c, RevCommit rc);
+  public interface Factory {
+    ChangeInserter create(Change.Id cid, RevCommit rc, String refName);
   }
 
   private static final Logger log =
       LoggerFactory.getLogger(ChangeInserter.class);
 
+  private final ProjectControl.GenericFactory projectControlFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ChangeHooks hooks;
+  private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CreateChangeSender.Factory createChangeSenderFactory;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
 
-  private final RefControl refControl;
-  private final IdentifiedUser user;
-  private final Change change;
-  private final PatchSet patchSet;
+  private final Change.Id changeId;
+  private final PatchSet.Id psId;
   private final RevCommit commit;
+  private final String refName;
 
   // Fields exposed as setters.
+  private Change.Status status;
+  private String topic;
   private String message;
+  private List<String> groups = Collections.emptyList();
   private CommitValidators.Policy validatePolicy =
       CommitValidators.Policy.GERRIT;
+  private NotifyHandling notify = NotifyHandling.ALL;
   private Set<Account.Id> reviewers;
   private Set<Account.Id> extraCC;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
   private ReceiveCommand updateRefCommand;
-  private boolean runHooks;
+  private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
 
   // Fields set during the insertion process.
+  private Change change;
   private ChangeMessage changeMessage;
   private PatchSetInfo patchSetInfo;
+  private PatchSet patchSet;
+  private String pushCert;
 
   @Inject
-  ChangeInserter(PatchSetInfoFactory patchSetInfoFactory,
-      ChangeHooks hooks,
+  ChangeInserter(ProjectControl.GenericFactory projectControlFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CreateChangeSender.Factory createChangeSenderFactory,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
-      @Assisted RefControl refControl,
-      @Assisted Change change,
-      @Assisted RevCommit commit) {
-    String projectName = refControl.getProjectControl().getProject().getName();
-    String refName = refControl.getRefName();
-    checkArgument(projectName.equals(change.getProject().get())
-          && refName.equals(change.getDest().get()),
-        "RefControl for %s,%s does not match change destination %s",
-        projectName, refName, change.getDest());
-
+      CommentAdded commentAdded,
+      RevisionCreated revisionCreated,
+      @Assisted Change.Id changeId,
+      @Assisted RevCommit commit,
+      @Assisted String refName) {
+    this.projectControlFactory = projectControlFactory;
+    this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
-    this.hooks = hooks;
+    this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.createChangeSenderFactory = createChangeSenderFactory;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
 
-    this.refControl = refControl;
-    this.change = change;
+    this.changeId = changeId;
+    this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
     this.commit = commit;
+    this.refName = refName;
     this.reviewers = Collections.emptySet();
     this.extraCC = Collections.emptySet();
     this.approvals = Collections.emptyMap();
     this.updateRefCommand = null;
-    this.runHooks = true;
+    this.fireRevisionCreated = true;
     this.sendMail = true;
     this.updateRef = true;
-
-    user = refControl.getUser().asIdentifiedUser();
-    patchSet =
-        new PatchSet(new PatchSet.Id(change.getId(), INITIAL_PATCH_SET_ID));
-    patchSet.setCreatedOn(change.getCreatedOn());
-    patchSet.setUploader(change.getOwner());
-    patchSet.setRevision(new RevId(commit.name()));
   }
 
   @Override
-  public Change getChange() {
+  public Change createChange(Context ctx) {
+    change = new Change(
+        getChangeKey(commit),
+        changeId,
+        ctx.getAccountId(),
+        new Branch.NameKey(ctx.getProject(), refName),
+        ctx.getWhen());
+    change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
+    change.setTopic(topic);
     return change;
   }
 
-  public IdentifiedUser getUser() {
-    return user;
+  private static Change.Key getChangeKey(RevCommit commit) {
+    List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+    if (!idList.isEmpty()) {
+      return new Change.Key(idList.get(idList.size() - 1).trim());
+    }
+    ObjectId id = ChangeIdUtil.computeChangeId(commit.getTree(), commit,
+        commit.getAuthorIdent(), commit.getCommitterIdent(),
+        commit.getShortMessage());
+    StringBuilder changeId = new StringBuilder();
+    changeId.append("I").append(ObjectId.toString(id));
+    return new Change.Key(changeId.toString());
+  }
+
+  public PatchSet.Id getPatchSetId() {
+    return psId;
+  }
+
+  public RevCommit getCommit() {
+    return commit;
+  }
+
+  public Change getChange() {
+    checkState(change != null, "getChange() only valid after creating change");
+    return change;
+  }
+
+  public ChangeInserter setTopic(String topic) {
+    checkState(change == null, "setTopic(String) only valid before creating change");
+    this.topic = topic;
+    return this;
   }
 
   public ChangeInserter setMessage(String message) {
@@ -170,6 +221,11 @@
     return this;
   }
 
+  public ChangeInserter setNotify(NotifyHandling notify) {
+    this.notify = notify;
+    return this;
+  }
+
   public ChangeInserter setReviewers(Set<Account.Id> reviewers) {
     this.reviewers = reviewers;
     return this;
@@ -181,18 +237,28 @@
   }
 
   public ChangeInserter setDraft(boolean draft) {
-    change.setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
-    patchSet.setDraft(draft);
+    checkState(change == null,
+        "setDraft(boolean) only valid before creating change");
+    return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
+  }
+
+  public ChangeInserter setStatus(Change.Status status) {
+    checkState(change == null,
+        "setStatus(Change.Status) only valid before creating change");
+    this.status = status;
     return this;
   }
 
-  public ChangeInserter setGroups(Iterable<String> groups) {
-    patchSet.setGroups(groups);
+  public ChangeInserter setGroups(List<String> groups) {
+    checkNotNull(groups, "groups may not be empty");
+    checkState(patchSet == null,
+        "setGroups(Iterable<String>) only valid before creating change");
+    this.groups = groups;
     return this;
   }
 
-  public ChangeInserter setRunHooks(boolean runHooks) {
-    this.runHooks = runHooks;
+  public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
+    this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
@@ -211,10 +277,12 @@
   }
 
   public void setPushCertificate(String cert) {
-    patchSet.setPushCertificate(cert);
+    pushCert = cert;
   }
 
   public PatchSet getPatchSet() {
+    checkState(patchSet != null,
+        "getPatchSet() only valid after creating change");
     return patchSet;
   }
 
@@ -239,58 +307,79 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws InvalidChangeOperationException, IOException {
+      throws ResourceConflictException, IOException {
     validate(ctx);
-    patchSetInfo = patchSetInfoFactory.get(
-        ctx.getRevWalk(), commit, patchSet.getId());
-    change.setCurrentPatchSet(patchSetInfo);
     if (!updateRef) {
       return;
     }
     if (updateRefCommand == null) {
       ctx.addRefUpdate(
-          new ReceiveCommand(ObjectId.zeroId(), commit, patchSet.getRefName()));
+          new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
     } else {
       ctx.addRefUpdate(updateRefCommand);
     }
   }
 
   @Override
-  public void updateChange(ChangeContext ctx) throws OrmException, IOException {
+  public boolean updateChange(ChangeContext ctx) throws OrmException, IOException {
+    change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
-    ChangeControl ctl = ctx.getChangeControl();
-    ChangeUpdate update = ctx.getChangeUpdate();
-    if (patchSet.getGroups() == null) {
-      patchSet.setGroups(GroupCollector.getDefaultGroups(patchSet));
+    ChangeControl ctl = ctx.getControl();
+    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    ctx.getChange().setCurrentPatchSet(patchSetInfo);
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.setChangeId(change.getKey().get());
+    update.setSubjectForCommit("Create change");
+    update.setBranch(change.getDest().get());
+    update.setTopic(change.getTopic());
+
+    boolean draft = status == Change.Status.DRAFT;
+    List<String> newGroups = groups;
+    if (newGroups.isEmpty()) {
+      newGroups = GroupCollector.getDefaultGroups(commit);
     }
-    db.patchSets().insert(Collections.singleton(patchSet));
-    db.changes().insert(Collections.singleton(change));
+    patchSet = psUtil.insert(ctx.getDb(), ctx.getRevWalk(), update, psId,
+        commit, draft, newGroups, pushCert);
+
+    /* TODO: fixStatus is used here because the tests
+     * (byStatusClosed() in AbstractQueryChangesTest)
+     * insert changes that are already merged,
+     * and setStatus may not be used to set the Status to merged
+     *
+     * is it possible to make the tests use the merge code path,
+     * instead of setting the status directly?
+     */
+    update.fixStatus(change.getStatus());
+
     LabelTypes labelTypes = ctl.getProjectControl().getLabelTypes();
     approvalsUtil.addReviewers(db, update, labelTypes, change,
         patchSet, patchSetInfo, reviewers, Collections.<Account.Id> emptySet());
     approvalsUtil.addApprovals(db, update, labelTypes, patchSet,
-        ctx.getChangeControl(), approvals);
+        ctx.getControl(), approvals);
     if (message != null) {
       changeMessage =
           new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db)), user.getAccountId(),
+              ChangeUtil.messageUUID(db)), ctx.getAccountId(),
               patchSet.getCreatedOn(), patchSet.getId());
       changeMessage.setMessage(message);
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
+    return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) throws OrmException, NoSuchChangeException {
     if (sendMail) {
       Runnable sender = new Runnable() {
         @Override
         public void run() {
           try {
-            CreateChangeSender cm =
-                createChangeSenderFactory.create(change.getId());
+            CreateChangeSender cm = createChangeSenderFactory
+                .create(change.getProject(), change.getId());
             cm.setFrom(change.getOwner());
             cm.setPatchSet(patchSet, patchSetInfo);
+            cm.setNotify(notify);
             cm.addReviewers(reviewers);
             cm.addExtraCC(extraCC);
             cm.send();
@@ -311,36 +400,60 @@
       }
     }
 
-    if (runHooks) {
-      ReviewDb db = ctx.getDb();
-      hooks.doPatchsetCreatedHook(change, patchSet, db);
+    /* For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     */
+    if (fireRevisionCreated) {
+      revisionCreated.fire(change, patchSet, ctx.getAccount(),
+          ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
-        hooks.doCommentAddedHook(
-            change, user.getAccount(), patchSet, null, approvals, db);
+        ChangeControl changeControl = changeControlFactory.controlFor(
+            ctx.getDb(), change, ctx.getUser());
+        List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
+        Map<String, Short> allApprovals = new HashMap<>();
+        Map<String, Short> oldApprovals = new HashMap<>();
+        for (LabelType lt : labels) {
+          allApprovals.put(lt.getName(), (short) 0);
+          oldApprovals.put(lt.getName(), null);
+        }
+        for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+          if (entry.getValue() != 0) {
+            allApprovals.put(entry.getKey(), entry.getValue());
+            oldApprovals.put(entry.getKey(), (short) 0);
+          }
+        }
+        commentAdded.fire(change, patchSet,
+            ctx.getAccount(), null,
+            allApprovals, oldApprovals, ctx.getWhen());
       }
     }
   }
 
   private void validate(RepoContext ctx)
-      throws IOException, InvalidChangeOperationException {
+      throws IOException, ResourceConflictException {
     if (validatePolicy == CommitValidators.Policy.NONE) {
       return;
     }
-    CommitValidators cv = commitValidatorsFactory.create(
-        refControl, new NoSshInfo(), ctx.getRepository());
-
-    String refName = patchSet.getId().toRefName();
-    CommitReceivedEvent event = new CommitReceivedEvent(
-        new ReceiveCommand(
-            ObjectId.zeroId(),
-            commit.getId(),
-            refName),
-        refControl.getProjectControl().getProject(),
-        change.getDest().get(),
-        commit,
-        user);
 
     try {
+      RefControl refControl = projectControlFactory
+          .controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
+      CommitValidators cv = commitValidatorsFactory.create(
+          refControl, new NoSshInfo(), ctx.getRepository());
+
+      String refName = psId.toRefName();
+      CommitReceivedEvent event = new CommitReceivedEvent(
+          new ReceiveCommand(
+              ObjectId.zeroId(),
+              commit.getId(),
+              refName),
+          refControl.getProjectControl().getProject(),
+          change.getDest().get(),
+          commit,
+          ctx.getIdentifiedUser());
+
       switch (validatePolicy) {
       case RECEIVE_COMMITS:
         NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(
@@ -354,7 +467,9 @@
         break;
       }
     } catch (CommitValidationException e) {
-      throw new InvalidChangeOperationException(e.getMessage());
+      throw new ResourceConflictException(e.getFullMessage());
+    } catch (NoSuchProjectException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 733d7a2..6d81319 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -31,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
 
@@ -58,6 +59,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -69,6 +71,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
@@ -88,19 +91,25 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
-import com.google.gerrit.server.query.change.QueryResult;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -119,6 +128,9 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -140,7 +152,6 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
-  private final Submit submit;
   private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeData.Factory changeDataFactory;
   private final FileInfoJson fileInfoJson;
@@ -153,8 +164,12 @@
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
   private final GpgApiAdapter gpgApi;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeKindCache changeKindCache;
 
   private AccountLoader accountLoader;
+  private Map<Change.Id, List<SubmitRecord>> submitRecords;
   private FixInput fix;
 
   @AssistedInject
@@ -166,7 +181,6 @@
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
-      Submit submit,
       IdentifiedUser.GenericFactory uf,
       ChangeData.Factory cdf,
       FileInfoJson fileInfoJson,
@@ -178,6 +192,9 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       GpgApiAdapter gpgApi,
+      ChangeNotes.Factory notesFactory,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeKindCache changeKindCache,
       @Assisted Set<ListChangesOption> options) {
     this.db = db;
     this.labelNormalizer = ln;
@@ -187,7 +204,6 @@
     this.repoManager = repoManager;
     this.userFactory = uf;
     this.projectCache = projectCache;
-    this.submit = submit;
     this.mergeUtilFactory = mergeUtilFactory;
     this.fileInfoJson = fileInfoJson;
     this.accountLoaderFactory = ailf;
@@ -198,6 +214,9 @@
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.gpgApi = gpgApi;
+    this.notesFactory = notesFactory;
+    this.changeResourceFactory = changeResourceFactory;
+    this.changeKindCache = changeKindCache;
     this.options = options.isEmpty()
         ? EnumSet.noneOf(ListChangesOption.class)
         : EnumSet.copyOf(options);
@@ -216,32 +235,18 @@
     return format(changeDataFactory.create(db.get(), change));
   }
 
-  public ChangeInfo format(Change.Id id) throws OrmException {
-    Change c;
+  public ChangeInfo format(Project.NameKey project, Change.Id id)
+      throws OrmException, NoSuchChangeException {
+    ChangeNotes notes;
     try {
-      c = db.get().changes().get(id);
-    } catch (OrmException e) {
+      notes = notesFactory.createChecked(db.get(), project, id);
+    } catch (OrmException | NoSuchChangeException e) {
       if (!has(CHECK)) {
         throw e;
       }
-      return checkOnly(changeDataFactory.create(db.get(), id));
+      return checkOnly(changeDataFactory.create(db.get(), project, id));
     }
-    return format(changeDataFactory.create(db.get(), c));
-  }
-
-  public List<ChangeInfo> format(Collection<Change.Id> ids) throws OrmException {
-    List<ChangeData> changes = new ArrayList<>(ids.size());
-    List<ChangeInfo> ret = new ArrayList<>(ids.size());
-    ReviewDb reviewDb = db.get();
-    for (Change.Id id : ids) {
-      changes.add(changeDataFactory.create(reviewDb, id));
-    }
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    for (ChangeData cd : changes) {
-      ret.add(format(cd, Optional.<PatchSet.Id> absent(), false));
-    }
-    accountLoader.fill();
-    return ret;
+    return format(changeDataFactory.create(db.get(), notes));
   }
 
   public ChangeInfo format(ChangeData cd) throws OrmException {
@@ -257,9 +262,8 @@
         ChangeInfo res = toChangeInfo(cd, limitToPsId);
         accountLoader.fill();
         return res;
-      } else {
-        return toChangeInfo(cd, limitToPsId);
       }
+      return toChangeInfo(cd, limitToPsId);
     } catch (PatchListNotAvailableException | GpgException | OrmException
         | IOException | RuntimeException e) {
       if (!has(CHECK)) {
@@ -275,22 +279,22 @@
     return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
   }
 
-  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult> in)
-      throws OrmException {
+  public List<List<ChangeInfo>> formatQueryResults(
+      List<QueryResult<ChangeData>> in) throws OrmException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(FluentIterable.from(in)
-        .transformAndConcat(new Function<QueryResult, List<ChangeData>>() {
+    ensureLoaded(FluentIterable.from(in).transformAndConcat(
+        new Function<QueryResult<ChangeData>, List<ChangeData>>() {
           @Override
-          public List<ChangeData> apply(QueryResult in) {
-            return in.changes();
+          public List<ChangeData> apply(QueryResult<ChangeData> in) {
+            return in.entities();
           }
         }));
 
     List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
-    Map<Change.Id, ChangeInfo> out = Maps.newHashMap();
-    for (QueryResult r : in) {
-      List<ChangeInfo> infos = toChangeInfo(out, r.changes());
-      if (!infos.isEmpty() && r.moreChanges()) {
+    Map<Change.Id, ChangeInfo> out = new HashMap<>();
+    for (QueryResult<ChangeData> r : in) {
+      List<ChangeInfo> infos = toChangeInfo(out, r.entities());
+      if (!infos.isEmpty() && r.more()) {
         infos.get(infos.size() - 1)._moreChanges = true;
       }
       res.add(infos);
@@ -354,7 +358,21 @@
   }
 
   private ChangeInfo checkOnly(ChangeData cd) {
-    ConsistencyChecker.Result result = checkerProvider.get().check(cd, fix);
+    ChangeControl ctl;
+    try {
+      ctl = cd.changeControl().forUser(userProvider.get());
+    } catch (OrmException e) {
+      String msg = "Error loading change";
+      log.warn(msg + " " + cd.getId(), e);
+      ChangeInfo info = new ChangeInfo();
+      info._number = cd.getId().get();
+      ProblemInfo p = new ProblemInfo();
+      p.message = msg;
+      info.problems = Lists.newArrayList(p);
+      return info;
+    }
+
+    ConsistencyChecker.Result result = checkerProvider.get().check(ctl, fix);
     ChangeInfo info;
     Change c = result.change();
     if (c != null) {
@@ -383,35 +401,38 @@
       Optional<PatchSet.Id> limitToPsId) throws PatchListNotAvailableException,
       GpgException, OrmException, IOException {
     ChangeInfo out = new ChangeInfo();
+    CurrentUser user = userProvider.get();
+    ChangeControl ctl = cd.changeControl().forUser(user);
 
     if (has(CHECK)) {
-      out.problems = checkerProvider.get().check(cd.change(), fix).problems();
+      out.problems = checkerProvider.get().check(ctl, fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
       for (ProblemInfo p : out.problems) {
         if (p.status == ProblemInfo.Status.FIXED) {
-          cd = changeDataFactory.create(cd.db(), cd.getId());
+          cd = changeDataFactory.create(cd.db(), cd.project(), cd.getId());
           break;
         }
       }
     }
 
     Change in = cd.change();
-    CurrentUser user = userProvider.get();
-    ChangeControl ctl = cd.changeControl().forUser(user);
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
     out.topic = in.getTopic();
-    out.hashtags = ctl.getNotes().load().getHashtags();
+    out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
-    // TODO(dborowitz): This gets the submit type, so we could include that in
-    // the response and avoid making a request to /submit_type from the UI.
-    out.mergeable = in.getStatus() == Change.Status.MERGED
-        ? null : cd.isMergeable();
-    out.submittable = submit.submittable(cd);
-    ChangedLines changedLines = cd.changedLines();
-    if (changedLines != null) {
-      out.insertions = changedLines.insertions;
-      out.deletions = changedLines.deletions;
+    if (in.getStatus() != Change.Status.MERGED) {
+      SubmitTypeRecord str = cd.submitTypeRecord();
+      if (str.isOk()) {
+        out.submitType = str.type;
+      }
+      out.mergeable = cd.isMergeable();
+    }
+    out.submittable = Submit.submittable(cd);
+    Optional<ChangedLines> changedLines = cd.changedLines();
+    if (changedLines.isPresent()) {
+      out.insertions = changedLines.get().insertions;
+      out.deletions = changedLines.get().deletions;
     }
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
@@ -419,15 +440,24 @@
     out.created = in.getCreatedOn();
     out.updated = in.getLastUpdatedOn();
     out._number = in.getId().get();
-    out.starred = user.getStarredChanges().contains(in.getId())
-        ? true
-        : null;
+
+    if (user.isIdentifiedUser()) {
+      Collection<String> stars = cd.stars().get(user.getAccountId());
+      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL)
+          ? true
+          : null;
+      if (!stars.isEmpty()) {
+        out.stars = stars;
+      }
+    }
+
     if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
       Account.Id accountId = user.getAccountId();
       out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
     }
 
     out.labels = labelsFor(ctl, cd, has(LABELS), has(DETAILED_LABELS));
+    out.submitted = getSubmittedOn(cd);
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
@@ -437,6 +467,17 @@
         out.permittedLabels = permittedLabels(ctl, cd);
       }
       out.removableReviewers = removableReviewers(ctl, out.labels.values());
+
+      out.reviewers = new HashMap<>();
+      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e
+          : cd.reviewers().asTable().rowMap().entrySet()) {
+        out.reviewers.put(e.getKey().asReviewerState(),
+            toAccountInfo(e.getValue().keySet()));
+      }
+    }
+
+    if (has(REVIEWER_UPDATES)) {
+      out.reviewerUpdates = reviewerUpdates(cd);
     }
 
     boolean needMessages = has(MESSAGES);
@@ -455,7 +496,7 @@
     finish(out);
 
     if (needRevisions) {
-      out.revisions = revisions(ctl, src);
+      out.revisions = revisions(ctl, cd, src);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -473,15 +514,38 @@
     return out;
   }
 
-  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
-    if (cd.getSubmitRecords() != null) {
-      return cd.getSubmitRecords();
+  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd)
+      throws OrmException {
+    List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
+    List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
+    for (ReviewerStatusUpdate c : reviewerUpdates) {
+      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
+      change.updated = c.date();
+      change.state = c.state().asReviewerState();
+      change.updatedBy = accountLoader.get(c.updatedBy());
+      change.reviewer = accountLoader.get(c.reviewer());
+      result.add(change);
     }
-    cd.setSubmitRecords(new SubmitRuleEvaluator(cd)
+    return result;
+  }
+
+  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
+    // Maintain our own cache rather than using cd.getSubmitRecords(),
+    // since the latter may not have used the same values for
+    // fastEvalLabels/allowDraft/etc.
+    // TODO(dborowitz): Handle this better at the ChangeData level.
+    if (submitRecords == null) {
+      submitRecords = new HashMap<>();
+    }
+    List<SubmitRecord> records = submitRecords.get(cd.getId());
+    if (records == null) {
+      records = new SubmitRuleEvaluator(cd)
         .setFastEvalLabels(true)
         .setAllowDraft(true)
-        .evaluate());
-    return cd.getSubmitRecords();
+        .evaluate();
+      submitRecords.put(cd.getId(), records);
+    }
+    return records;
   }
 
   private Map<String, LabelInfo> labelsFor(ChangeControl ctl,
@@ -551,6 +615,9 @@
                 n.rejected = accountLoader.get(r.appliedBy);
                 n.blocking = true;
                 break;
+              case IMPOSSIBLE:
+              case MAY:
+              case NEED:
               default:
                 break;
             }
@@ -595,8 +662,8 @@
     // Include a user in the output for this label if either:
     //  - They are an explicit reviewer.
     //  - They ever voted on this change.
-    Set<Account.Id> allUsers = Sets.newHashSet();
-    allUsers.addAll(cd.reviewers().values());
+    Set<Account.Id> allUsers = new HashSet<>();
+    allUsers.addAll(cd.reviewers().all());
     for (PatchSetApproval psa : cd.approvals().values()) {
       allUsers.add(psa.getAccountId());
     }
@@ -618,6 +685,7 @@
           continue;
         }
         Integer value;
+        String tag = null;
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
         if (psa != null) {
@@ -628,6 +696,7 @@
             // label.
             value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
           }
+          tag = psa.getTag();
           date = psa.getGranted();
         } else {
           // Either the user cannot vote on this label, or they were added as a
@@ -635,15 +704,22 @@
           // user can vote on this label.
           value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
         }
-        addApproval(e.getValue().label(), approvalInfo(accountId, value, date));
+        addApproval(e.getValue().label(),
+            approvalInfo(accountId, value, tag, date));
       }
     }
   }
 
+  private Timestamp getSubmittedOn(ChangeData cd)
+      throws OrmException {
+    Optional<PatchSetApproval> s = cd.getSubmitApproval();
+    return s.isPresent() ? s.get().getGranted() : null;
+  }
+
   private Map<String, LabelWithStatus> labelsForClosedChange(ChangeData cd,
       LabelTypes labelTypes, boolean standard, boolean detailed)
       throws OrmException {
-    Set<Account.Id> allUsers = Sets.newHashSet();
+    Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
       // Users expect to see all reviewers on closed changes, even if they
       // didn't vote on the latest patch set. If we don't need detailed labels,
@@ -656,7 +732,7 @@
 
     // We can only approximately reconstruct what the submit rule evaluator
     // would have done. These should really come from a stored submit record.
-    Set<String> labelNames = Sets.newHashSet();
+    Set<String> labelNames = new HashSet<>();
     Multimap<Account.Id, PatchSetApproval> current = HashMultimap.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
       allUsers.add(a.getAccountId());
@@ -687,7 +763,7 @@
 
       if (detailed) {
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null);
+          ApprovalInfo ai = approvalInfo(accountId, 0, null, null);
           byLabel.put(entry.getKey(), ai);
           addApproval(entry.getValue().label(), ai);
         }
@@ -703,6 +779,7 @@
         if (info != null) {
           info.value = Integer.valueOf(val);
           info.date = psa.getGranted();
+          info.tag = psa.getTag();
         }
         if (!standard) {
           continue;
@@ -714,11 +791,19 @@
     return labels;
   }
 
-  private ApprovalInfo approvalInfo(Account.Id id, Integer value, Timestamp date) {
+  private ApprovalInfo approvalInfo(Account.Id id, Integer value, String tag,
+      Timestamp date) {
+    ApprovalInfo ai = getApprovalInfo(id, value, tag, date);
+    accountLoader.put(ai);
+    return ai;
+  }
+
+  public static ApprovalInfo getApprovalInfo(
+      Account.Id id, Integer value, String tag, Timestamp date) {
     ApprovalInfo ai = new ApprovalInfo(id.get());
     ai.value = value;
     ai.date = date;
-    accountLoader.put(ai);
+    ai.tag = tag;
     return ai;
   }
 
@@ -728,7 +813,7 @@
 
   private void setLabelValues(LabelType type, LabelWithStatus l) {
     l.label().defaultValue = type.getDefaultValue();
-    l.label().values = Maps.newLinkedHashMap();
+    l.label().values = new LinkedHashMap<>();
     for (LabelValue v : type.getValues()) {
       l.label().values.put(v.formatValue(), v.getText());
     }
@@ -795,6 +880,7 @@
         cmi.author = accountLoader.get(message.getAuthor());
         cmi.date = message.getWrittenOn();
         cmi.message = message.getMessage();
+        cmi.tag = message.getTag();
         cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
         result.add(cmi);
       }
@@ -828,18 +914,33 @@
     return result;
   }
 
-  private Map<String, RevisionInfo> revisions(ChangeControl ctl,
+  private Collection<AccountInfo> toAccountInfo(
+      Collection<Account.Id> accounts) {
+    return FluentIterable.from(accounts)
+        .transform(new Function<Account.Id, AccountInfo>() {
+          @Override
+          public AccountInfo apply(Account.Id id) {
+            return accountLoader.get(id);
+          }
+        })
+        .toSortedList(AccountInfoComparator.ORDER_NULLS_FIRST);
+  }
+
+  private Map<String, RevisionInfo> revisions(ChangeControl ctl, ChangeData cd,
       Map<PatchSet.Id, PatchSet> map) throws PatchListNotAvailableException,
       GpgException, OrmException, IOException {
-    Map<String, RevisionInfo> res = Maps.newLinkedHashMap();
-    for (PatchSet in : map.values()) {
-      if ((has(ALL_REVISIONS)
-          || in.getId().equals(ctl.getChange().currentPatchSetId()))
-          && ctl.isPatchVisible(in, db.get())) {
-        res.put(in.getRevision().get(), toRevisionInfo(ctl, in));
+    Map<String, RevisionInfo> res = new LinkedHashMap<>();
+    try (Repository repo =
+        repoManager.openRepository(ctl.getProject().getNameKey())) {
+      for (PatchSet in : map.values()) {
+        if ((has(ALL_REVISIONS)
+            || in.getId().equals(ctl.getChange().currentPatchSetId()))
+            && ctl.isPatchVisible(in, db.get())) {
+          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false));
+        }
       }
+      return res;
     }
-    return res;
   }
 
   private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd,
@@ -870,7 +971,21 @@
     return map;
   }
 
-  private RevisionInfo toRevisionInfo(ChangeControl ctl, PatchSet in)
+  public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
+      throws PatchListNotAvailableException, GpgException, OrmException,
+      IOException {
+    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    try (Repository repo =
+        repoManager.openRepository(ctl.getProject().getNameKey())) {
+      RevisionInfo rev = toRevisionInfo(
+          ctl, changeDataFactory.create(db.get(), ctl), in, repo, true);
+      accountLoader.fill();
+      return rev;
+    }
+  }
+
+  private RevisionInfo toRevisionInfo(ChangeControl ctl, ChangeData cd,
+      PatchSet in, Repository repo, boolean fillCommit)
       throws PatchListNotAvailableException, GpgException, OrmException,
       IOException {
     Change c = ctl.getChange();
@@ -882,19 +997,19 @@
     out.uploader = accountLoader.get(in.getUploader());
     out.draft = in.isDraft() ? true : null;
     out.fetch = makeFetchMap(ctl, in);
+    out.kind = changeKindCache.getChangeKind(repo, cd, in);
 
     boolean setCommit = has(ALL_COMMITS)
         || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
     if (setCommit || addFooters) {
       Project.NameKey project = c.getProject();
-      try (Repository repo = repoManager.openRepository(project);
-          RevWalk rw = new RevWalk(repo)) {
+      try (RevWalk rw = new RevWalk(repo)) {
         String rev = in.getRevision().get();
         RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
         rw.parseBody(commit);
         if (setCommit) {
-          out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS));
+          out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
         }
         if (addFooters) {
           out.commitWithFooters = mergeUtilFactory
@@ -914,14 +1029,14 @@
         && userProvider.get().isIdentifiedUser()) {
 
       actionJson.addRevisionActions(out,
-          new RevisionResource(new ChangeResource(ctl), in));
+          new RevisionResource(changeResourceFactory.create(ctl), in));
     }
 
-    if (has(PUSH_CERTIFICATES)) {
+    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
       if (in.getPushCertificate() != null) {
         out.pushCertificate = gpgApi.checkPushCertificate(
             in.getPushCertificate(),
-            userFactory.create(db, in.getUploader()));
+            userFactory.create(in.getUploader()));
       } else {
         out.pushCertificate = new PushCertificateInfo();
       }
@@ -931,9 +1046,12 @@
   }
 
   CommitInfo toCommit(ChangeControl ctl, RevWalk rw, RevCommit commit,
-      boolean addLinks) throws IOException {
-    Project.NameKey project = ctl.getChange().getProject();
+      boolean addLinks, boolean fillCommit) throws IOException {
+    Project.NameKey project = ctl.getProject().getNameKey();
     CommitInfo info = new CommitInfo();
+    if (fillCommit) {
+      info.commit = commit.name();
+    }
     info.parents = new ArrayList<>(commit.getParentCount());
     info.author = toGitPerson(commit.getAuthorIdent());
     info.committer = toGitPerson(commit.getCommitterIdent());
@@ -953,7 +1071,7 @@
       i.subject = parent.getShortMessage();
       if (addLinks) {
         FluentIterable<WebLinkInfo> parentLinks =
-            webLinks.getPatchSetLinks(project, parent.name());
+            webLinks.getParentLinks(project, parent.name());
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks.toList();
       }
       info.parents.add(i);
@@ -963,7 +1081,7 @@
 
   private Map<String, FetchInfo> makeFetchMap(ChangeControl ctl, PatchSet in)
       throws OrmException {
-    Map<String, FetchInfo> r = Maps.newLinkedHashMap();
+    Map<String, FetchInfo> r = new LinkedHashMap<>();
 
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
@@ -1009,7 +1127,7 @@
   private static void addCommand(FetchInfo fetchInfo, String commandName,
       String c) {
     if (fetchInfo.commands == null) {
-      fetchInfo.commands = Maps.newTreeMap();
+      fetchInfo.commands = new TreeMap<>();
     }
     fetchInfo.commands.put(commandName, c);
   }
@@ -1023,7 +1141,7 @@
 
   private static void addApproval(LabelInfo label, ApprovalInfo approval) {
     if (label.all == null) {
-      label.all = Lists.newArrayList();
+      label.all = new ArrayList<>();
     }
     label.all.add(approval);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKind.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKind.java
deleted file mode 100644
index 6e6f6fa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKind.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-/** Operation performed by a change relative to its parent. */
-public enum ChangeKind {
-  /** Nontrivial content changes. */
-  REWORK,
-
-  /** Conflict-free merge between the new parent and the prior patch set. */
-  TRIVIAL_REBASE,
-
-  /** Same tree and same parent tree. */
-  NO_CODE_CHANGE,
-
-  /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index 7b55b63..2302b70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -29,8 +31,10 @@
  * implementation changes, which might invalidate old entries).
  */
 public interface ChangeKindCache {
-  public ChangeKind getChangeKind(ProjectState project, Repository repo,
+  ChangeKind getChangeKind(ProjectState project, Repository repo,
       ObjectId prior, ObjectId next);
 
-  public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
+  ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
+
+  ChangeKind getChangeKind(Repository repo, ChangeData cd, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 389d53a..1d1b27b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -21,6 +21,8 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
+import com.google.common.collect.FluentIterable;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -52,8 +54,10 @@
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 
@@ -109,10 +113,17 @@
     }
 
     @Override
-    public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
+    public ChangeKind getChangeKind(ReviewDb db, Change change,
+        PatchSet patch) {
       return getChangeKindInternal(this, db, change, patch, changeDataFactory,
           projectCache, repoManager);
     }
+
+    @Override
+    public ChangeKind getChangeKind(Repository repo, ChangeData cd,
+        PatchSet patch) {
+      return getChangeKindInternal(this, repo, cd, patch, projectCache);
+    }
   }
 
   public static class Key implements Serializable {
@@ -202,16 +213,17 @@
         if (!next.getFullMessage().equals(prior.getFullMessage())) {
           if (isSameDeltaAndTree(prior, next)) {
             return ChangeKind.NO_CODE_CHANGE;
-          } else {
-            return ChangeKind.REWORK;
           }
+          return ChangeKind.REWORK;
         }
 
         if (isSameDeltaAndTree(prior, next)) {
           return ChangeKind.NO_CHANGE;
         }
 
-        if (prior.getParentCount() != 1 || next.getParentCount() != 1) {
+        if ((prior.getParentCount() != 1 || next.getParentCount() != 1)
+            && (!onlyFirstParentChanged(prior, next)
+                || prior.getParentCount() == 0)) {
           // Trivial rebases done by machine only work well on 1 parent.
           return ChangeKind.REWORK;
         }
@@ -225,7 +237,10 @@
           merger.setBase(prior.getParent(0));
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
-            return ChangeKind.TRIVIAL_REBASE;
+            if (prior.getParentCount() == 1) {
+              return ChangeKind.TRIVIAL_REBASE;
+            }
+            return ChangeKind.MERGE_FIRST_PARENT_UPDATE;
           }
         } catch (LargeObjectException e) {
           // Some object is too large for the merge attempt to succeed. Assume
@@ -235,6 +250,27 @@
       }
     }
 
+    public static boolean onlyFirstParentChanged(RevCommit prior, RevCommit next) {
+      return !sameFirstParents(prior, next) && sameRestOfParents(prior, next);
+    }
+
+    private static boolean sameFirstParents(RevCommit prior, RevCommit next) {
+      if (prior.getParentCount() == 0) {
+        return next.getParentCount() == 0;
+      }
+      return prior.getParent(0).equals(next.getParent(0));
+    }
+
+    private static boolean sameRestOfParents(RevCommit prior, RevCommit next) {
+      Set<RevCommit> priorRestParents = allExceptFirstParent(prior.getParents());
+      Set<RevCommit> nextRestParents = allExceptFirstParent(next.getParents());
+      return priorRestParents.equals(nextRestParents);
+    }
+
+    private static Set<RevCommit> allExceptFirstParent(RevCommit[] parents) {
+      return FluentIterable.from(Arrays.asList(parents)).skip(1).toSet();
+    }
+
     private static boolean isSameDeltaAndTree(RevCommit prior, RevCommit next) {
       if (next.getTree() != prior.getTree()) {
         return false;
@@ -260,8 +296,8 @@
   public static class ChangeKindWeigher implements Weigher<Key, ChangeKind> {
     @Override
     public int weigh(Key key, ChangeKind changeKind) {
-      return 16 + 2*36 + 2*key.strategyName.length() // Size of Key, 64 bit JVM
-          + 2*changeKind.name().length(); // Size of ChangeKind, 64 bit JVM
+      return 16 + 2 * 36 + 2 * key.strategyName.length() // Size of Key, 64 bit JVM
+          + 2 * changeKind.name().length(); // Size of ChangeKind, 64 bit JVM
     }
   }
 
@@ -304,23 +340,25 @@
         projectCache, repoManager);
   }
 
+  @Override
+  public ChangeKind getChangeKind(Repository repo, ChangeData cd,
+      PatchSet patch) {
+    return getChangeKindInternal(this, repo, cd, patch, projectCache);
+  }
+
   private static ChangeKind getChangeKindInternal(
       ChangeKindCache cache,
-      ReviewDb db,
-      Change change,
+      Repository repo,
+      ChangeData change,
       PatchSet patch,
-      ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
-      GitRepositoryManager repoManager) {
-    // TODO - dborowitz: add NEW_CHANGE type for default.
+      ProjectCache projectCache) {
     ChangeKind kind = ChangeKind.REWORK;
-    // Trivial case: if we're on the first patch, we don't need to open
+    // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
     if (patch.getId().get() > 1) {
-      try (Repository repo = repoManager.openRepository(change.getProject())) {
-        ProjectState projectState = projectCache.checkedGet(change.getProject());
-        ChangeData cd = changeDataFactory.create(db, change);
-        Collection<PatchSet> patchSetCollection = cd.patchSets();
+      try {
+        ProjectState projectState = projectCache.checkedGet(change.project());
+        Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
           if (ps.getId().get() < patch.getId().get() &&
@@ -344,6 +382,32 @@
       } catch (IOException | OrmException e) {
         // Do nothing; assume we have a complex change
         log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
+            "of change " + change.getId(), e);
+      }
+    }
+    return kind;
+  }
+
+  private static ChangeKind getChangeKindInternal(
+      ChangeKindCache cache,
+      ReviewDb db,
+      Change change,
+      PatchSet patch,
+      ChangeData.Factory changeDataFactory,
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager) {
+    // TODO - dborowitz: add NEW_CHANGE type for default.
+    ChangeKind kind = ChangeKind.REWORK;
+    // Trivial case: if we're on the first patch, we don't need to open
+    // the repository.
+    if (patch.getId().get() > 1) {
+      try (Repository repo = repoManager.openRepository(change.getProject())) {
+        kind = getChangeKindInternal(cache, repo,
+            changeDataFactory.create(db, change), patch,
+            projectCache);
+      } catch (IOException e) {
+        // Do nothing; assume we have a complex change
+        log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
             "of change " + change.getChangeId(), e);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index 03d189f..05d12b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
@@ -22,37 +24,66 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.TypeLiteral;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
+  /**
+   * JSON format version number for ETag computations.
+   * <p>
+   * Should be bumped on any JSON format change (new fields, etc.) so that
+   * otherwise unmodified changes get new ETags.
+   */
+  public static final int JSON_FORMAT_VERSION = 1;
+
   public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
       new TypeLiteral<RestView<ChangeResource>>() {};
 
-  private final ChangeControl control;
-
-  public ChangeResource(ChangeControl control) {
-    this.control = control;
+  public interface Factory {
+    ChangeResource create(ChangeControl ctl);
   }
 
-  protected ChangeResource(ChangeResource copy) {
-    this.control = copy.control;
+  private final StarredChangesUtil starredChangesUtil;
+  private final ChangeControl control;
+
+  @AssistedInject
+  ChangeResource(StarredChangesUtil starredChangesUtil,
+      @Assisted ChangeControl control) {
+    this.starredChangesUtil = starredChangesUtil;
+    this.control = control;
   }
 
   public ChangeControl getControl() {
     return control;
   }
 
+  public IdentifiedUser getUser() {
+    return getControl().getUser().asIdentifiedUser();
+  }
+
+  public Change.Id getId() {
+    return getControl().getId();
+  }
+
   public Change getChange() {
     return getControl().getChange();
   }
 
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
   public ChangeNotes getNotes() {
     return getControl().getNotes();
   }
@@ -60,7 +91,8 @@
   // This includes all information relevant for ETag computation
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
-    h.putLong(getChange().getLastUpdatedOn().getTime())
+    h.putInt(JSON_FORMAT_VERSION)
+      .putLong(getChange().getLastUpdatedOn().getTime())
       .putInt(getChange().getRowVersion())
       .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
@@ -78,7 +110,7 @@
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
     hashObjectId(h, noteId, buf);
-    // TODO(dborowitz): Include more notedb and other related refs, e.g. drafts
+    // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
     // and edits.
 
     for (ProjectState p : control.getProjectControl().getProjectState().tree()) {
@@ -89,8 +121,12 @@
   @Override
   public String getETag() {
     CurrentUser user = control.getUser();
-    Hasher h = Hashing.md5().newHasher()
-        .putBoolean(user.getStarredChanges().contains(getChange().getId()));
+    Hasher h = Hashing.md5().newHasher();
+    if (user.isIdentifiedUser()) {
+      h.putString(
+          starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(),
+          UTF_8);
+    }
     prepareETag(h, user);
     return h.hash().toString();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index 1648a5d..b89691a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -24,48 +23,46 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.index.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.QueryChanges;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.io.IOException;
 import java.util.List;
 
 @Singleton
 public class ChangesCollection implements
     RestCollection<TopLevelResource, ChangeResource>,
     AcceptsPost<TopLevelResource> {
+  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final Provider<QueryChanges> queryFactory;
   private final DynamicMap<RestView<ChangeResource>> views;
-  private final ChangeUtil changeUtil;
+  private final ChangeFinder changeFinder;
   private final CreateChange createChange;
-  private final ChangeIndexer changeIndexer;
+  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   ChangesCollection(
+      Provider<ReviewDb> db,
       Provider<CurrentUser> user,
-      ChangeControl.GenericFactory changeControlFactory,
       Provider<QueryChanges> queryFactory,
       DynamicMap<RestView<ChangeResource>> views,
-      ChangeUtil changeUtil,
+      ChangeFinder changeFinder,
       CreateChange createChange,
-      ChangeIndexer changeIndexer) {
+      ChangeResource.Factory changeResourceFactory) {
+    this.db = db;
     this.user = user;
-    this.changeControlFactory = changeControlFactory;
     this.queryFactory = queryFactory;
     this.views = views;
-    this.changeUtil = changeUtil;
+    this.changeFinder = changeFinder;
     this.createChange = createChange;
-    this.changeIndexer = changeIndexer;
+    this.changeResourceFactory = changeResourceFactory;
   }
 
   @Override
@@ -81,38 +78,42 @@
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
       throws ResourceNotFoundException, OrmException {
-    List<Change> changes = changeUtil.findChanges(id.encoded());
-    if (changes.isEmpty()) {
-      Integer changeId = Ints.tryParse(id.get());
-      if (changeId != null) {
-        try {
-          changeIndexer.delete(new Change.Id(changeId));
-        } catch (IOException e) {
-          throw new ResourceNotFoundException(id.get(), e);
-        }
-      }
-    }
-    if (changes.size() != 1) {
+    List<ChangeControl> ctls = changeFinder.find(id.encoded(), user.get());
+    if (ctls.isEmpty()) {
       throw new ResourceNotFoundException(id);
+    } else if (ctls.size() != 1) {
+      throw new ResourceNotFoundException("Multiple changes found for " + id);
     }
 
-    ChangeControl control;
-    try {
-      control = changeControlFactory.validateFor(changes.get(0), user.get());
-    } catch (NoSuchChangeException e) {
+    ChangeControl ctl = ctls.get(0);
+    if (!ctl.isVisible(db.get())) {
       throw new ResourceNotFoundException(id);
     }
-    return new ChangeResource(control);
+    return changeResourceFactory.create(ctl);
   }
 
   public ChangeResource parse(Change.Id id)
       throws ResourceNotFoundException, OrmException {
-    return parse(TopLevelResource.INSTANCE,
-        IdString.fromUrl(Integer.toString(id.get())));
+    List<ChangeControl> ctls = changeFinder.find(id, user.get());
+    if (ctls.isEmpty()) {
+      throw new ResourceNotFoundException(toIdString(id));
+    } else if (ctls.size() != 1) {
+      throw new ResourceNotFoundException("Multiple changes found for " + id);
+    }
+
+    ChangeControl ctl = ctls.get(0);
+    if (!ctl.isVisible(db.get())) {
+      throw new ResourceNotFoundException(toIdString(id));
+    }
+    return changeResourceFactory.create(ctl);
+  }
+
+  private static IdString toIdString(Change.Id id) {
+    return IdString.fromDecoded(id.toString());
   }
 
   public ChangeResource parse(ChangeControl control) {
-    return new ChangeResource(control);
+    return changeResourceFactory.create(control);
   }
 
   @SuppressWarnings("unchecked")
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index d7fe43b..f4869be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.ChangeControl;
@@ -37,13 +38,14 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
+  public Response<ChangeInfo> apply(ChangeResource rsrc)
+      throws RestApiException, OrmException {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws AuthException, OrmException {
+      throws RestApiException, OrmException {
     ChangeControl ctl = rsrc.getControl();
     if (!ctl.isOwner()
         && !ctl.getProjectControl().isOwner()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index 82aaecb..1a063f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -71,8 +73,14 @@
       throw new AuthException("Cherry pick not permitted");
     }
 
+    ProjectControl projectControl = control.getProjectControl();
+    Capable capable = projectControl.canPushToAtLeastOneRef();
+    if (capable != Capable.OK) {
+      throw new AuthException(capable.getMessage());
+    }
+
     String refName = RefNames.fullName(input.destination);
-    RefControl refControl = control.getProjectControl().controlForRef(refName);
+    RefControl refControl = projectControl.controlForRef(refName);
     if (!refControl.canUpload()) {
       throw new AuthException("Not allowed to cherry pick "
           + revision.getChange().getId().toString() + " to "
@@ -84,7 +92,8 @@
           cherryPickChange.cherryPick(revision.getChange(),
               revision.getPatchSet(), input.message, refName,
               refControl);
-      return json.create(ChangeJson.NO_OPTIONS).format(cherryPickedChangeId);
+      return json.create(ChangeJson.NO_OPTIONS).format(revision.getProject(),
+          cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index fc4893c..db18ba2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -30,7 +30,10 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -39,7 +42,6 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -60,10 +62,10 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.TimeZone;
 
@@ -71,6 +73,7 @@
 public class CherryPickChange {
 
   private final Provider<ReviewDb> db;
+  private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
   private final TimeZone serverTimeZone;
@@ -79,11 +82,12 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtil.Factory mergeUtilFactory;
   private final ChangeMessagesUtil changeMessagesUtil;
-  private final ChangeUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
 
   @Inject
   CherryPickChange(Provider<ReviewDb> db,
+      Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
       @GerritPersonIdent PersonIdent myIdent,
       GitRepositoryManager gitManager,
@@ -92,9 +96,10 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
       ChangeMessagesUtil changeMessagesUtil,
-      ChangeUpdate.Factory updateFactory,
+      PatchSetUtil psUtil,
       BatchUpdate.Factory batchUpdateFactory) {
     this.db = db;
+    this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
     this.serverTimeZone = myIdent.getTimeZone();
@@ -103,7 +108,7 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.changeMessagesUtil = changeMessagesUtil;
-    this.updateFactory = updateFactory;
+    this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
   }
 
@@ -124,7 +129,12 @@
     String destinationBranch = RefNames.shortName(ref);
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
-        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(git)) {
+        // This inserter and revwalk *must* be passed to any BatchUpdates
+        // created later on, to ensure the cherry-picked commit is flushed
+        // before patch sets are updated.
+        ObjectInserter oi = git.newObjectInserter();
+        CodeReviewRevWalk revWalk =
+          CodeReviewCommit.newRevWalk(oi.newReader())) {
       Ref destRef = git.getRefDatabase().exactRef(ref);
       if (destRef == null) {
         throw new InvalidChangeOperationException(String.format(
@@ -136,9 +146,9 @@
       CodeReviewCommit commitToCherryPick =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
 
+      Timestamp now = TimeUtil.nowTs();
       PersonIdent committerIdent =
-          identifiedUser.newCommitterIdent(TimeUtil.nowTs(),
-              serverTimeZone);
+          identifiedUser.newCommitterIdent(now, serverTimeZone);
 
       final ObjectId computedChangeId =
           ChangeIdUtil
@@ -148,7 +158,7 @@
           ChangeIdUtil.insertId(message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      try (ObjectInserter oi = git.newObjectInserter()) {
+      try {
         ProjectState projectState = refControl.getProjectControl().getProjectState();
         cherryPickCommit =
             mergeUtilFactory.create(projectState).createCherryPickFromCommit(git, oi, mergeTip,
@@ -173,26 +183,36 @@
           throw new InvalidChangeOperationException("Several changes with key "
               + changeKey + " reside on the same branch. "
               + "Cannot create a new patch set.");
-        } else if (destChanges.size() == 1) {
-          // The change key exists on the destination branch. The cherry pick
-          // will be added as a new patch set.
-          return insertPatchSet(git, revWalk, oi, destChanges.get(0).change(),
-              cherryPickCommit, refControl, identifiedUser);
-        } else {
-          // Change key not found on destination branch. We can create a new
-          // change.
-          String newTopic = null;
-          if (!Strings.isNullOrEmpty(change.getTopic())) {
-            newTopic = change.getTopic() + "-" + newDest.getShortName();
+        }
+        try (BatchUpdate bu = batchUpdateFactory.create(
+            db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+          bu.setRepository(git, revWalk, oi);
+          Change.Id result;
+          if (destChanges.size() == 1) {
+            // The change key exists on the destination branch. The cherry pick
+            // will be added as a new patch set.
+            ChangeControl destCtl = refControl.getProjectControl()
+                .controlFor(destChanges.get(0).notes());
+            result = insertPatchSet(
+                bu, git, destCtl, cherryPickCommit);
+          } else {
+            // Change key not found on destination branch. We can create a new
+            // change.
+            String newTopic = null;
+            if (!Strings.isNullOrEmpty(change.getTopic())) {
+              newTopic = change.getTopic() + "-" + newDest.getShortName();
+            }
+            result =
+                createNewChange(bu, cherryPickCommit,
+                    refControl.getRefName(), newTopic, change.getDest());
+
+            bu.addOp(change.getId(),
+                new AddMessageToSourceChangeOp(
+                    changeMessagesUtil, patch.getId(), destinationBranch,
+                    cherryPickCommit));
           }
-          Change newChange = createNewChange(git, revWalk, oi, changeKey,
-              project, destRef, cherryPickCommit, refControl, identifiedUser,
-              newTopic, change.getDest());
-
-          addMessageToSourceChange(change, patch.getId(), destinationBranch,
-              cherryPickCommit, identifiedUser, refControl);
-
-          return newChange.getId();
+          bu.execute();
+          return result;
         }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
@@ -202,78 +222,72 @@
     }
   }
 
-  private Change.Id insertPatchSet(Repository git, RevWalk revWalk,
-      ObjectInserter oi, Change change, CodeReviewCommit cherryPickCommit,
-      RefControl refControl, IdentifiedUser identifiedUser)
-      throws IOException, OrmException, UpdateException, RestApiException {
+  private Change.Id insertPatchSet(BatchUpdate bu, Repository git,
+      ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
+      throws IOException, OrmException {
+    Change destChange = destCtl.getChange();
     PatchSet.Id psId =
-        ChangeUtil.nextPatchSetId(git, change.currentPatchSetId());
+        ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory
-        .create(refControl, psId, cherryPickCommit);
+        .create(destCtl, psId, cherryPickCommit);
     PatchSet.Id newPatchSetId = inserter.getPatchSetId();
-    PatchSet current = db.get().patchSets().get(change.currentPatchSetId());
+    PatchSet current = psUtil.current(db.get(), destCtl.getNotes());
 
-    try (BatchUpdate bu = batchUpdateFactory.create(
-        db.get(), change.getDest().getParentKey(), identifiedUser,
-        TimeUtil.nowTs())) {
-      bu.setRepository(git, revWalk, oi);
-      bu.addOp(change.getId(), inserter
-          .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
-          .setDraft(current.isDraft())
-          .setUploader(identifiedUser.getAccountId())
-          .setSendMail(false));
-      bu.execute();
-    }
-    return change.getId();
+    bu.addOp(destChange.getId(), inserter
+        .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
+        .setDraft(current.isDraft())
+        .setSendMail(false));
+    return destChange.getId();
   }
 
-  private Change createNewChange(Repository git, RevWalk revWalk,
-      ObjectInserter oi, Change.Key changeKey, Project.NameKey project,
-      Ref destRef, CodeReviewCommit cherryPickCommit, RefControl refControl,
-      IdentifiedUser identifiedUser, String topic, Branch.NameKey sourceBranch)
-      throws RestApiException, UpdateException, OrmException {
-    Change change =
-        new Change(changeKey, new Change.Id(db.get().nextChangeId()),
-            identifiedUser.getAccountId(), new Branch.NameKey(project,
-                destRef.getName()), TimeUtil.nowTs());
-    change.setTopic(topic);
+  private Change.Id createNewChange(BatchUpdate bu,
+      CodeReviewCommit cherryPickCommit, String refName, String topic,
+      Branch.NameKey sourceBranch) throws OrmException {
+    Change.Id changeId = new Change.Id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(
-          refControl, change, cherryPickCommit)
-        .setValidatePolicy(CommitValidators.Policy.GERRIT);
+          changeId, cherryPickCommit, refName)
+        .setValidatePolicy(CommitValidators.Policy.GERRIT)
+        .setTopic(topic);
 
     ins.setMessage(
-        messageForDestinationChange(ins.getPatchSet().getId(), sourceBranch));
-    try (BatchUpdate bu = batchUpdateFactory.create(
-        db.get(), change.getProject(), identifiedUser, TimeUtil.nowTs())) {
-      bu.setRepository(git, revWalk, oi);
-      bu.insertChange(ins);
-      bu.execute();
-    }
-    return ins.getChange();
+        messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+    bu.insertChange(ins);
+    return changeId;
   }
 
-  private void addMessageToSourceChange(Change change, PatchSet.Id patchSetId,
-      String destinationBranch, CodeReviewCommit cherryPickCommit,
-      IdentifiedUser identifiedUser, RefControl refControl)
-          throws OrmException, IOException {
-    ChangeMessage changeMessage = new ChangeMessage(
-        new ChangeMessage.Key(
-            patchSetId.getParentKey(), ChangeUtil.messageUUID(db.get())),
-            identifiedUser.getAccountId(), TimeUtil.nowTs(), patchSetId);
-    StringBuilder sb = new StringBuilder("Patch Set ")
-        .append(patchSetId.get())
-        .append(": Cherry Picked")
-        .append("\n\n")
-        .append("This patchset was cherry picked to branch ")
-        .append(destinationBranch)
-        .append(" as commit ")
-        .append(cherryPickCommit.getId().getName());
-    changeMessage.setMessage(sb.toString());
+  private static class AddMessageToSourceChangeOp extends BatchUpdate.Op {
+    private final ChangeMessagesUtil cmUtil;
+    private final PatchSet.Id psId;
+    private final String destBranch;
+    private final ObjectId cherryPickCommit;
 
-    ChangeControl ctl = refControl.getProjectControl().controlFor(change);
-    ChangeUpdate update = updateFactory.create(ctl, TimeUtil.nowTs());
-    changeMessagesUtil.addChangeMessage(db.get(), update, changeMessage);
-    update.commit();
+    private AddMessageToSourceChangeOp(ChangeMessagesUtil cmUtil,
+        PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
+      this.cmUtil = cmUtil;
+      this.psId = psId;
+      this.destBranch = destBranch;
+      this.cherryPickCommit = cherryPickCommit;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      ChangeMessage changeMessage = new ChangeMessage(
+          new ChangeMessage.Key(
+              ctx.getChange().getId(), ChangeUtil.messageUUID(ctx.getDb())),
+              ctx.getAccountId(), ctx.getWhen(), psId);
+      StringBuilder sb = new StringBuilder("Patch Set ")
+          .append(psId.get())
+          .append(": Cherry Picked")
+          .append("\n\n")
+          .append("This patchset was cherry picked to branch ")
+          .append(destBranch)
+          .append(" as commit ")
+          .append(cherryPickCommit.name());
+      changeMessage.setMessage(sb.toString());
+
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+      return true;
+    }
   }
 
   private String messageForDestinationChange(PatchSet.Id patchSetId,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
index b155b84..d1ce453 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
@@ -126,8 +126,11 @@
     }
     r.id = Url.encode(c.getKey().get());
     r.path = c.getKey().getParentKey().getFileName();
-    if (c.getSide() == 0) {
+    if (c.getSide() <= 0) {
       r.side = Side.PARENT;
+      if (c.getSide() < 0) {
+        r.parent = -c.getSide();
+      }
     }
     if (c.getLine() > 0) {
       r.line = c.getLine();
@@ -136,6 +139,7 @@
     r.message = Strings.emptyToNull(c.getMessage());
     r.updated = c.getWrittenOn();
     r.range = toRange(c.getRange());
+    r.tag = c.getTag();
     if (loader != null) {
       r.author = loader.get(c.getAuthor());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index ddeb5c0..287c3ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
 import static com.google.gerrit.server.ChangeUtil.TO_PS_ID;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
@@ -32,26 +34,29 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -67,6 +72,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -74,9 +80,10 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
+import java.util.Set;
 
 /**
  * Checks changes for various kinds of inconsistency and corruption.
@@ -90,12 +97,10 @@
 
   @AutoValue
   public abstract static class Result {
-    private static Result create(Change.Id id, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(id, null, problems);
-    }
-
-    private static Result create(Change c, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(c.getId(), c, problems);
+    private static Result create(ChangeControl ctl,
+        List<ProblemInfo> problems) {
+      return new AutoValue_ConsistencyChecker_Result(
+          ctl.getId(), ctl.getChange(), problems);
     }
 
     public abstract Change.Id id();
@@ -106,17 +111,20 @@
     public abstract List<ProblemInfo> problems();
   }
 
-  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final GitRepositoryManager repoManager;
-  private final Provider<CurrentUser> user;
-  private final Provider<PersonIdent> serverIdent;
-  private final ProjectControl.GenericFactory projectControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
+  private final Provider<CurrentUser> user;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<ReviewDb> db;
 
   private FixInput fix;
-  private Change change;
+  private ChangeControl ctl;
   private Repository repo;
   private RevWalk rw;
 
@@ -128,57 +136,51 @@
   private List<ProblemInfo> problems;
 
   @Inject
-  ConsistencyChecker(Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      Provider<CurrentUser> user,
+  ConsistencyChecker(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ProjectControl.GenericFactory projectControlFactory,
+      BatchUpdate.Factory updateFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeNotes.Factory notesFactory,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      GitRepositoryManager repoManager,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
-      BatchUpdate.Factory updateFactory) {
+      PatchSetUtil psUtil,
+      Provider<CurrentUser> user,
+      Provider<ReviewDb> db) {
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeControlFactory = changeControlFactory;
     this.db = db;
-    this.repoManager = repoManager;
-    this.user = user;
-    this.serverIdent = serverIdent;
-    this.projectControlFactory = projectControlFactory;
+    this.notesFactory = notesFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
+    this.psUtil = psUtil;
+    this.repoManager = repoManager;
+    this.serverIdent = serverIdent;
     this.updateFactory = updateFactory;
+    this.user = user;
     reset();
   }
 
   private void reset() {
-    change = null;
+    ctl = null;
     repo = null;
     rw = null;
     problems = new ArrayList<>();
   }
 
-  public Result check(ChangeData cd) {
-    return check(cd, null);
+  private Change change() {
+    return ctl.getChange();
   }
 
-  public Result check(ChangeData cd, @Nullable FixInput f) {
-    reset();
+  public Result check(ChangeControl cc, @Nullable FixInput f) {
+    checkNotNull(cc);
     try {
-      return check(cd.change(), f);
-    } catch (OrmException e) {
-      error("Error looking up change", e);
-      return Result.create(cd.getId(), problems);
-    }
-  }
-
-  public Result check(Change c) {
-    return check(c, null);
-  }
-
-  public Result check(Change c, @Nullable FixInput f) {
-    reset();
-    fix = f;
-    change = c;
-    try {
+      reset();
+      ctl = cc;
+      fix = f;
       checkImpl();
-      return Result.create(c, problems);
+      return result();
     } finally {
       if (rw != null) {
         rw.close();
@@ -205,8 +207,8 @@
 
   private void checkOwner() {
     try {
-      if (db.get().accounts().get(change.getOwner()) == null) {
-        problem("Missing change owner: " + change.getOwner());
+      if (db.get().accounts().get(change().getOwner()) == null) {
+        problem("Missing change owner: " + change().getOwner());
       }
     } catch (OrmException e) {
       error("Failed to look up owner", e);
@@ -215,10 +217,10 @@
 
   private void checkCurrentPatchSetEntity() {
     try {
-      PatchSet.Id psId = change.currentPatchSetId();
-      currPs = db.get().patchSets().get(psId);
+      currPs = psUtil.current(db.get(), ctl.getNotes());
       if (currPs == null) {
-        problem(String.format("Current patch set %d not found", psId.get()));
+        problem(String.format("Current patch set %d not found",
+              change().currentPatchSetId().get()));
       }
     } catch (OrmException e) {
       error("Failed to look up current patch set", e);
@@ -226,7 +228,7 @@
   }
 
   private boolean openRepo() {
-    Project.NameKey project = change.getDest().getParentKey();
+    Project.NameKey project = change().getDest().getParentKey();
     try {
       repo = repoManager.openRepository(project);
       rw = new RevWalk(repo);
@@ -241,13 +243,11 @@
   private boolean checkPatchSets() {
     List<PatchSet> all;
     try {
-      all = Lists.newArrayList(db.get().patchSets().byChange(change.getId()));
+      // Iterate in descending order.
+      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), ctl.getNotes()));
     } catch (OrmException e) {
       return error("Failed to look up patch sets", e);
     }
-    // Iterate in descending order so deletePatchSet can assume the latest patch
-    // set exists.
-    Collections.sort(all, PS_ID_ORDER.reverse());
     patchSetsBySha = MultimapBuilder.hashKeys(all.size())
         .treeSetValues(PS_ID_ORDER)
         .build();
@@ -266,6 +266,7 @@
       refs = Collections.emptyMap();
     }
 
+    List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
     for (PatchSet ps : all) {
       // Check revision format.
       int psNum = ps.getId().get();
@@ -296,17 +297,21 @@
           objId, String.format("patch set %d", psNum));
       if (psCommit == null) {
         if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSet(lastProblem(), ps.getId());
+          deletePatchSetOps.add(
+              new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
         fixPatchSetRef(refProblem, ps);
       }
-      if (ps.getId().equals(change.currentPatchSetId())) {
+      if (ps.getId().equals(change().currentPatchSetId())) {
         currPsCommit = psCommit;
       }
     }
 
+    // Delete any bad patch sets found above, in a single update.
+    deletePatchSets(deletePatchSetOps);
+
     // Check for duplicates.
     for (Map.Entry<ObjectId, Collection<PatchSet>> e
         : patchSetsBySha.asMap().entrySet()) {
@@ -321,7 +326,7 @@
   }
 
   private void checkMerged() {
-    String refName = change.getDest().get();
+    String refName = change().getDest().get();
     Ref dest;
     try {
       dest = repo.getRefDatabase().exactRef(refName);
@@ -354,22 +359,27 @@
     }
   }
 
+  private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
+    String refName = change().getDest().get();
+    return problem(String.format(
+        "Patch set %d (%s) is merged into destination ref %s (%s), but change"
+        + " status is %s", psId.get(), commit.name(),
+        refName, tip.name(), change().getStatus()));
+  }
+
   private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit,
       boolean merged) {
-    String refName = change.getDest().get();
-    if (merged && change.getStatus() != Change.Status.MERGED) {
-      ProblemInfo p = problem(String.format(
-          "Patch set %d (%s) is merged into destination ref %s (%s), but change"
-          + " status is %s", psId.get(), commit.name(),
-          refName, tip.name(), change.getStatus()));
+    String refName = change().getDest().get();
+    if (merged && change().getStatus() != Change.Status.MERGED) {
+      ProblemInfo p = wrongChangeStatus(psId, commit);
       if (fix != null) {
         fixMerged(p);
       }
-    } else if (!merged && change.getStatus() == Change.Status.MERGED) {
+    } else if (!merged && change().getStatus() == Change.Status.MERGED) {
       problem(String.format("Patch set %d (%s) is not merged into"
             + " destination ref %s (%s), but change status is %s",
             currPs.getId().get(), commit.name(), refName, tip.name(),
-            change.getStatus()));
+            change().getStatus()));
     }
   }
 
@@ -380,142 +390,204 @@
     if (commit == null) {
       return;
     }
-    if (Objects.equals(commit, currPsCommit)) {
-      // Caller gave us latest patch set SHA-1; verified in checkPatchSets.
-      return;
-    }
 
     try {
       if (!rw.isMergedInto(commit, tip)) {
         problem(String.format("Expected merged commit %s is not merged into"
               + " destination ref %s (%s)",
-              commit.name(), change.getDest().get(), tip.name()));
+              commit.name(), change().getDest().get(), tip.name()));
         return;
       }
 
-      RevId revId = new RevId(commit.name());
-      List<PatchSet> patchSets = FluentIterable
-          .from(db.get().patchSets().byRevision(revId))
-          .filter(new Predicate<PatchSet>() {
-            @Override
-            public boolean apply(PatchSet ps) {
-              try {
-                Change c = db.get().changes().get(ps.getId().getParentKey());
-                return c != null && c.getDest().equals(change.getDest());
-              } catch (OrmException e) {
-                warn(e);
-                return true; // Should cause an error below, that's good.
-              }
-            }
-          }).toSortedList(ChangeUtil.PS_ID_ORDER);
-      switch (patchSets.size()) {
+      List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
+      for (Ref ref : repo.getRefDatabase().getRefs(REFS_CHANGES).values()) {
+        if (!ref.getObjectId().equals(commit)) {
+          continue;
+        }
+        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+        if (psId == null) {
+          continue;
+        }
+        try {
+          Change c = notesFactory.createChecked(
+              db.get(), change().getProject(), psId.getParentKey()).getChange();
+          if (!c.getDest().equals(change().getDest())) {
+            continue;
+          }
+        } catch (OrmException | NoSuchChangeException e) {
+          warn(e);
+          // Include this patch set; should cause an error below, which is good.
+        }
+        thisCommitPsIds.add(psId);
+      }
+      switch (thisCommitPsIds.size()) {
         case 0:
           // No patch set for this commit; insert one.
           rw.parseBody(commit);
           String changeId = Iterables.getFirst(
               commit.getFooterLines(FooterConstants.CHANGE_ID), null);
           // Missing Change-Id footer is ok, but mismatched is not.
-          if (changeId != null && !changeId.equals(change.getKey().get())) {
+          if (changeId != null && !changeId.equals(change().getKey().get())) {
             problem(String.format("Expected merged commit %s has Change-Id: %s,"
                   + " but expected %s",
-                  commit.name(), changeId, change.getKey().get()));
+                  commit.name(), changeId, change().getKey().get()));
             return;
           }
-          PatchSet.Id psId = insertPatchSet(commit);
-          if (psId != null) {
-            checkMergedBitMatchesStatus(psId, commit, true);
-          }
+          insertMergedPatchSet(commit, null, false);
           break;
 
         case 1:
-          // Existing patch set of this commit; check that it is the current
-          // patch set.
-          // TODO(dborowitz): This could be fixed if it's an older patch set of
-          // the current change.
-          PatchSet.Id id = patchSets.get(0).getId();
-          if (!id.equals(change.currentPatchSetId())) {
-            problem(String.format("Expected merged commit %s corresponds to"
-                  + " patch set %s, which is not the current patch set %s",
-                  commit.name(), id, change.currentPatchSetId()));
+          // Existing patch set ref pointing to this commit.
+          PatchSet.Id id = thisCommitPsIds.get(0);
+          if (id.equals(change().currentPatchSetId())) {
+            // If it's the current patch set, we can just fix the status.
+            fixMerged(wrongChangeStatus(id, commit));
+          } else if (id.get() > change().currentPatchSetId().get()) {
+            // If it's newer than the current patch set, reuse this patch set
+            // ID when inserting a new merged patch set.
+            insertMergedPatchSet(commit, id, true);
+          } else {
+            // If it's older than the current patch set, just delete the old
+            // ref, and use a new ID when inserting a new merged patch set.
+            insertMergedPatchSet(commit, id, false);
           }
           break;
 
         default:
           problem(String.format(
                 "Multiple patch sets for expected merged commit %s: %s",
-                commit.name(), patchSets));
+                commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
           break;
       }
-    } catch (OrmException | IOException e) {
+    } catch (IOException e) {
       error("Error looking up expected merged commit " + fix.expectMergedAs,
           e);
     }
   }
 
-  private PatchSet.Id insertPatchSet(RevCommit commit) {
-    ProblemInfo p =
+  private void insertMergedPatchSet(final RevCommit commit,
+      final @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
+    ProblemInfo notFound =
         problem("No patch set found for merged commit " + commit.name());
     if (!user.get().isIdentifiedUser()) {
-      p.status = Status.FIX_FAILED;
-      p.outcome =
+      notFound.status = Status.FIX_FAILED;
+      notFound.outcome =
           "Must be called by an identified user to insert new patch set";
-      return null;
+      return;
+    }
+    ProblemInfo insertPatchSetProblem;
+    ProblemInfo deleteOldPatchSetProblem;
+
+    if (psIdToDelete == null) {
+      insertPatchSetProblem = problem(String.format(
+          "Expected merged commit %s has no associated patch set",
+          commit.name()));
+      deleteOldPatchSetProblem = null;
+    } else {
+      String msg = String.format(
+          "Expected merge commit %s corresponds to patch set %s,"
+              + " not the current patch set %s",
+          commit.name(), psIdToDelete.get(),
+          change().currentPatchSetId().get());
+      // Maybe an identical problem, but different fix.
+      deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
+      insertPatchSetProblem = problem(msg);
     }
 
+    List<ProblemInfo> currProblems = new ArrayList<>(3);
+    currProblems.add(notFound);
+    if (deleteOldPatchSetProblem != null) {
+      currProblems.add(insertPatchSetProblem);
+    }
+    currProblems.add(insertPatchSetProblem);
+
     try {
-      RefControl ctl = projectControlFactory
-          .controlFor(change.getProject(), user.get())
-          .controlForRef(change.getDest());
-      PatchSet.Id psId =
-          ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
+      PatchSet.Id psId = (psIdToDelete != null && reuseOldPsId)
+          ? psIdToDelete
+          : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory.create(ctl, psId, commit);
-      try (BatchUpdate bu = updateFactory.create(
-            db.get(), change.getProject(), ctl.getUser(), TimeUtil.nowTs());
+      try (BatchUpdate bu = newBatchUpdate();
           ObjectInserter oi = repo.newObjectInserter()) {
         bu.setRepository(repo, rw, oi);
-        bu.addOp(change.getId(), inserter
+
+        if (psIdToDelete != null) {
+          // Delete the given patch set ref. If reuseOldPsId is true,
+          // PatchSetInserter will reinsert the same ref, making it a no-op.
+          bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+            @Override
+            public void updateRepo(RepoContext ctx) throws IOException {
+              ctx.addRefUpdate(new ReceiveCommand(
+                  commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
+            }
+          });
+          if (!reuseOldPsId) {
+            bu.addOp(ctl.getId(), new DeletePatchSetFromDbOp(
+                checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
+          }
+        }
+
+        bu.addOp(ctl.getId(), inserter
             .setValidatePolicy(CommitValidators.Policy.NONE)
-            .setRunHooks(false)
+            .setFireRevisionCreated(false)
             .setSendMail(false)
             .setAllowClosed(true)
-            .setUploader(user.get().getAccountId())
             .setMessage(
                 "Patch set for merged commit inserted by consistency checker"));
+        bu.addOp(ctl.getId(), new FixMergedOp(notFound));
         bu.execute();
       }
-      change = inserter.getChange();
-      p.status = Status.FIXED;
-      p.outcome = "Inserted as patch set " + psId.get();
-      return psId;
-    } catch (IOException | NoSuchProjectException | UpdateException
-        | RestApiException e) {
+      ctl = changeControlFactory.controlFor(
+          db.get(), inserter.getChange(), ctl.getUser());
+      insertPatchSetProblem.status = Status.FIXED;
+      insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
+    } catch (OrmException | IOException | NoSuchChangeException
+        | UpdateException | RestApiException e) {
       warn(e);
-      p.status = Status.FIX_FAILED;
-      p.outcome = "Error inserting new patch set";
-      return null;
+      for (ProblemInfo pi : currProblems) {
+        pi.status = Status.FIX_FAILED;
+        pi.outcome = "Error inserting merged patch set";
+      }
+      return;
+    }
+  }
+
+  private static class FixMergedOp extends BatchUpdate.Op {
+    private final ProblemInfo p;
+
+    private FixMergedOp(ProblemInfo p) {
+      this.p = p;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      ctx.getChange().setStatus(Change.Status.MERGED);
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+        .fixStatus(Change.Status.MERGED);
+      p.status = Status.FIXED;
+      p.outcome = "Marked change as merged";
+      return true;
     }
   }
 
   private void fixMerged(ProblemInfo p) {
-    try {
-      change = db.get().changes().atomicUpdate(change.getId(),
-          new AtomicUpdate<Change>() {
-            @Override
-            public Change update(Change c) {
-              c.setStatus(Change.Status.MERGED);
-              return c;
-            }
-          });
-      p.status = Status.FIXED;
-      p.outcome = "Marked change as merged";
-    } catch (OrmException e) {
-      log.warn("Error marking " + change.getId() + "as merged", e);
+    try (BatchUpdate bu = newBatchUpdate();
+        ObjectInserter oi = repo.newObjectInserter()) {
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(ctl.getId(), new FixMergedOp(p));
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      log.warn("Error marking " + ctl.getId() + "as merged", e);
       p.status = Status.FIX_FAILED;
       p.outcome = "Error updating status to merged";
     }
   }
 
+  private BatchUpdate newBatchUpdate() {
+    return updateFactory.create(
+        db.get(), change().getProject(), ctl.getUser(), TimeUtil.nowTs());
+  }
+
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
     try {
       RefUpdate ru = repo.updateRef(ps.getId().toRefName());
@@ -532,6 +604,12 @@
           p.status = Status.FIXED;
           p.outcome = "Repaired patch set ref";
           return;
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
         default:
           p.status = Status.FIX_FAILED;
           p.outcome = "Failed to update patch set ref: " + result;
@@ -545,61 +623,108 @@
     }
   }
 
-  private void deletePatchSet(ProblemInfo p, PatchSet.Id psId) {
-    ReviewDb db = this.db.get();
-    Change.Id cid = psId.getParentKey();
-    try {
-      db.changes().beginTransaction(cid);
-      try {
-        Change c = db.changes().get(cid);
-        if (c == null) {
-          throw new OrmException("Change missing: " + cid);
-        }
-
-        if (psId.equals(c.currentPatchSetId())) {
-          List<PatchSet> all = Lists.newArrayList(db.patchSets().byChange(cid));
-          if (all.size() == 1 && all.get(0).getId().equals(psId)) {
-            p.status = Status.FIX_FAILED;
-            p.outcome = "Cannot delete patch set; no patch sets would remain";
-            return;
-          }
-          // If there were multiple missing patch sets, assumes deletePatchSet
-          // has been called in decreasing order, so the max remaining PatchSet
-          // is the effective current patch set.
-          Collections.sort(all, PS_ID_ORDER.reverse());
-          PatchSet.Id latest = null;
-          for (PatchSet ps : all) {
-            latest = ps.getId();
-            if (!ps.getId().equals(psId)) {
-              break;
-            }
-          }
-          c.setCurrentPatchSet(patchSetInfoFactory.get(db, latest));
-          db.changes().update(Collections.singleton(c));
-        }
-
-        // Delete dangling primary key references. Don't delete ChangeMessages,
-        // which don't use patch sets as a primary key, and may provide useful
-        // historical information.
-        db.accountPatchReviews().delete(
-            db.accountPatchReviews().byPatchSet(psId));
-        db.patchSetApprovals().delete(
-            db.patchSetApprovals().byPatchSet(psId));
-        db.patchComments().delete(
-            db.patchComments().byPatchSet(psId));
-        db.patchSets().deleteKeys(Collections.singleton(psId));
-        db.commit();
-
-        p.status = Status.FIXED;
-        p.outcome = "Deleted patch set";
-      } finally {
-        db.rollback();
+  private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
+    try (BatchUpdate bu = newBatchUpdate();
+        ObjectInserter oi = repo.newObjectInserter()) {
+      bu.setRepository(repo, rw, oi);
+      for (DeletePatchSetFromDbOp op : ops) {
+        checkArgument(op.psId.getParentKey().equals(ctl.getId()));
+        bu.addOp(ctl.getId(), op);
       }
-    } catch (PatchSetInfoNotAvailableException | OrmException e) {
+      bu.addOp(ctl.getId(), new UpdateCurrentPatchSetOp(ops));
+      bu.execute();
+    } catch (NoPatchSetsWouldRemainException e) {
+      for (DeletePatchSetFromDbOp op : ops) {
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = e.getMessage();
+      }
+    } catch (UpdateException | RestApiException e) {
       String msg = "Error deleting patch set";
-      log.warn(msg + ' ' + psId, e);
-      p.status = Status.FIX_FAILED;
-      p.outcome = msg;
+      log.warn(msg + " of change " + ops.get(0).psId.getParentKey(), e);
+      for (DeletePatchSetFromDbOp op : ops) {
+        // Overwrite existing statuses that were set before the transaction was
+        // rolled back.
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = msg;
+      }
+    }
+  }
+
+  private class DeletePatchSetFromDbOp extends BatchUpdate.Op {
+    private final ProblemInfo p;
+    private final PatchSet.Id psId;
+
+    private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
+      this.p = p;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException {
+      // Delete dangling key references.
+      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      accountPatchReviewStore.get().clearReviewed(psId);
+      db.changeMessages().delete(
+          db.changeMessages().byChange(psId.getParentKey()));
+      db.patchSetApprovals().delete(
+          db.patchSetApprovals().byPatchSet(psId));
+      db.patchComments().delete(
+          db.patchComments().byPatchSet(psId));
+      db.patchSets().deleteKeys(Collections.singleton(psId));
+
+      // NoteDb requires no additional fiddling; setting the state to deleted is
+      // sufficient to filter everything else out.
+      ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
+
+      p.status = Status.FIXED;
+      p.outcome = "Deleted patch set";
+      return true;
+    }
+  }
+
+  private static class NoPatchSetsWouldRemainException
+      extends RestApiException {
+    private static final long serialVersionUID = 1L;
+
+    private NoPatchSetsWouldRemainException() {
+      super("Cannot delete patch set; no patch sets would remain");
+    }
+  }
+
+  private class UpdateCurrentPatchSetOp extends BatchUpdate.Op {
+    private final Set<PatchSet.Id> toDelete;
+
+    private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
+      toDelete = new HashSet<>();
+      for (DeletePatchSetFromDbOp op : deleteOps) {
+        toDelete.add(op.psId);
+      }
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException,
+        NoPatchSetsWouldRemainException {
+      if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
+        return false;
+      }
+      Set<PatchSet.Id> all = new HashSet<>();
+      // Doesn't make any assumptions about the order in which deletes happen
+      // and whether they are seen by this op; we are already given the full set
+      // of patch sets that will eventually be deleted in this update.
+      for (PatchSet ps : psUtil.byChange(ctx.getDb(), ctx.getNotes())) {
+        if (!toDelete.contains(ps.getId())) {
+          all.add(ps.getId());
+        }
+      }
+      if (all.isEmpty()) {
+        throw new NoPatchSetsWouldRemainException();
+      }
+      PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
+      ctx.getChange().setCurrentPatchSet(
+          patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
+      return true;
     }
   }
 
@@ -607,9 +732,8 @@
     CurrentUser u = user.get();
     if (u.isIdentifiedUser()) {
       return u.asIdentifiedUser().newRefLogIdent();
-    } else {
-      return serverIdent.get();
     }
+    return serverIdent.get();
   }
 
   private ObjectId parseObjectId(String objIdStr, String desc) {
@@ -636,7 +760,7 @@
 
   private ProblemInfo problem(String msg) {
     ProblemInfo p = new ProblemInfo();
-    p.message = msg;
+    p.message = checkNotNull(msg);
     problems.add(p);
     return p;
   }
@@ -653,6 +777,10 @@
   }
 
   private void warn(Throwable t) {
-    log.warn("Error in consistency check of change " + change.getId(), t);
+    log.warn("Error in consistency check of change " + ctl.getId(), t);
+  }
+
+  private Result result() {
+    return Result.create(ctl, problems);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 3768738..cf4be18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -14,37 +14,52 @@
 
 package com.google.gerrit.server.change;
 
+import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gerrit.server.project.RefControl;
@@ -60,6 +75,7 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -67,49 +83,68 @@
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.sql.Timestamp;
+import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
 
 @Singleton
 public class CreateChange implements
-    RestModifyView<TopLevelResource, ChangeInfo> {
+    RestModifyView<TopLevelResource, ChangeInput> {
 
+  private final String anonymousCowardName;
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
+  private final AccountCache accountCache;
+  private final Sequences seq;
   private final TimeZone serverTimeZone;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeJson.Factory jsonFactory;
-  private final ChangeUtil changeUtil;
+  private final ChangeFinder changeFinder;
   private final BatchUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
   private final boolean allowDrafts;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final SubmitType submitType;
 
   @Inject
-  CreateChange(Provider<ReviewDb> db,
+  CreateChange(@AnonymousCowardName String anonymousCowardName,
+      Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
+      AccountCache accountCache,
+      Sequences seq,
       @GerritPersonIdent PersonIdent myIdent,
       Provider<CurrentUser> user,
       ProjectsCollection projectsCollection,
       ChangeInserter.Factory changeInserterFactory,
       ChangeJson.Factory json,
-      ChangeUtil changeUtil,
+      ChangeFinder changeFinder,
       BatchUpdate.Factory updateFactory,
-      @GerritServerConfig Config config) {
+      PatchSetUtil psUtil,
+      @GerritServerConfig Config config,
+      MergeUtil.Factory mergeUtilFactory) {
+    this.anonymousCowardName = anonymousCowardName;
     this.db = db;
     this.gitManager = gitManager;
+    this.accountCache = accountCache;
+    this.seq = seq;
     this.serverTimeZone = myIdent.getTimeZone();
     this.user = user;
     this.projectsCollection = projectsCollection;
     this.changeInserterFactory = changeInserterFactory;
     this.jsonFactory = json;
-    this.changeUtil = changeUtil;
+    this.changeFinder = changeFinder;
     this.updateFactory = updateFactory;
+    this.psUtil = psUtil;
     this.allowDrafts = config.getBoolean("change", "allowDrafts", true);
+    this.submitType = config
+        .getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+    this.mergeUtilFactory = mergeUtilFactory;
   }
 
   @Override
-  public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInfo input)
+  public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
       throws OrmException, IOException, InvalidChangeOperationException,
       RestApiException, UpdateException {
     if (Strings.isNullOrEmpty(input.project)) {
@@ -150,100 +185,151 @@
 
     Project.NameKey project = rsrc.getNameKey();
     try (Repository git = gitManager.openRepository(project);
-        RevWalk rw = new RevWalk(git)) {
+         ObjectInserter oi = git.newObjectInserter();
+         RevWalk rw = new RevWalk(oi.newReader())) {
       ObjectId parentCommit;
       List<String> groups;
       if (input.baseChange != null) {
-        List<Change> changes = changeUtil.findChanges(input.baseChange);
-        if (changes.size() != 1) {
+        List<ChangeControl> ctls = changeFinder.find(
+            input.baseChange, rsrc.getControl().getUser());
+        if (ctls.size() != 1) {
           throw new InvalidChangeOperationException(
               "Base change not found: " + input.baseChange);
         }
-        Change change = Iterables.getOnlyElement(changes);
-        if (!rsrc.getControl().controlFor(change).isVisible(db.get())) {
+        ChangeControl ctl = Iterables.getOnlyElement(ctls);
+        if (!ctl.isVisible(db.get())) {
           throw new InvalidChangeOperationException(
               "Base change not found: " + input.baseChange);
         }
-        PatchSet ps = db.get().patchSets().get(
-            new PatchSet.Id(change.getId(),
-            change.currentPatchSetId().get()));
+        PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
         parentCommit = ObjectId.fromString(ps.getRevision().get());
         groups = ps.getGroups();
       } else {
         Ref destRef = git.getRefDatabase().exactRef(refName);
-        if (destRef == null) {
-          throw new UnprocessableEntityException(String.format(
-              "Branch %s does not exist.", refName));
+        if (destRef != null) {
+          if (Boolean.TRUE.equals(input.newBranch)) {
+            throw new ResourceConflictException(String.format(
+                "Branch %s already exists.", refName));
+          }
+          parentCommit = destRef.getObjectId();
+        } else {
+          if (Boolean.TRUE.equals(input.newBranch)) {
+            parentCommit = null;
+          } else {
+            throw new UnprocessableEntityException(String.format(
+                "Branch %s does not exist.", refName));
+          }
         }
-        parentCommit = destRef.getObjectId();
-        groups = null;
+        groups = Collections.emptyList();
       }
-      RevCommit mergeTip = rw.parseCommit(parentCommit);
+      RevCommit mergeTip =
+          parentCommit == null ? null : rw.parseCommit(parentCommit);
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      AccountState account = accountCache.get(me.getAccountId());
+      GeneralPreferencesInfo info =
+          account.getAccount().getGeneralPreferencesInfo();
 
-      ObjectId id = ChangeIdUtil.computeChangeId(mergeTip.getTree(),
-          mergeTip, author, author, input.subject);
-      String commitMessage = ChangeIdUtil.insertId(input.subject, id);
-
-      try (ObjectInserter oi = git.newObjectInserter()) {
-        RevCommit c = newCommit(oi, rw, author, mergeTip, commitMessage);
-
-        Change change = new Change(
-            getChangeId(id, c),
-            new Change.Id(db.get().nextChangeId()),
-            me.getAccountId(),
-            new Branch.NameKey(project, refName),
-            now);
-
-        ChangeInserter ins = changeInserterFactory
-            .create(refControl, change, c)
-            .setValidatePolicy(CommitValidators.Policy.GERRIT);
-        ins.setMessage(String.format("Uploaded patch set %s.",
-            ins.getPatchSet().getPatchSetId()));
-        String topic = input.topic;
-        if (topic != null) {
-          topic = Strings.emptyToNull(topic.trim());
-        }
-        change.setTopic(topic);
-        ins.setDraft(input.status != null && input.status == ChangeStatus.DRAFT);
-        ins.setGroups(groups);
-        try (BatchUpdate bu = updateFactory.create(
-            db.get(), change.getProject(), me, now)) {
-          bu.setRepository(git, rw, oi);
-          bu.insertChange(ins);
-          bu.execute();
-        }
-        ChangeJson json = jsonFactory.create(ChangeJson.NO_OPTIONS);
-        return Response.created(json.format(change.getId()));
+      // Add a Change-Id line if there isn't already one
+      String commitMessage = input.subject;
+      if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+        ObjectId treeId =
+            mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
+        ObjectId id = ChangeIdUtil.computeChangeId(treeId,
+            mergeTip, author, author, commitMessage);
+        commitMessage = ChangeIdUtil.insertId(commitMessage, id);
       }
 
-    }
-  }
+      if (Boolean.TRUE.equals(info.signedOffBy)) {
+        commitMessage =
+            Joiner.on("\n").join(commitMessage.trim(), String.format(
+                "%s%s", SIGNED_OFF_BY_TAG,
+                account.getAccount().getNameEmail(anonymousCowardName)));
+      }
 
-  private static Change.Key getChangeId(ObjectId id, RevCommit emptyCommit) {
-    List<String> idList = emptyCommit.getFooterLines(
-        FooterConstants.CHANGE_ID);
-    Change.Key changeKey = !idList.isEmpty()
-        ? new Change.Key(idList.get(idList.size() - 1).trim())
-        : new Change.Key("I" + id.name());
-    return changeKey;
+      RevCommit c;
+      if (input.merge != null) {
+        // create a merge commit
+        if (!(submitType.equals(SubmitType.MERGE_ALWAYS) ||
+              submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+          throw new BadRequestException(
+              "Submit type: " + submitType + " is not supported");
+        }
+        c = newMergeCommit(git, oi, rw, rsrc.getControl(), mergeTip, input.merge,
+            author, commitMessage);
+      } else {
+        // create an empty commit
+        c = newCommit(oi, rw, author, mergeTip, commitMessage);
+      }
+
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName)
+          .setValidatePolicy(CommitValidators.Policy.GERRIT);
+      ins.setMessage(String.format("Uploaded patch set %s.",
+          ins.getPatchSetId().get()));
+      String topic = input.topic;
+      if (topic != null) {
+        topic = Strings.emptyToNull(topic.trim());
+      }
+      ins.setTopic(topic);
+      ins.setDraft(input.status == ChangeStatus.DRAFT);
+      ins.setGroups(groups);
+      try (BatchUpdate bu = updateFactory.create(
+          db.get(), project, me, now)) {
+        bu.setRepository(git, rw, oi);
+        bu.insertChange(ins);
+        bu.execute();
+      }
+      ChangeJson json = jsonFactory.create(ChangeJson.NO_OPTIONS);
+      return Response.created(json.format(ins.getChange()));
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
   }
 
   private static RevCommit newCommit(ObjectInserter oi, RevWalk rw,
       PersonIdent authorIdent, RevCommit mergeTip, String commitMessage)
       throws IOException {
     CommitBuilder commit = new CommitBuilder();
-    commit.setTreeId(mergeTip.getTree().getId());
-    commit.setParentId(mergeTip);
+    if (mergeTip == null) {
+      commit.setTreeId(emptyTreeId(oi));
+    } else {
+      commit.setTreeId(mergeTip.getTree().getId());
+      commit.setParentId(mergeTip);
+    }
     commit.setAuthor(authorIdent);
     commit.setCommitter(authorIdent);
     commit.setMessage(commitMessage);
     return rw.parseCommit(insert(oi, commit));
   }
 
+  private RevCommit newMergeCommit(Repository repo, ObjectInserter oi,
+      RevWalk rw, ProjectControl projectControl, RevCommit mergeTip,
+      MergeInput merge, PersonIdent authorIdent, String commitMessage)
+      throws RestApiException, IOException {
+    if (Strings.isNullOrEmpty(merge.source)) {
+      throw new BadRequestException("merge.source must be non-empty");
+    }
+
+    RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
+    if (!projectControl.canReadCommit(db.get(), repo, sourceCommit)) {
+      throw new BadRequestException(
+          "do not have read permission for: " + merge.source);
+    }
+
+    MergeUtil mergeUtil =
+        mergeUtilFactory.create(projectControl.getProjectState());
+    // default merge strategy from project settings
+    String mergeStrategy = MoreObjects.firstNonNull(
+        Strings.emptyToNull(merge.strategy),
+        mergeUtil.mergeStrategyName());
+
+    return MergeUtil.createMergeCommit(repo, oi, mergeTip, sourceCommit,
+        mergeStrategy, authorIdent, commitMessage, rw);
+  }
+
   private static ObjectId insert(ObjectInserter inserter,
       CommitBuilder commit) throws IOException,
       UnsupportedEncodingException {
@@ -251,4 +337,9 @@
     inserter.flush();
     return id;
   }
+
+  private static ObjectId emptyTreeId(ObjectInserter inserter)
+      throws IOException {
+    return inserter.insert(new TreeFormatter());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index a503721..7cb2aac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -15,84 +15,120 @@
 package com.google.gerrit.server.change;
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.change.PutDraftComment.side;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Collections;
 
 @Singleton
 public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
   private final Provider<ReviewDb> db;
-  private final ChangeUpdate.Factory updateFactory;
+  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchLineCommentsUtil plcUtil;
+  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
 
   @Inject
   CreateDraftComment(Provider<ReviewDb> db,
-      ChangeUpdate.Factory updateFactory,
+      BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
       PatchListCache patchListCache) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
   }
 
   @Override
   public Response<CommentInfo> apply(RevisionResource rsrc, DraftInput in)
-      throws BadRequestException, OrmException, IOException {
+      throws RestApiException, UpdateException, OrmException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
-    } else if (in.line != null && in.line <= 0) {
-      throw new BadRequestException("line must be > 0");
+    } else if (in.line != null && in.line < 0) {
+      throw new BadRequestException("line must be >= 0");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    int line = in.line != null
-        ? in.line
-        : in.range != null ? in.range.endLine : 0;
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getPatchSet().getId(), in);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      return Response.created(
+          commentJson.get().setFillAccounts(false).format(op.comment));
+    }
+  }
 
-    Timestamp now = TimeUtil.nowTs();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl(), now);
+  private class Op extends BatchUpdate.Op {
+    private final PatchSet.Id psId;
+    private final DraftInput in;
 
-    PatchLineComment c = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(rsrc.getPatchSet().getId(), in.path),
-            ChangeUtil.messageUUID(db.get())),
-        line, rsrc.getAccountId(), Url.decode(in.inReplyTo), now);
-    c.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
-    c.setMessage(in.message.trim());
-    c.setRange(in.range);
-    setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-    plcUtil.insertComments(db.get(), update, Collections.singleton(c));
-    update.commit();
-    return Response.created(commentJson.get().setFillAccounts(false).format(c));
+    private PatchLineComment comment;
+
+    private Op(PatchSet.Id psId, DraftInput in) {
+      this.psId = psId;
+      this.in = in;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      int line = in.line != null
+          ? in.line
+          : in.range != null ? in.range.endLine : 0;
+      comment = new PatchLineComment(
+          new PatchLineComment.Key(
+              new Patch.Key(ps.getId(), in.path),
+              ChangeUtil.messageUUID(ctx.getDb())),
+          line, ctx.getAccountId(), Url.decode(in.inReplyTo),
+          ctx.getWhen());
+      comment.setSide(side(in));
+      comment.setMessage(in.message.trim());
+      comment.setRange(in.range);
+      comment.setTag(in.tag);
+      setCommentRevId(
+          comment, patchListCache, ctx.getChange(), ps);
+      plcUtil.putComments(
+          ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(comment));
+      ctx.bumpLastUpdatedOn(false);
+      return true;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
index 495e695..7c1e959 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.change.DeleteChangeEdit.Input;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -41,7 +42,8 @@
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, IOException {
+      throws AuthException, ResourceNotFoundException, IOException,
+      OrmException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
     if (edit.isPresent()) {
       editUtil.delete(edit.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
index b276aae..a125272 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.DeleteDraftChange.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,50 +33,38 @@
 
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-
 @Singleton
 public class DeleteDraftChange implements
     RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   public static class Input {
   }
 
-  protected final Provider<ReviewDb> dbProvider;
-  private final ChangeUtil changeUtil;
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
+  private final Provider<DeleteDraftChangeOp> opProvider;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftChange(Provider<ReviewDb> dbProvider,
-      ChangeUtil changeUtil,
+  public DeleteDraftChange(Provider<ReviewDb> db,
+      BatchUpdate.Factory updateFactory,
+      Provider<DeleteDraftChangeOp> opProvider,
       @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
-    this.changeUtil = changeUtil;
-    this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
+    this.db = db;
+    this.updateFactory = updateFactory;
+    this.opProvider = opProvider;
+    this.allowDrafts = DeleteDraftChangeOp.allowDrafts(cfg);
   }
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws ResourceConflictException, AuthException,
-      ResourceNotFoundException, MethodNotAllowedException,
-      OrmException, IOException {
-    if (rsrc.getChange().getStatus() != Status.DRAFT) {
-      throw new ResourceConflictException("Change is not a draft");
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
+      bu.addOp(id, opProvider.get());
+      bu.execute();
     }
-
-    if (!rsrc.getControl().canDeleteDraft(dbProvider.get())) {
-      throw new AuthException("Not permitted to delete this draft change");
-    }
-
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("draft workflow is disabled");
-    }
-
-    try {
-      changeUtil.deleteDraftChange(rsrc.getChange());
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-
     return Response.none();
   }
 
@@ -85,11 +72,11 @@
   public UiAction.Description getDescription(ChangeResource rsrc) {
     try {
       return new UiAction.Description()
-        .setTitle(String.format("Delete draft change %d",
-            rsrc.getChange().getChangeId()))
+        .setLabel("Delete")
+        .setTitle("Delete draft change " + rsrc.getId())
         .setVisible(allowDrafts
             && rsrc.getChange().getStatus() == Status.DRAFT
-            && rsrc.getControl().canDeleteDraft(dbProvider.get()));
+            && rsrc.getControl().canDeleteDraft(db.get()));
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
new file mode 100644
index 0000000..3ca0e1b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.BatchUpdateReviewDb;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.List;
+
+class DeleteDraftChangeOp extends BatchUpdate.Op {
+  static boolean allowDrafts(Config cfg) {
+    return cfg.getBoolean("change", "allowDrafts", true);
+  }
+
+  static ReviewDb unwrap(ReviewDb db) {
+    // This is special. We want to delete exactly the rows that are present in
+    // the database, even when reading everything else from NoteDb, so we need
+    // to bypass the write-only wrapper.
+    if (db instanceof BatchUpdateReviewDb) {
+      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+    }
+    return ReviewDbUtil.unwrapDb(db);
+  }
+
+
+  private final PatchSetUtil psUtil;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final boolean allowDrafts;
+
+  private Change.Id id;
+
+  @Inject
+  DeleteDraftChangeOp(PatchSetUtil psUtil,
+      StarredChangesUtil starredChangesUtil,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      @GerritServerConfig Config cfg) {
+    this.psUtil = psUtil;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.allowDrafts = allowDrafts(cfg);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException,
+      OrmException, IOException, NoSuchChangeException {
+    checkState(ctx.getOrder() == BatchUpdate.Order.DB_BEFORE_REPO,
+        "must use DeleteDraftChangeOp with DB_BEFORE_REPO");
+    checkState(id == null, "cannot reuse DeleteDraftChangeOp");
+
+    Change change = ctx.getChange();
+    id = change.getId();
+
+    ReviewDb db = unwrap(ctx.getDb());
+    if (change.getStatus() != Change.Status.DRAFT) {
+      throw new ResourceConflictException("Change is not a draft: " + id);
+    }
+    if (!allowDrafts) {
+      throw new MethodNotAllowedException("Draft workflow is disabled");
+    }
+    if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
+      throw new AuthException("Not permitted to delete this draft change");
+    }
+    List<PatchSet> patchSets = ImmutableList.copyOf(
+        psUtil.byChange(ctx.getDb(), ctx.getNotes()));
+    for (PatchSet ps : patchSets) {
+      if (!ps.isDraft()) {
+        throw new ResourceConflictException("Cannot delete draft change " + id
+            + ": patch set " + ps.getPatchSetId() + " is not a draft");
+      }
+      accountPatchReviewStore.get().clearReviewed(ps.getId());
+    }
+
+    // Only delete from ReviewDb here; deletion from NoteDb is handled in
+    // BatchUpdate.
+    db.patchComments().delete(db.patchComments().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+
+    // Non-atomic operation on Accounts table; not much we can do to make it
+    // atomic.
+    starredChangesUtil.unstarAll(change.getProject(), id);
+
+    ctx.deleteChange();
+    return true;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    String prefix = new PatchSet.Id(id, 1).toRefName();
+    prefix = prefix.substring(0, prefix.length() - 1);
+    for (Ref ref
+        : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
+      ctx.addRefUpdate(
+          new ReceiveCommand(
+            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index c4270a9..56dbfa7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -16,53 +16,94 @@
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
 
+import com.google.common.base.Optional;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
-public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+public class DeleteDraftComment
+    implements RestModifyView<DraftCommentResource, Input> {
   static class Input {
   }
 
   private final Provider<ReviewDb> db;
   private final PatchLineCommentsUtil plcUtil;
-  private final ChangeUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
+  private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(Provider<ReviewDb> db,
       PatchLineCommentsUtil plcUtil,
-      ChangeUpdate.Factory updateFactory,
+      PatchSetUtil psUtil,
+      BatchUpdate.Factory updateFactory,
       PatchListCache patchListCache) {
     this.db = db;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.patchListCache = patchListCache;
   }
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
-      throws OrmException, IOException {
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
-
-    PatchLineComment c = rsrc.getComment();
-    setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-    plcUtil.deleteComments(db.get(), update, Collections.singleton(c));
-    update.commit();
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
+        TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().getKey());
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+    }
     return Response.none();
   }
+
+  private class Op extends BatchUpdate.Op {
+    private final PatchLineComment.Key key;
+
+    private Op(PatchLineComment.Key key) {
+      this.key = key;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      Optional<PatchLineComment> maybeComment =
+          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+      if (!maybeComment.isPresent()) {
+        return false; // Nothing to do.
+      }
+      PatchSet.Id psId = key.getParentKey().getParentKey();
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      PatchLineComment c = maybeComment.get();
+      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
+      plcUtil.deleteComments(
+          ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
+      ctx.bumpLastUpdatedOn(false);
+      return true;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index a266337..1cd8726 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -14,33 +14,40 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftPatchSet.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
+import java.util.Collection;
 
 @Singleton
 public class DeleteDraftPatchSet implements RestModifyView<RevisionResource, Input>,
@@ -48,131 +55,150 @@
   public static class Input {
   }
 
-  protected final Provider<ReviewDb> dbProvider;
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ChangeUtil changeUtil;
-  private final ChangeIndexer indexer;
+  private final PatchSetUtil psUtil;
+  private final Provider<DeleteDraftChangeOp> deleteChangeOpProvider;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftPatchSet(Provider<ReviewDb> dbProvider,
+  public DeleteDraftPatchSet(Provider<ReviewDb> db,
+      BatchUpdate.Factory updateFactory,
       PatchSetInfoFactory patchSetInfoFactory,
-      ChangeUtil changeUtil,
-      ChangeIndexer indexer,
+      PatchSetUtil psUtil,
+      Provider<DeleteDraftChangeOp> deleteChangeOpProvider,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
+    this.db = db;
+    this.updateFactory = updateFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
-    this.changeUtil = changeUtil;
-    this.indexer = indexer;
+    this.psUtil = psUtil;
+    this.deleteChangeOpProvider = deleteChangeOpProvider;
+    this.accountPatchReviewStore = accountPatchReviewStore;
     this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
   }
 
   @Override
   public Response<?> apply(RevisionResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, MethodNotAllowedException,
-      OrmException, IOException {
-    PatchSet patchSet = rsrc.getPatchSet();
-    PatchSet.Id patchSetId = patchSet.getId();
-    Change change = rsrc.getChange();
-
-    if (!patchSet.isDraft()) {
-      throw new ResourceConflictException("Patch set is not a draft");
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
+      bu.addOp(rsrc.getChange().getId(), new Op(rsrc.getPatchSet().getId()));
+      bu.execute();
     }
-
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("draft workflow is disabled");
-    }
-
-    if (!rsrc.getControl().canDeleteDraft(dbProvider.get())) {
-      throw new AuthException("Not permitted to delete this draft patch set");
-    }
-
-    deleteDraftPatchSet(patchSet, change);
-    deleteOrUpdateDraftChange(patchSetId, change);
-
     return Response.none();
   }
 
+  private class Op extends BatchUpdate.Op {
+    private final PatchSet.Id psId;
+
+    private Collection<PatchSet> patchSetsBeforeDeletion;
+    private PatchSet patchSet;
+    private DeleteDraftChangeOp deleteChangeOp;
+
+    private Op(PatchSet.Id psId) {
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws RestApiException,
+        OrmException, IOException, NoSuchChangeException {
+      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (patchSet == null) {
+        return false; // Nothing to do.
+      }
+      if (!patchSet.isDraft()) {
+        throw new ResourceConflictException("Patch set is not a draft");
+      }
+      if (!allowDrafts) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+      if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
+        throw new AuthException("Not permitted to delete this draft patch set");
+      }
+
+      patchSetsBeforeDeletion = psUtil.byChange(ctx.getDb(), ctx.getNotes());
+      deleteDraftPatchSet(patchSet, ctx);
+      deleteOrUpdateDraftChange(ctx);
+      return true;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws IOException {
+      if (deleteChangeOp != null) {
+        deleteChangeOp.updateRepo(ctx);
+        return;
+      }
+      ctx.addRefUpdate(
+          new ReceiveCommand(
+              ObjectId.fromString(patchSet.getRevision().get()),
+              ObjectId.zeroId(),
+              patchSet.getRefName()));
+    }
+
+    private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx)
+        throws OrmException {
+      // For NoteDb itself, no need to delete these entities, as they are
+      // automatically filtered out when patch sets are deleted.
+      psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
+
+      accountPatchReviewStore.get().clearReviewed(psId);
+      // Use the unwrap from DeleteDraftChangeOp to handle BatchUpdateReviewDb.
+      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
+      db.patchComments().delete(db.patchComments().byPatchSet(psId));
+      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
+    }
+
+    private void deleteOrUpdateDraftChange(ChangeContext ctx)
+        throws OrmException, RestApiException, IOException,
+        NoSuchChangeException {
+      Change c = ctx.getChange();
+      if (deletedOnlyPatchSet()) {
+        deleteChangeOp = deleteChangeOpProvider.get();
+        deleteChangeOp.updateChange(ctx);
+        return;
+      }
+      if (c.currentPatchSetId().equals(psId)) {
+        c.setCurrentPatchSet(previousPatchSetInfo(ctx));
+      }
+    }
+
+    private boolean deletedOnlyPatchSet() {
+      return patchSetsBeforeDeletion.size() == 1
+          && patchSetsBeforeDeletion.iterator().next().getId().equals(psId);
+    }
+
+    private PatchSetInfo previousPatchSetInfo(ChangeContext ctx)
+        throws OrmException {
+      try {
+        // TODO(dborowitz): Get this in a way that doesn't involve re-opening
+        // the repo after the updateRepo phase.
+        return patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(),
+            new PatchSet.Id(psId.getParentKey(), psId.get() - 1));
+      } catch (PatchSetInfoNotAvailableException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
-      int psCount = dbProvider.get().patchSets()
-          .byChange(rsrc.getChange().getId()).toList().size();
+      int psCount = psUtil.byChange(db.get(), rsrc.getNotes()).size();
       return new UiAction.Description()
+        .setLabel("Delete")
         .setTitle(String.format("Delete draft revision %d",
             rsrc.getPatchSet().getPatchSetId()))
         .setVisible(allowDrafts
             && rsrc.getPatchSet().isDraft()
-            && rsrc.getControl().canDeleteDraft(dbProvider.get())
+            && rsrc.getControl().canDeleteDraft(db.get())
             && psCount > 1);
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
   }
-
-  private void deleteDraftPatchSet(PatchSet patchSet, Change change)
-      throws ResourceNotFoundException, OrmException, IOException {
-    try {
-      changeUtil.deleteOnlyDraftPatchSet(patchSet, change);
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-  }
-
-  private void deleteOrUpdateDraftChange(PatchSet.Id patchSetId,
-      Change change) throws OrmException, ResourceNotFoundException,
-      IOException {
-    if (dbProvider.get()
-            .patchSets()
-            .byChange(change.getId())
-            .toList().size() == 0) {
-      deleteDraftChange(change);
-    } else {
-      if (change.currentPatchSetId().equals(patchSetId)) {
-        updateChange(dbProvider.get(), change,
-            previousPatchSetInfo(patchSetId));
-      } else {
-        // TODO(davido): find a better way to enforce cache invalidation.
-        updateChange(dbProvider.get(), change, null);
-      }
-    }
-  }
-
-  private void deleteDraftChange(Change change)
-      throws OrmException, IOException, ResourceNotFoundException {
-    try {
-      changeUtil.deleteDraftChange(change);
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-  }
-
-  private PatchSetInfo previousPatchSetInfo(PatchSet.Id patchSetId)
-      throws OrmException {
-    try {
-      return patchSetInfoFactory.get(dbProvider.get(),
-          new PatchSet.Id(patchSetId.getParentKey(),
-              patchSetId.get() - 1));
-    } catch (PatchSetInfoNotAvailableException e) {
-        throw new OrmException(e);
-    }
-  }
-
-  private void updateChange(final ReviewDb db,
-      Change change, final PatchSetInfo psInfo)
-      throws OrmException, IOException  {
-    change = db.changes().atomicUpdate(change.getId(),
-        new AtomicUpdate<Change>() {
-          @Override
-          public Change update(Change c) {
-            if (psInfo != null) {
-              c.setCurrentPatchSet(psInfo);
-            }
-            ChangeUtil.updated(c);
-            return c;
-          }
-        });
-    indexer.index(db, change);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 6c37252..bdefa93 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -18,132 +18,246 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteReviewer.Input;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.BatchUpdateReviewDb;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.mail.DeleteReviewerSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.io.IOException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Singleton
 public class DeleteReviewer implements RestModifyView<ReviewerResource, Input> {
+  private static final Logger log = LoggerFactory
+      .getLogger(DeleteReviewer.class);
+
   public static class Input {
   }
 
   private final Provider<ReviewDb> dbProvider;
-  private final ChangeUpdate.Factory updateFactory;
   private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final ChangeIndexer indexer;
+  private final BatchUpdate.Factory batchUpdateFactory;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
 
   @Inject
   DeleteReviewer(Provider<ReviewDb> dbProvider,
-      ChangeUpdate.Factory updateFactory,
       ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      ChangeIndexer indexer,
-      IdentifiedUser.GenericFactory userFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration) {
     this.dbProvider = dbProvider;
-    this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.indexer = indexer;
+    this.batchUpdateFactory = batchUpdateFactory;
     this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
   }
 
   @Override
   public Response<?> apply(ReviewerResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException,
-      IOException {
-    ChangeControl control = rsrc.getControl();
-    Change.Id changeId = rsrc.getChange().getId();
-    ReviewDb db = dbProvider.get();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
-
-    StringBuilder msg = new StringBuilder();
-    db.changes().beginTransaction(changeId);
-    try {
-      List<PatchSetApproval> del = Lists.newArrayList();
-      for (PatchSetApproval a : approvals(db, rsrc)) {
-        if (control.canRemoveReviewer(a)) {
-          del.add(a);
-          if (a.getPatchSetId().equals(control.getChange().currentPatchSetId())
-              && a.getValue() != 0) {
-            if (msg.length() == 0) {
-              msg.append("Removed the following votes:\n\n");
-            }
-            msg.append("* ")
-                .append(a.getLabel()).append(formatLabelValue(a.getValue()))
-                .append(" by ").append(userFactory.create(a.getAccountId()).getNameEmail())
-                .append("\n");
-          }
-        } else {
-          throw new AuthException("delete not permitted");
-        }
-      }
-      if (del.isEmpty()) {
-        throw new ResourceNotFoundException();
-      }
-      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getChange().getId(), db);
-      db.patchSetApprovals().delete(del);
-      update.removeReviewer(rsrc.getUser().getAccountId());
-
-      if (msg.length() > 0) {
-        ChangeMessage changeMessage =
-            new ChangeMessage(new ChangeMessage.Key(rsrc.getChange().getId(),
-                ChangeUtil.messageUUID(db)),
-                control.getUser().getAccountId(),
-                TimeUtil.nowTs(), rsrc.getChange().currentPatchSetId());
-        changeMessage.setMessage(msg.toString());
-        cmUtil.addChangeMessage(db, update, changeMessage);
-      }
-
-      db.commit();
-    } finally {
-      db.rollback();
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
+        rsrc.getChangeResource().getProject(),
+        rsrc.getChangeResource().getUser(), TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getReviewerUser().getAccount());
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
     }
-    update.commit();
-    indexer.index(db, rsrc.getChange());
+
     return Response.none();
   }
 
-  private static String formatLabelValue(short value) {
-    if (value > 0) {
-      return "+" + value;
-    } else {
+  private class Op extends BatchUpdate.Op {
+    private final Account reviewer;
+    ChangeMessage changeMessage;
+    Change currChange;
+    PatchSet currPs;
+    List<PatchSetApproval> del = new ArrayList<>();
+    Map<String, Short> newApprovals = new HashMap<>();
+    Map<String, Short> oldApprovals = new HashMap<>();
+
+    Op(Account reviewerAccount) {
+      this.reviewer = reviewerAccount;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws AuthException, ResourceNotFoundException, OrmException {
+      Account.Id reviewerId = reviewer.getId();
+      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all()
+          .contains(reviewerId)) {
+        throw new ResourceNotFoundException();
+      }
+      currChange = ctx.getChange();
+      currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      // removing a reviewer will remove all her votes
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        newApprovals.put(lt.getName(), (short) 0);
+      }
+
+      StringBuilder msg = new StringBuilder();
+      for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+        if (ctx.getControl().canRemoveReviewer(a)) {
+          del.add(a);
+          if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+            oldApprovals.put(a.getLabel(), a.getValue());
+            if (msg.length() == 0) {
+              msg.append("Removed reviewer ").append(reviewer.getFullName())
+                  .append(" with the following votes:\n\n");
+            }
+            msg.append("* ").append(a.getLabel())
+                .append(formatLabelValue(a.getValue())).append(" by ")
+                .append(userFactory.create(a.getAccountId()).getNameEmail())
+                .append("\n");
+          }
+        } else {
+          throw new AuthException("delete reviewer not permitted");
+        }
+      }
+
+      ctx.getDb().patchSetApprovals().delete(del);
+      ChangeUpdate update = ctx.getUpdate(currPs.getId());
+      update.removeReviewer(reviewerId);
+
+      if (msg.length() > 0) {
+        changeMessage = new ChangeMessage(
+            new ChangeMessage.Key(currChange.getId(),
+                ChangeUtil.messageUUID(ctx.getDb())),
+            ctx.getAccountId(), ctx.getWhen(), currPs.getId());
+        changeMessage.setMessage(msg.toString());
+        cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+      }
+
+      return true;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      if (changeMessage == null) {
+        return;
+      }
+
+      emailReviewers(ctx.getProject(), currChange, del, changeMessage);
+      reviewerDeleted.fire(currChange, currPs, reviewer,
+          ctx.getAccount(),
+          changeMessage.getMessage(),
+          newApprovals, oldApprovals,
+          ctx.getWhen());
+    }
+
+    private Iterable<PatchSetApproval> approvals(ChangeContext ctx,
+        final Account.Id accountId) throws OrmException {
+      Change.Id changeId = ctx.getNotes().getChangeId();
+      Iterable<PatchSetApproval> approvals;
+
+      if (migration.readChanges()) {
+        // Because NoteDb and ReviewDb have different semantics for zero-value
+        // approvals, we must fall back to ReviewDb as the source of truth here.
+        ReviewDb db = ctx.getDb();
+
+        if (db instanceof BatchUpdateReviewDb) {
+          db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+        }
+        db = ReviewDbUtil.unwrapDb(db);
+        approvals = db.patchSetApprovals().byChange(changeId);
+      } else {
+        approvals =
+            approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+      }
+
+      return Iterables.filter(
+          approvals,
+          new Predicate<PatchSetApproval>() {
+            @Override
+            public boolean apply(PatchSetApproval input) {
+              return accountId.equals(input.getAccountId());
+            }
+          });
+    }
+
+    private String formatLabelValue(short value) {
+      if (value > 0) {
+        return "+" + value;
+      }
       return Short.toString(value);
     }
   }
 
-  private Iterable<PatchSetApproval> approvals(ReviewDb db,
-      ReviewerResource rsrc) throws OrmException {
-    final Account.Id user = rsrc.getUser().getAccountId();
-    return Iterables.filter(
-        approvalsUtil.byChange(db, rsrc.getNotes()).values(),
-        new Predicate<PatchSetApproval>() {
-          @Override
-          public boolean apply(PatchSetApproval input) {
-            return user.equals(input.getAccountId());
-          }
-        });
+  private void emailReviewers(Project.NameKey projectName, Change change,
+      List<PatchSetApproval> dels, ChangeMessage changeMessage) {
+
+    // The user knows they removed themselves, don't bother emailing them.
+    List<Account.Id> toMail = Lists.newArrayListWithCapacity(dels.size());
+    Account.Id userId = user.get().getAccountId();
+    for (PatchSetApproval psa : dels) {
+      if (!psa.getAccountId().equals(userId)) {
+        toMail.add(psa.getAccountId());
+      }
+    }
+    if (!toMail.isEmpty()) {
+      try {
+        DeleteReviewerSender cm =
+            deleteReviewerSenderFactory.create(projectName, change.getId());
+        cm.setFrom(userId);
+        cm.addReviewers(toMail);
+        cm.setChangeMessage(changeMessage.getMessage(),
+            changeMessage.getWrittenOn());
+        cm.send();
+      } catch (Exception err) {
+        log.error("Cannot email update for change " + change.getId(), err);
+      }
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
new file mode 100644
index 0000000..f1bdba5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -0,0 +1,230 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.mail.DeleteVoteSender;
+import com.google.gerrit.server.mail.ReplyToChangeSender;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+@Singleton
+public class DeleteVote
+    implements RestModifyView<VoteResource, DeleteVoteInput> {
+  private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
+
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+
+  @Inject
+  DeleteVote(Provider<ReviewDb> db,
+      BatchUpdate.Factory batchUpdateFactory,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory) {
+    this.db = db;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+  }
+
+  @Override
+  public Response<?> apply(VoteResource rsrc, DeleteVoteInput input)
+      throws RestApiException, UpdateException {
+    if (input == null) {
+      input = new DeleteVoteInput();
+    }
+    if (input.label != null && !rsrc.getLabel().equals(input.label)) {
+      throw new BadRequestException("label must match URL");
+    }
+    if (input.notify == null) {
+      input.notify = NotifyHandling.ALL;
+    }
+    ReviewerResource r = rsrc.getReviewer();
+    Change change = r.getChange();
+    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
+          change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
+      bu.addOp(change.getId(),
+          new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel(), input));
+      bu.execute();
+    }
+
+    return Response.none();
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private final Account.Id accountId;
+    private final String label;
+    private final DeleteVoteInput input;
+    private ChangeMessage changeMessage;
+    private Change change;
+    private PatchSet ps;
+    private Map<String, Short> newApprovals = new HashMap<>();
+    private Map<String, Short> oldApprovals = new HashMap<>();
+
+    private Op(Account.Id accountId, String label, DeleteVoteInput input) {
+      this.accountId = accountId;
+      this.label = label;
+      this.input = input;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, AuthException, ResourceNotFoundException {
+      ChangeControl ctl = ctx.getControl();
+      change = ctl.getChange();
+      PatchSet.Id psId = change.currentPatchSetId();
+      ps = psUtil.current(db.get(), ctl.getNotes());
+
+      PatchSetApproval psa = null;
+      StringBuilder msg = new StringBuilder();
+
+      // get all of the current approvals
+      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      Map<String, Short> currentApprovals = new HashMap<>();
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        currentApprovals.put(lt.getName(), (short) 0);
+        for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
+            ctx.getDb(), ctl, psId, accountId)) {
+          if (lt.getLabelId().equals(a.getLabelId())) {
+            currentApprovals.put(lt.getName(), a.getValue());
+          }
+        }
+      }
+      // removing votes so we need to determine the new set of approval scores
+      newApprovals.putAll(currentApprovals);
+      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
+            ctx.getDb(), ctl, psId, accountId)) {
+        if (ctl.canRemoveReviewer(a)) {
+          if (a.getLabel().equals(label)) {
+            // set the approval to 0 if vote is being removed
+            newApprovals.put(a.getLabel(), (short) 0);
+            // set old value only if the vote changed
+            oldApprovals.put(a.getLabel(), a.getValue());
+            msg.append("Removed ")
+                .append(a.getLabel()).append(formatLabelValue(a.getValue()))
+                .append(" by ").append(userFactory.create(a.getAccountId())
+                    .getNameEmail())
+                .append("\n");
+            psa = a;
+            a.setValue((short)0);
+            ctx.getUpdate(psId).removeApprovalFor(a.getAccountId(), label);
+            break;
+          }
+        } else {
+          throw new AuthException("delete vote not permitted");
+        }
+      }
+      if (psa == null) {
+        throw new ResourceNotFoundException();
+      }
+      ctx.getDb().patchSetApprovals().update(Collections.singleton(psa));
+
+      if (msg.length() > 0) {
+        changeMessage =
+            new ChangeMessage(new ChangeMessage.Key(change.getId(),
+                ChangeUtil.messageUUID(ctx.getDb())),
+                ctx.getAccountId(),
+                ctx.getWhen(),
+                change.currentPatchSetId());
+        changeMessage.setMessage(msg.toString());
+        cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId),
+            changeMessage);
+      }
+      return true;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      if (changeMessage == null) {
+        return;
+      }
+
+      IdentifiedUser user = ctx.getIdentifiedUser();
+      if (input.notify.compareTo(NotifyHandling.NONE) > 0) {
+        try {
+          ReplyToChangeSender cm = deleteVoteSenderFactory.create(
+              ctx.getProject(), change.getId());
+          cm.setFrom(user.getAccountId());
+          cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          cm.setNotify(input.notify);
+          cm.send();
+        } catch (Exception e) {
+          log.error("Cannot email update for change " + change.getId(), e);
+        }
+      }
+
+      voteDeleted.fire(change, ps,
+          newApprovals, oldApprovals, input.notify, changeMessage.getMessage(),
+          user.getAccount(), ctx.getWhen());
+    }
+  }
+
+  private static String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
index f1dff88..390f416 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,8 +16,7 @@
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
 
-import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.mail.CommentSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -48,7 +48,7 @@
   interface Factory {
     EmailReviewComments create(
         NotifyHandling notify,
-        Change change,
+        ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
@@ -62,7 +62,7 @@
   private final ThreadLocalRequestContext requestContext;
 
   private final NotifyHandling notify;
-  private final Change change;
+  private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
@@ -77,7 +77,7 @@
       SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       @Assisted NotifyHandling notify,
-      @Assisted Change change,
+      @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
@@ -88,7 +88,7 @@
     this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.notify = notify;
-    this.change = change;
+    this.notes = notes;
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
@@ -104,11 +104,14 @@
     RequestContext old = requestContext.setContext(this);
     try {
 
-      CommentSender cm = commentSenderFactory.create(notify, change.getId());
+      CommentSender cm = commentSenderFactory.create(notes.getProjectName(),
+          notes.getChangeId());
       cm.setFrom(user.getAccountId());
-      cm.setPatchSet(patchSet, patchSetInfoFactory.get(change, patchSet));
-      cm.setChangeMessage(message);
+      cm.setPatchSet(patchSet,
+          patchSetInfoFactory.get(notes.getProjectName(), patchSet));
+      cm.setChangeMessage(message.getMessage(), message.getWrittenOn());
       cm.setPatchLineComments(comments);
+      cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
       log.error("Cannot email comments for " + patchSet.getId(), e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index 71974c4..e0591f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Maps;
+import static com.google.gerrit.server.util.GitUtil.getParent;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -22,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -30,16 +32,24 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
+import java.io.IOException;
 import java.util.Map;
+import java.util.TreeMap;
 
 @Singleton
 public class FileInfoJson {
   private final PatchListCache patchListCache;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FileInfoJson(PatchListCache patchListCache) {
+  FileInfoJson(
+      PatchListCache patchListCache,
+      GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
     this.patchListCache = patchListCache;
   }
 
@@ -54,16 +64,33 @@
         ? null
         : ObjectId.fromString(base.getRevision().get());
     ObjectId b = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, a, b);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+      throws RepositoryNotFoundException, IOException,
+          PatchListNotAvailableException {
+    ObjectId b = ObjectId.fromString(revision.get());
+    ObjectId a;
+    try (Repository git = repoManager.openRepository(change.getProject())) {
+      a = getParent(git, b, parent);
+    }
+    return toFileInfoMap(change, a, b);
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change,
+      ObjectId a, ObjectId b) throws PatchListNotAvailableException {
     PatchList list = patchListCache.get(
         new PatchListKey(a, b, Whitespace.IGNORE_NONE), change.getProject());
 
-    Map<String, FileInfo> files = Maps.newTreeMap();
+    Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
       FileInfo d = new FileInfo();
       d.status = e.getChangeType() != Patch.ChangeType.MODIFIED
           ? e.getChangeType().getCode() : null;
       d.oldPath = e.getOldName();
       d.sizeDelta = e.getSizeDelta();
+      d.size = e.getSize();
       if (e.getPatchType() == Patch.PatchType.BINARY) {
         d.binary = true;
       } else {
@@ -78,6 +105,7 @@
         // a single record with data from both sides.
         d.status = Patch.ChangeType.REWRITE.getCode();
         d.sizeDelta = o.sizeDelta;
+        d.size = o.size;
         if (o.binary != null && o.binary) {
           d.binary = true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index e28d796..dd584cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.base.Optional;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,12 +29,13 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.AccountPatchReviewStore.PatchSetWithReviewedFiles;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -57,11 +59,11 @@
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.SortedSet;
 import java.util.concurrent.TimeUnit;
 
 @Singleton
@@ -96,6 +98,9 @@
     @Option(name = "--base", metaVar = "revision-id")
     String base;
 
+    @Option(name = "--parent", metaVar = "parent-number")
+    int parentNum;
+
     @Option(name = "--reviewed")
     boolean reviewed;
 
@@ -108,6 +113,8 @@
     private final Revisions revisions;
     private final GitRepositoryManager gitManager;
     private final PatchListCache patchListCache;
+    private final PatchSetUtil psUtil;
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
     ListFiles(Provider<ReviewDb> db,
@@ -115,13 +122,17 @@
         FileInfoJson fileInfoJson,
         Revisions revisions,
         GitRepositoryManager gitManager,
-        PatchListCache patchListCache) {
+        PatchListCache patchListCache,
+        PatchSetUtil psUtil,
+        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
       this.db = db;
       this.self = self;
       this.fileInfoJson = fileInfoJson;
       this.revisions = revisions;
       this.gitManager = gitManager;
       this.patchListCache = patchListCache;
+      this.psUtil = psUtil;
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     public ListFiles setReviewed(boolean r) {
@@ -140,24 +151,33 @@
         return Response.ok(query(resource));
       }
 
-      PatchSet basePatchSet = null;
-      if (base != null) {
-        RevisionResource baseResource = revisions.parse(
-            resource.getChangeResource(), IdString.fromDecoded(base));
-        basePatchSet = baseResource.getPatchSet();
-      }
+      Response<Map<String, FileInfo>> r;
       try {
-        Response<Map<String, FileInfo>> r = Response.ok(fileInfoJson.toFileInfoMap(
-            resource.getChange(),
-            resource.getPatchSet().getRevision(),
-            basePatchSet));
-        if (resource.isCacheable()) {
-          r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+        if (base != null) {
+          RevisionResource baseResource = revisions.parse(
+              resource.getChangeResource(), IdString.fromDecoded(base));
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet().getRevision(),
+              baseResource.getPatchSet()));
+        } else if (parentNum > 0) {
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet().getRevision(),
+              parentNum - 1));
+        } else {
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet()));
         }
-        return r;
       } catch (PatchListNotAvailableException e) {
         throw new ResourceNotFoundException(e.getMessage());
       }
+
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
     }
 
     private void checkOptions() throws BadRequestException {
@@ -165,6 +185,9 @@
       if (base != null) {
         supplied++;
       }
+      if (parentNum > 0) {
+        supplied++;
+      }
       if (reviewed) {
         supplied++;
       }
@@ -172,7 +195,8 @@
         supplied++;
       }
       if (supplied > 1) {
-        throw new BadRequestException("cannot combine base, reviewed, query");
+        throw new BadRequestException(
+            "cannot combine base, parent, reviewed, query");
       }
     }
 
@@ -199,7 +223,7 @@
       }
     }
 
-    private List<String> reviewed(RevisionResource resource)
+    private Collection<String> reviewed(RevisionResource resource)
         throws AuthException, OrmException {
       CurrentUser user = self.get();
       if (!(user.isIdentifiedUser())) {
@@ -207,49 +231,25 @@
       }
 
       Account.Id userId = user.getAccountId();
-      List<String> r = scan(userId, resource.getPatchSet().getId());
+      PatchSet patchSetId = resource.getPatchSet();
+      Optional<PatchSetWithReviewedFiles> o = accountPatchReviewStore.get()
+          .findReviewed(patchSetId.getId(), userId);
 
-      if (r.isEmpty() && 1 < resource.getPatchSet().getPatchSetId()) {
-        for (Integer id : reverseSortPatchSets(resource)) {
-          PatchSet.Id old = new PatchSet.Id(resource.getChange().getId(), id);
-          List<String> o = scan(userId, old);
-          if (!o.isEmpty()) {
-            try {
-              r = copy(Sets.newHashSet(o), old, resource, userId);
-            } catch (IOException | PatchListNotAvailableException e) {
-              log.warn("Cannot copy patch review flags", e);
-            }
-            break;
-          }
+      if (o.isPresent()) {
+        PatchSetWithReviewedFiles res = o.get();
+        if (res.patchSetId().equals(patchSetId.getId())) {
+          return res.files();
+        }
+
+        try {
+          return copy(res.files(), res.patchSetId(), resource,
+              userId);
+        } catch (IOException | PatchListNotAvailableException e) {
+          log.warn("Cannot copy patch review flags", e);
         }
       }
 
-      return r;
-    }
-
-    private List<String> scan(Account.Id userId, PatchSet.Id psId)
-        throws OrmException {
-      List<String> r = Lists.newArrayList();
-      for (AccountPatchReview w : db.get().accountPatchReviews()
-          .byReviewer(userId, psId)) {
-        r.add(w.getKey().getPatchKey().getFileName());
-      }
-      return r;
-    }
-
-    private List<Integer> reverseSortPatchSets(
-        RevisionResource resource) throws OrmException {
-      SortedSet<Integer> ids = Sets.newTreeSet();
-      for (PatchSet p : db.get().patchSets()
-          .byChange(resource.getChange().getId())) {
-        if (p.getPatchSetId() < resource.getPatchSet().getPatchSetId()) {
-          ids.add(p.getPatchSetId());
-        }
-      }
-
-      List<Integer> r = Lists.newArrayList(ids);
-      Collections.reverse(r);
-      return r;
+      return Collections.emptyList();
     }
 
     private List<String> copy(Set<String> paths, PatchSet.Id old,
@@ -260,16 +260,20 @@
           ObjectReader reader = git.newObjectReader();
           RevWalk rw = new RevWalk(reader);
           TreeWalk tw = new TreeWalk(reader)) {
-        PatchList oldList = patchListCache.get(
-            resource.getChange(),
-            db.get().patchSets().get(old));
+        Change change = resource.getChange();
+        PatchSet patchSet = psUtil.get(db.get(), resource.getNotes(), old);
+        if (patchSet == null) {
+          throw new PatchListNotAvailableException(
+              String.format(
+                  "patch set %s of change %s not found",
+                  old.get(), change.getId().get()));
+        }
 
-        PatchList curList = patchListCache.get(
-            resource.getChange(),
-            resource.getPatchSet());
+        PatchList oldList = patchListCache.get(change, patchSet);
+
+        PatchList curList = patchListCache.get(change, resource.getPatchSet());
 
         int sz = paths.size();
-        List<AccountPatchReview> inserts = Lists.newArrayListWithCapacity(sz);
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
 
         tw.setFilter(PathFilterGroup.createFromStrings(paths));
@@ -294,11 +298,6 @@
               && paths.contains(path)) {
             // File exists in previously reviewed oldList and in curList.
             // File content is identical.
-            inserts.add(new AccountPatchReview(
-                new Patch.Key(
-                    resource.getPatchSet().getId(),
-                    path),
-                  userId));
             pathList.add(path);
           } else if (op >= 0 && cp >= 0
               && tw.getRawMode(o) == 0 && tw.getRawMode(c) == 0
@@ -308,15 +307,11 @@
             // File was deleted in previously reviewed oldList and curList.
             // File exists in ancestor of oldList and curList.
             // File content is identical in ancestors.
-            inserts.add(new AccountPatchReview(
-                new Patch.Key(
-                    resource.getPatchSet().getId(),
-                    path),
-                  userId));
             pathList.add(path);
           }
         }
-        db.get().accountPatchReviews().insert(inserts);
+        accountPatchReviewStore.get()
+            .markReviewed(resource.getPatchSet().getId(), userId, pathList);
         return pathList;
       }
     }
@@ -325,5 +320,10 @@
       this.base = base;
       return this;
     }
+
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
new file mode 100644
index 0000000..0f0f5a6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.common.BlameInfo;
+import com.google.gerrit.extensions.common.RangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gitiles.blame.BlameCache;
+import com.google.gitiles.blame.Region;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class GetBlame implements RestReadView<FileResource> {
+
+  private final GitRepositoryManager repoManager;
+  private final BlameCache blameCache;
+  private final boolean allowBlame;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final AutoMerger autoMerger;
+
+  @Option(name = "--base", aliases = {"-b"},
+    usage = "whether to load the blame of the base revision (the direct"
+      + " parent of the change) instead of the change")
+  private boolean base;
+
+  @Inject
+  GetBlame(GitRepositoryManager repoManager,
+      BlameCache blameCache,
+      @GerritServerConfig Config cfg,
+      AutoMerger autoMerger) {
+    this.repoManager = repoManager;
+    this.blameCache = blameCache;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.autoMerger = autoMerger;
+    allowBlame = cfg.getBoolean("change", "allowBlame", true);
+  }
+
+  @Override
+  public Response<List<BlameInfo>> apply(FileResource resource)
+      throws RestApiException, OrmException, IOException,
+      InvalidChangeOperationException {
+    if (!allowBlame) {
+      throw new BadRequestException("blame is disabled");
+    }
+
+    Project.NameKey project = resource.getRevision().getChange().getProject();
+    try (Repository repository = repoManager.openRepository(project);
+        ObjectInserter ins = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(ins.newReader())) {
+      String refName = resource.getRevision().getEdit().isPresent()
+          ? resource.getRevision().getEdit().get().getRefName()
+          : resource.getRevision().getPatchSet().getRefName();
+
+      Ref ref = repository.findRef(refName);
+      if (ref == null) {
+        throw new ResourceNotFoundException("unknown ref " + refName);
+      }
+      ObjectId objectId = ref.getObjectId();
+      RevCommit revCommit = revWalk.parseCommit(objectId);
+      RevCommit[] parents = revCommit.getParents();
+
+      String path = resource.getPatchKey().getFileName();
+
+      List<BlameInfo> result;
+      if (!base) {
+        result = blame(revCommit, path, repository, revWalk);
+
+      } else if (parents.length == 0) {
+        throw new ResourceNotFoundException("Initial commit doesn't have base");
+
+      } else if (parents.length == 1) {
+        result = blame(parents[0], path, repository, revWalk);
+
+      } else if (parents.length == 2) {
+        ObjectId automerge = autoMerger.merge(repository, revWalk, ins,
+            revCommit, mergeStrategy);
+        result = blame(automerge, path, repository, revWalk);
+
+      } else {
+        throw new ResourceNotFoundException(
+            "Cannot generate blame for merge commit with more than 2 parents");
+      }
+
+      Response<List<BlameInfo>> r = Response.ok(result);
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
+    }
+  }
+
+  private List<BlameInfo> blame(ObjectId id, String path,
+      Repository repository, RevWalk revWalk) throws IOException {
+    ListMultimap<BlameInfo, RangeInfo> ranges = ArrayListMultimap.create();
+    List<BlameInfo> result = new ArrayList<>();
+    if (blameCache.findLastCommit(repository, id, path) == null) {
+      return result;
+    }
+
+    List<Region> blameRegions = blameCache.get(repository, id, path);
+    int from = 1;
+    for (Region region : blameRegions) {
+      RevCommit commit = revWalk.parseCommit(region.getSourceCommit());
+      BlameInfo blameInfo = toBlameInfo(commit, region.getSourceAuthor());
+      ranges.put(blameInfo, new RangeInfo(from, from + region.getCount() - 1));
+      from += region.getCount();
+    }
+
+    for (BlameInfo key : ranges.keySet()) {
+      key.ranges = ranges.get(key);
+      result.add(key);
+    }
+    return result;
+  }
+
+  private static BlameInfo toBlameInfo(RevCommit commit,
+      PersonIdent sourceAuthor) {
+    BlameInfo blameInfo = new BlameInfo();
+    blameInfo.author = sourceAuthor.getName();
+    blameInfo.id = commit.getName();
+    blameInfo.commitMsg = commit.getFullMessage();
+    blameInfo.time = commit.getCommitTime();
+    return blameInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
index 72f8c35..8c9a0ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
@@ -53,8 +53,7 @@
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
       CommitInfo info = json.create(ChangeJson.NO_OPTIONS)
-          .toCommit(rsrc.getControl(), rw, commit, addLinks);
-      info.commit = commit.name();
+          .toCommit(rsrc.getControl(), rw, commit, addLinks, true);
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
index 810a3a6..5a546f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
@@ -17,27 +17,44 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
 
 @Singleton
 public class GetContent implements RestReadView<FileResource> {
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final PatchSetUtil psUtil;
   private final FileContentUtil fileContentUtil;
-  private final ChangeUtil changeUtil;
 
   @Inject
-  GetContent(FileContentUtil fileContentUtil,
-      ChangeUtil changeUtil) {
+  GetContent(
+      Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      PatchSetUtil psUtil,
+      FileContentUtil fileContentUtil) {
+    this.db = db;
+    this.gitManager = gitManager;
+    this.psUtil = psUtil;
     this.fileContentUtil = fileContentUtil;
-    this.changeUtil = changeUtil;
   }
 
   @Override
@@ -46,7 +63,8 @@
       OrmException {
     String path = rsrc.getPatchKey().get();
     if (Patch.COMMIT_MSG.equals(path)) {
-      String msg = changeUtil.getMessage(rsrc.getRevision().getChange());
+      String msg = getMessage(
+          rsrc.getRevision().getChangeResource().getNotes());
       return BinaryResult.create(msg)
           .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
           .base64();
@@ -56,4 +74,22 @@
         ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
         path);
   }
+
+  private String getMessage(ChangeNotes notes)
+      throws NoSuchChangeException, OrmException, IOException {
+    Change.Id changeId = notes.getChangeId();
+    PatchSet ps = psUtil.current(db.get(), notes);
+    if (ps == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try (Repository git = gitManager.openRepository(notes.getProjectName());
+        RevWalk revWalk = new RevWalk(git)) {
+      RevCommit commit = revWalk.parseCommit(
+          ObjectId.fromString(ps.getRevision().get()));
+      return commit.getFullMessage();
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
index 509bbd4..48bd2f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
@@ -43,6 +43,7 @@
     delegate.addOption(ListChangesOption.DETAILED_LABELS);
     delegate.addOption(ListChangesOption.DETAILED_ACCOUNTS);
     delegate.addOption(ListChangesOption.MESSAGES);
+    delegate.addOption(ListChangesOption.REVIEWER_UPDATES);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 3dcc442..6e284bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.ReplaceEdit;
@@ -67,8 +69,6 @@
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
-import javax.inject.Inject;
-
 public class GetDiff implements RestReadView<FileResource> {
   private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
       Maps.immutableEnumMap(
@@ -89,8 +89,15 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  @Option(name = "--parent", metaVar = "parent-number")
+  int parentNum;
+
+  @Deprecated
   @Option(name = "--ignore-whitespace")
-  IgnoreWhitespace ignoreWhitespace = IgnoreWhitespace.NONE;
+  IgnoreWhitespace ignoreWhitespace;
+
+  @Option(name = "--whitespace")
+  Whitespace whitespace;
 
   @Option(name = "--context", handler = ContextOptionHandler.class)
   int context = DiffPreferencesInfo.DEFAULT_CONTEXT;
@@ -116,24 +123,46 @@
   public Response<DiffInfo> apply(FileResource resource)
       throws ResourceConflictException, ResourceNotFoundException,
       OrmException, AuthException, InvalidChangeOperationException, IOException {
+    DiffPreferencesInfo prefs = new DiffPreferencesInfo();
+    if (whitespace != null) {
+      prefs.ignoreWhitespace = whitespace;
+    } else if (ignoreWhitespace != null) {
+      prefs.ignoreWhitespace = ignoreWhitespace.whitespace;
+    } else {
+      prefs.ignoreWhitespace = Whitespace.IGNORE_LEADING_AND_TRAILING;
+    }
+    prefs.context = context;
+    prefs.intralineDifference = intraline;
+
+    PatchScriptFactory psf;
     PatchSet basePatchSet = null;
     if (base != null) {
       RevisionResource baseResource = revisions.parse(
           resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-    }
-    DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-    prefs.ignoreWhitespace = ignoreWhitespace.whitespace;
-    prefs.context = context;
-    prefs.intralineDifference = intraline;
-
-    try {
-      PatchScriptFactory psf = patchScriptFactoryFactory.create(
+      psf = patchScriptFactoryFactory.create(
           resource.getRevision().getControl(),
           resource.getPatchKey().getFileName(),
-          basePatchSet != null ? basePatchSet.getId() : null,
+          basePatchSet.getId(),
           resource.getPatchKey().getParentKey(),
           prefs);
+    } else if (parentNum > 0) {
+      psf = patchScriptFactoryFactory.create(
+          resource.getRevision().getControl(),
+          resource.getPatchKey().getFileName(),
+          parentNum - 1,
+          resource.getPatchKey().getParentKey(),
+          prefs);
+    } else {
+      psf = patchScriptFactoryFactory.create(
+          resource.getRevision().getControl(),
+          resource.getPatchKey().getFileName(),
+          null,
+          resource.getPatchKey().getParentKey(),
+          prefs);
+    }
+
+    try {
       psf.setLoadHistory(false);
       psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
@@ -153,8 +182,8 @@
           case INSERT:
           case REPLACE:
             List<Edit> internalEdit = edit instanceof ReplaceEdit
-              ? ((ReplaceEdit) edit).getInternalEdits()
-              : null;
+                ? ((ReplaceEdit) edit).getInternalEdits()
+                : null;
             content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit);
             break;
           case EMPTY:
@@ -243,9 +272,9 @@
       }
       return r;
     } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
+      throw new ResourceNotFoundException(e.getMessage(), e);
     } catch (LargeObjectException e) {
-      throw new ResourceConflictException(e.getMessage());
+      throw new ResourceConflictException(e.getMessage(), e);
     }
   }
 
@@ -261,6 +290,26 @@
     return this;
   }
 
+  public GetDiff setParent(int parentNum) {
+    this.parentNum = parentNum;
+    return this;
+  }
+
+  public GetDiff setContext(int context) {
+    this.context = context;
+    return this;
+  }
+
+  public GetDiff setIntraline(boolean intraline) {
+    this.intraline = intraline;
+    return this;
+  }
+
+  public GetDiff setWhitespace(Whitespace whitespace) {
+    this.whitespace = whitespace;
+    return this;
+  }
+
   private static class Content {
     final List<ContentEntry> lines;
     final SparseFileContent fileA;
@@ -368,6 +417,7 @@
     }
   }
 
+  @Deprecated
   enum IgnoreWhitespace {
     NONE(DiffPreferencesInfo.Whitespace.IGNORE_NONE),
     TRAILING(DiffPreferencesInfo.Whitespace.IGNORE_TRAILING),
@@ -376,7 +426,7 @@
 
     private final DiffPreferencesInfo.Whitespace whitespace;
 
-    private IgnoreWhitespace(DiffPreferencesInfo.Whitespace whitespace) {
+    IgnoreWhitespace(DiffPreferencesInfo.Whitespace whitespace) {
       this.whitespace = whitespace;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
index 3c4d79d..a13e7be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
@@ -167,7 +167,7 @@
 
   private static String fileName(RevWalk rw, RevCommit commit)
       throws IOException {
-    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 8);
+    AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
     return id.name() + ".diff";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
index 6aa1a47..12e4276 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -23,7 +23,9 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -45,14 +47,17 @@
 public class GetRelated implements RestReadView<RevisionResource> {
   private final Provider<ReviewDb> db;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final PatchSetUtil psUtil;
   private final RelatedChangesSorter sorter;
 
   @Inject
   GetRelated(Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
+      PatchSetUtil psUtil,
       RelatedChangesSorter sorter) {
     this.db = db;
     this.queryProvider = queryProvider;
+    this.psUtil = psUtil;
     this.sorter = sorter;
   }
 
@@ -66,7 +71,7 @@
 
   private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
       throws OrmException, IOException {
-    Set<String> groups = getAllGroups(rsrc.getChange().getId());
+    Set<String> groups = getAllGroups(rsrc.getNotes());
     if (groups.isEmpty()) {
       return Collections.emptyList();
     }
@@ -76,7 +81,8 @@
         .byProjectGroups(rsrc.getChange().getProject(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
-    } if (cds.size() == 1
+    }
+    if (cds.size() == 1
         && cds.get(0).getId().equals(rsrc.getChange().getId())) {
       return Collections.emptyList();
     }
@@ -109,13 +115,10 @@
     return result;
   }
 
-  private Set<String> getAllGroups(Change.Id changeId) throws OrmException {
+  private Set<String> getAllGroups(ChangeNotes notes) throws OrmException {
     Set<String> result = new HashSet<>();
-    for (PatchSet ps : db.get().patchSets().byChange(changeId)) {
-      List<String> groups = ps.getGroups();
-      if (groups != null) {
-        result.addAll(groups);
-      }
+    for (PatchSet ps : psUtil.byChange(db.get(), notes)) {
+      result.addAll(ps.getGroups());
     }
     return result;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
index c90b3bc..533468d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.ReviewerJson.ReviewerInfo;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index 6b08469..eae67a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -42,16 +42,19 @@
   private final Config config;
   private final Provider<ReviewDb> dbProvider;
   private final MergeSuperSet mergeSuperSet;
+  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   GetRevisionActions(
       ActionJson delegate,
       Provider<ReviewDb> dbProvider,
       MergeSuperSet mergeSuperSet,
+      ChangeResource.Factory changeResourceFactory,
       @GerritServerConfig Config config) {
     this.delegate = delegate;
     this.dbProvider = dbProvider;
     this.mergeSuperSet = mergeSuperSet;
+    this.changeResourceFactory = changeResourceFactory;
     this.config = config;
   }
 
@@ -68,10 +71,12 @@
       rsrc.getChangeResource().prepareETag(h, user);
       h.putBoolean(Submit.wholeTopicEnabled(config));
       ReviewDb db = dbProvider.get();
-      ChangeSet cs = mergeSuperSet.completeChangeSet(db, rsrc.getChange());
+      ChangeSet cs =
+          mergeSuperSet.completeChangeSet(db, rsrc.getChange(), user);
       for (ChangeData cd : cs.changes()) {
-        new ChangeResource(cd.changeControl()).prepareETag(h, user);
+        changeResourceFactory.create(cd.changeControl()).prepareETag(h, user);
       }
+      h.putBoolean(cs.furtherHiddenChanges());
     } catch (IOException | OrmException e) {
       throw new OrmRuntimeException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
index eb260f8..39fdf3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
@@ -49,19 +49,18 @@
       throws IllegalArgumentException {
     if (input == null) {
       return Collections.emptySet();
-    } else {
-      HashSet<String> result = new HashSet<>();
-      for (String hashtag : input) {
-        if (hashtag.contains(",")) {
-          throw new IllegalArgumentException("Hashtags may not contain commas");
-        }
-        hashtag = cleanupHashtag(hashtag);
-        if (!hashtag.isEmpty()) {
-          result.add(hashtag);
-        }
-      }
-      return result;
     }
+    HashSet<String> result = new HashSet<>();
+    for (String hashtag : input) {
+      if (hashtag.contains(",")) {
+        throw new IllegalArgumentException("Hashtags may not contain commas");
+      }
+      hashtag = cleanupHashtag(hashtag);
+      if (!hashtag.isEmpty()) {
+        result.add(hashtag);
+      }
+    }
+    return result;
   }
 
   private HashtagsUtil() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
index 61b9545..344cb44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.data.IncludedInDetail;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
-import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
@@ -39,7 +41,6 @@
 
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.Map;
 
 @Singleton
@@ -47,14 +48,17 @@
 
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager repoManager;
-  private final DynamicMap<ExternalIncludedIn> includedIn;
+  private final PatchSetUtil psUtil;
+  private final DynamicSet<ExternalIncludedIn> includedIn;
 
   @Inject
   IncludedIn(Provider<ReviewDb> db,
       GitRepositoryManager repoManager,
-      DynamicMap<ExternalIncludedIn> includedIn) {
+      PatchSetUtil psUtil,
+      DynamicSet<ExternalIncludedIn> includedIn) {
     this.db = db;
     this.repoManager = repoManager;
+    this.psUtil = psUtil;
     this.includedIn = includedIn;
   }
 
@@ -62,8 +66,7 @@
   public IncludedInInfo apply(ChangeResource rsrc) throws BadRequestException,
       ResourceConflictException, OrmException, IOException {
     ChangeControl ctl = rsrc.getControl();
-    PatchSet ps =
-        db.get().patchSets().get(ctl.getChange().currentPatchSetId());
+    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
     Project.NameKey project = ctl.getProject().getNameKey();
     try (Repository r = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(r)) {
@@ -77,14 +80,17 @@
         throw new ResourceConflictException(err.getMessage());
       }
 
-      IncludedInDetail d = IncludedInResolver.resolve(r, rw, rev);
-      Map<String, Collection<String>> external = new HashMap<>();
-      for (DynamicMap.Entry<ExternalIncludedIn> i : includedIn) {
-        external.put(i.getExportName(),
-            i.getProvider().get().getIncludedIn(
-                project.get(), rev.name(), d.getTags(), d.getBranches()));
+      IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
+      Multimap<String, String> external = ArrayListMultimap.create();
+      for (ExternalIncludedIn ext : includedIn) {
+        Multimap<String, String> extIncludedIns = ext.getIncludedIn(
+            project.get(), rev.name(), d.getTags(), d.getBranches());
+        if (extIncludedIns != null) {
+          external.putAll(extIncludedIns);
+        }
       }
-      return new IncludedInInfo(d, (!external.isEmpty() ? external : null));
+      return new IncludedInInfo(d,
+          (!external.isEmpty() ? external.asMap() : null));
     }
   }
 
@@ -93,7 +99,7 @@
     Collection<String> tags;
     Map<String, Collection<String>> external;
 
-    IncludedInInfo(IncludedInDetail in, Map<String, Collection<String>> e) {
+    IncludedInInfo(IncludedInResolver.Result in, Map<String, Collection<String>> e) {
       branches = in.getBranches();
       tags = in.getTags();
       external = e;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
index fcac76d..0c3ecd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -17,8 +17,6 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.IncludedInDetail;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -36,6 +34,8 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Set;
 
@@ -47,7 +47,7 @@
   private static final Logger log = LoggerFactory
       .getLogger(IncludedInResolver.class);
 
-  public static IncludedInDetail resolve(final Repository repo,
+  public static Result resolve(final Repository repo,
       final RevWalk rw, final RevCommit commit) throws IOException {
     RevFlag flag = newFlag(rw);
     try {
@@ -87,7 +87,7 @@
     this.containsTarget = containsTarget;
   }
 
-  private IncludedInDetail resolve() throws IOException {
+  private Result resolve() throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
     Collection<Ref> tags = refDb.getRefs(Constants.R_TAGS).values();
     Collection<Ref> branches = refDb.getRefs(Constants.R_HEADS).values();
@@ -98,7 +98,7 @@
     parseCommits(allTagsAndBranches);
     Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
 
-    IncludedInDetail detail = new IncludedInDetail();
+    Result detail = new Result();
     detail
         .setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
     detail.setTags(getMatchingRefNames(allMatchingTagsAndBranches, tags));
@@ -108,9 +108,10 @@
 
   private boolean includedInOne(final Collection<Ref> refs) throws IOException {
     parseCommits(refs);
-    List<RevCommit> before = Lists.newLinkedList();
-    List<RevCommit> after = Lists.newLinkedList();
+    List<RevCommit> before = new LinkedList<>();
+    List<RevCommit> after = new LinkedList<>();
     partition(before, after);
+    rw.reset();
     // It is highly likely that the target is reachable from the "after" set
     // Within the "before" set we are trying to handle cases arising from clock skew
     return !includedIn(after, 1).isEmpty() || !includedIn(before, 1).isEmpty();
@@ -121,7 +122,7 @@
    */
   private Set<String> includedIn(final Collection<RevCommit> tips, int limit)
       throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    Set<String> result = Sets.newHashSet();
+    Set<String> result = new HashSet<>();
     for (RevCommit tip : tips) {
       boolean commitFound = false;
       rw.resetRetain(RevFlag.UNINTERESTING, containsTarget);
@@ -228,4 +229,30 @@
       }
     });
   }
+
+  public static class Result {
+    private List<String> branches;
+    private List<String> tags;
+
+    public Result() {
+    }
+
+    public void setBranches(final List<String> b) {
+      Collections.sort(b);
+      branches = b;
+    }
+
+    public List<String> getBranches() {
+      return branches;
+    }
+
+    public void setTags(final List<String> t) {
+      Collections.sort(t);
+      tags = t;
+    }
+
+    public List<String> getTags() {
+      return tags;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index a0be718..44a9975 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -19,8 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.Index.Input;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -44,7 +45,7 @@
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws IOException, AuthException {
+      throws IOException, AuthException, OrmException {
     ChangeControl ctl = rsrc.getControl();
     if (!ctl.isOwner()
         && !ctl.getUser().getCapabilities().canMaintainServer()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index ca5a55b..ccbd552 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.change.ReviewerJson.ReviewerInfo;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -48,10 +48,10 @@
 
   @Override
   public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
-    Map<Account.Id, ReviewerResource> reviewers = Maps.newLinkedHashMap();
+    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId
-        : approvalsUtil.getReviewers(db, rsrc.getNotes()).values()) {
+        : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
       if (!reviewers.containsKey(accountId)) {
         reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
index 547a500..93c4ac3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -24,11 +23,10 @@
 
 /** Cache for mergeability of commits into destination branches. */
 public interface MergeabilityCache {
-  public static class NotImplemented implements MergeabilityCache {
+  class NotImplemented implements MergeabilityCache {
     @Override
     public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-        String mergeStrategy, Branch.NameKey dest, Repository repo,
-        ReviewDb db) {
+        String mergeStrategy, Branch.NameKey dest, Repository repo) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
 
@@ -39,9 +37,9 @@
     }
   }
 
-  public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-      String mergeStrategy, Branch.NameKey dest, Repository repo, ReviewDb db);
+  boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
+      String mergeStrategy, Branch.NameKey dest, Repository repo);
 
-  public Boolean getIfPresent(ObjectId commit, Ref intoRef,
+  Boolean getIfPresent(ObjectId commit, Ref intoRef,
       SubmitType submitType, String mergeStrategy);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 0f55e58..62d75aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -26,33 +26,24 @@
 import com.google.common.cache.Weigher;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.ImmutableBiMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.strategy.SubmitStrategyFactory;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -189,13 +180,11 @@
     private final EntryKey key;
     private final Branch.NameKey dest;
     private final Repository repo;
-    private final ReviewDb db;
 
-    Loader(EntryKey key, Branch.NameKey dest, Repository repo, ReviewDb db) {
+    Loader(EntryKey key, Branch.NameKey dest, Repository repo) {
       this.key = key;
       this.dest = dest;
       this.repo = repo;
-      this.db = db;
     }
 
     @Override
@@ -204,43 +193,14 @@
       if (key.into.equals(ObjectId.zeroId())) {
         return true; // Assume yes on new branch.
       }
-      RefDatabase refDatabase = repo.getRefDatabase();
-      Iterable<Ref> refs = Iterables.concat(
-          refDatabase.getRefs(Constants.R_HEADS).values(),
-          refDatabase.getRefs(Constants.R_TAGS).values());
       try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        RevFlag canMerge = rw.newFlag("CAN_MERGE");
-        CodeReviewCommit rev = rw.parseCommit(key.commit);
-        rev.add(canMerge);
-        CodeReviewCommit tip = rw.parseCommit(key.into);
-        Set<RevCommit> accepted = alreadyAccepted(rw, refs);
-        accepted.add(tip);
-        accepted.addAll(Arrays.asList(rev.getParents()));
-        return submitStrategyFactory.create(
-            key.submitType,
-            db,
-            repo,
-            rw,
-            null /*inserter*/,
-            canMerge,
-            accepted,
-            dest,
-            null).dryRun(tip, rev);
+        Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
+        accepted.add(rw.parseCommit(key.into));
+        accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
+        return submitDryRun.run(
+            key.submitType, repo, rw, dest, key.into, key.commit, accepted);
       }
     }
-
-    private Set<RevCommit> alreadyAccepted(RevWalk rw, Iterable<Ref> refs)
-        throws MissingObjectException, IOException {
-      Set<RevCommit> accepted = Sets.newHashSet();
-      for (Ref r : refs) {
-        try {
-          accepted.add(rw.parseCommit(r.getObjectId()));
-        } catch (IncorrectObjectTypeException nonCommit) {
-          // Not a commit? Skip over it.
-        }
-      }
-      return accepted;
-    }
   }
 
   public static class MergeabilityWeigher
@@ -252,24 +212,24 @@
     }
   }
 
-  private final SubmitStrategyFactory submitStrategyFactory;
+  private final SubmitDryRun submitDryRun;
   private final Cache<EntryKey, Boolean> cache;
 
   @Inject
   MergeabilityCacheImpl(
-      SubmitStrategyFactory submitStrategyFactory,
+      SubmitDryRun submitDryRun,
       @Named(CACHE_NAME) Cache<EntryKey, Boolean> cache) {
-    this.submitStrategyFactory = submitStrategyFactory;
+    this.submitDryRun = submitDryRun;
     this.cache = cache;
   }
 
   @Override
   public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-      String mergeStrategy, Branch.NameKey dest, Repository repo, ReviewDb db) {
+      String mergeStrategy, Branch.NameKey dest, Repository repo) {
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
     try {
-      return cache.get(key, new Loader(key, dest, repo, db));
+      return cache.get(key, new Loader(key, dest, repo));
     } catch (ExecutionException | UncheckedExecutionException e) {
       log.error(String.format("Error checking mergeability of %s into %s (%s)",
             key.commit.name(), key.into.name(), key.submitType.name()),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index 007c233..7796d18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -24,11 +24,11 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
@@ -47,6 +47,7 @@
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Objects;
 
@@ -101,34 +102,17 @@
     }
 
     ChangeData cd = changeDataFactory.create(db.get(), resource.getControl());
-    SubmitTypeRecord rec = new SubmitRuleEvaluator(cd)
-        .setPatchSet(ps)
-        .getSubmitType();
-    if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new OrmException("Submit type rule failed: " + rec);
-    }
-    result.submitType = rec.type;
+    result.submitType = getSubmitType(cd, ps);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
       ObjectId commit = toId(ps);
-      if (commit == null) {
-        result.mergeable = false;
-        return result;
-      }
-
       Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
       ProjectState projectState = projectCache.get(change.getProject());
       String strategy = mergeUtilFactory.create(projectState)
           .mergeStrategyName();
-      Boolean old =
-          cache.getIfPresent(commit, ref, result.submitType, strategy);
-
-      if (old == null) {
-        result.mergeable = refresh(change, commit, ref, result.submitType,
-            strategy, git, old);
-      } else {
-        result.mergeable = old;
-      }
+      result.strategy = strategy;
+      result.mergeable =
+          isMergable(git, change, commit, ref, result.submitType, strategy);
 
       if (otherBranches) {
         result.mergeableInto = new ArrayList<>();
@@ -143,7 +127,7 @@
               continue;
             }
             if (cache.get(commit, other, SubmitType.CHERRY_PICK, strategy,
-                change.getDest(), git, db.get())) {
+                change.getDest(), git)) {
               result.mergeableInto.add(other.getName().substring(prefixLen));
             }
           }
@@ -153,6 +137,31 @@
     return result;
   }
 
+  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet)
+      throws OrmException {
+    SubmitTypeRecord rec =
+        new SubmitRuleEvaluator(cd).setPatchSet(patchSet).getSubmitType();
+    if (rec.status != SubmitTypeRecord.Status.OK) {
+      throw new OrmException("Submit type rule failed: " + rec);
+    }
+    return rec.type;
+  }
+
+  private boolean isMergable(Repository git, Change change, ObjectId commit,
+      Ref ref, SubmitType submitType, String strategy)
+          throws IOException, OrmException {
+    if (commit == null) {
+      return false;
+    }
+
+    Boolean old = cache.getIfPresent(commit, ref, submitType, strategy);
+    if (old != null) {
+      return old;
+    }
+    return refresh(change, commit, ref, submitType,
+          strategy, git, old);
+  }
+
   private static ObjectId toId(PatchSet ps) {
     try {
       return ObjectId.fromString(ps.getRevision().get());
@@ -166,12 +175,22 @@
       final Ref ref, SubmitType type, String strategy, Repository git,
       Boolean old) throws OrmException, IOException {
     final boolean mergeable =
-        cache.get(commit, ref, type, strategy, change.getDest(), git, db.get());
+        cache.get(commit, ref, type, strategy, change.getDest(), git);
     if (!Objects.equals(mergeable, old)) {
-      // TODO(dborowitz): Include cache info in ETag somehow instead.
-      ChangeUtil.bumpRowVersionNotLastUpdatedOn(change.getId(), db.get());
+      invalidateETag(change.getId(), db.get());
       indexer.index(db.get(), change);
     }
     return mergeable;
   }
+
+  private static void invalidateETag(Change.Id id, ReviewDb db)
+      throws OrmException {
+    // Empty update of Change to bump rowVersion, changing its ETag.
+    // TODO(dborowitz): Include cache info in ETag somehow instead.
+    db = ReviewDbUtil.unwrapDb(db);
+    Change c = db.changes().get(id);
+    if (c != null) {
+      db.changes().update(Collections.singleton(c));
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index be93c29..6de7deb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -37,6 +38,7 @@
     bind(DraftComments.class);
     bind(Comments.class);
     bind(Files.class);
+    bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
@@ -45,6 +47,7 @@
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
     DynamicMap.mapOf(binder(), REVISION_KIND);
     DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
+    DynamicMap.mapOf(binder(), VOTE_KIND);
 
     get(CHANGE_KIND).to(GetChange.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
@@ -67,12 +70,17 @@
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
     post(CHANGE_KIND, "index").to(Index.class);
+    post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
+    post(CHANGE_KIND, "move").to(Move.class);
 
     post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    child(REVIEWER_KIND, "votes").to(Votes.class);
+    delete(VOTE_KIND).to(DeleteVote.class);
+    post(VOTE_KIND, "delete").to(DeleteVote.class);
 
     child(CHANGE_KIND, "revisions").to(Revisions.class);
     get(REVISION_KIND, "actions").to(GetRevisionActions.class);
@@ -107,6 +115,7 @@
     get(FILE_KIND, "content").to(GetContent.class);
     get(FILE_KIND, "download").to(DownloadContent.class);
     get(FILE_KIND, "diff").to(GetDiff.class);
+    get(FILE_KIND, "blame").to(GetBlame.class);
 
     child(CHANGE_KIND, "edit").to(ChangeEdits.class);
     delete(CHANGE_KIND, "edit").to(DeleteChangeEdit.class);
@@ -128,5 +137,6 @@
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(ChangeResource.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
new file mode 100644
index 0000000..2139ec4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -0,0 +1,196 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+@Singleton
+public class Move implements RestModifyView<ChangeResource, MoveInput> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeJson.Factory json;
+  private final GitRepositoryManager repoManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeMessagesUtil cmUtil;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  Move(Provider<ReviewDb> dbProvider,
+      ChangeJson.Factory json,
+      GitRepositoryManager repoManager,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetUtil psUtil) {
+    this.dbProvider = dbProvider;
+    this.json = json;
+    this.repoManager = repoManager;
+    this.queryProvider = queryProvider;
+    this.cmUtil = cmUtil;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public ChangeInfo apply(ChangeResource req, MoveInput input)
+      throws RestApiException, OrmException, UpdateException {
+    ChangeControl control = req.getControl();
+    input.destinationBranch = RefNames.fullName(input.destinationBranch);
+    if (!control.canMoveTo(input.destinationBranch, dbProvider.get())) {
+      throw new AuthException("Move not permitted");
+    }
+
+    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
+        req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
+      u.addOp(req.getChange().getId(), new Op(control, input));
+      u.execute();
+    }
+
+    return json.create(ChangeJson.NO_OPTIONS).format(req.getChange());
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private final MoveInput input;
+    private final IdentifiedUser caller;
+
+    private Change change;
+    private Branch.NameKey newDestKey;
+
+    Op(ChangeControl ctl, MoveInput input) {
+      this.input = input;
+      this.caller = ctl.getUser().asIdentifiedUser();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException,
+        ResourceConflictException, RepositoryNotFoundException, IOException {
+      change = ctx.getChange();
+      if (change.getStatus() != Status.NEW
+          && change.getStatus() != Status.DRAFT) {
+        throw new ResourceConflictException("Change is " + status(change));
+      }
+
+      Project.NameKey projectKey = change.getProject();
+      newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
+      Branch.NameKey changePrevDest = change.getDest();
+      if (changePrevDest.equals(newDestKey)) {
+        throw new ResourceConflictException(
+            "Change is already destined for the specified branch");
+      }
+
+      final PatchSet.Id patchSetId = change.currentPatchSetId();
+      try (Repository repo = repoManager.openRepository(projectKey);
+          RevWalk revWalk = new RevWalk(repo)) {
+        RevCommit currPatchsetRevCommit = revWalk.parseCommit(
+            ObjectId.fromString(psUtil.current(ctx.getDb(), ctx.getNotes())
+                .getRevision().get()));
+        if (currPatchsetRevCommit.getParentCount() > 1) {
+          throw new ResourceConflictException("Merge commit cannot be moved");
+        }
+
+        ObjectId refId = repo.resolve(input.destinationBranch);
+        // Check if destination ref exists in project repo
+        if (refId == null) {
+          throw new ResourceConflictException(
+              "Destination " + input.destinationBranch + " not found in the project");
+        }
+        RevCommit refCommit = revWalk.parseCommit(refId);
+        if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) {
+          throw new ResourceConflictException(
+              "Current patchset revision is reachable from tip of "
+                  + input.destinationBranch);
+        }
+      }
+
+      Change.Key changeKey = change.getKey();
+      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey))
+          .isEmpty()) {
+        throw new ResourceConflictException(
+            "Destination " + newDestKey.getShortName()
+                + " has a different change with same change key " + changeKey);
+      }
+
+      if (!change.currentPatchSetId().equals(patchSetId)) {
+        throw new ResourceConflictException("Patch set is not current");
+      }
+
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      update.setBranch(newDestKey.get());
+      change.setDest(newDestKey);
+
+      StringBuilder msgBuf = new StringBuilder();
+      msgBuf.append("Change destination moved from ");
+      msgBuf.append(changePrevDest.getShortName());
+      msgBuf.append(" to ");
+      msgBuf.append(newDestKey.getShortName());
+      if (!Strings.isNullOrEmpty(input.message)) {
+        msgBuf.append("\n\n");
+        msgBuf.append(input.message);
+      }
+      ChangeMessage cmsg = new ChangeMessage(
+          new ChangeMessage.Key(change.getId(),
+              ChangeUtil.messageUUID(ctx.getDb())),
+          caller.getAccountId(), ctx.getWhen(), change.currentPatchSetId());
+      cmsg.setMessage(msgBuf.toString());
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+
+      return true;
+    }
+  }
+
+  private static String status(Change change) {
+    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 4445343d..f41d41d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -14,43 +14,40 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
-import com.google.common.collect.SetMultimap;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ChangeModifiedException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -64,69 +61,77 @@
 
 import java.io.IOException;
 import java.util.Collections;
+import java.util.List;
 
 public class PatchSetInserter extends BatchUpdate.Op {
   private static final Logger log =
       LoggerFactory.getLogger(PatchSetInserter.class);
 
-  public static interface Factory {
-    PatchSetInserter create(RefControl refControl, PatchSet.Id psId,
+  public interface Factory {
+    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId,
         RevCommit commit);
   }
 
   // Injected fields.
-  private final ChangeHooks hooks;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ApprovalCopier approvalCopier;
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
   private final RevCommit commit;
-  private final RefControl refControl;
+  // Read prior to running the batch update, so must only be used during
+  // updateRepo; updateChange and later must use the control from the
+  // ChangeContext.
+  private final ChangeControl origCtl;
 
   // Fields exposed as setters.
   private SshInfo sshInfo;
   private String message;
   private CommitValidators.Policy validatePolicy =
       CommitValidators.Policy.GERRIT;
+  private boolean checkAddPatchSetPermission = true;
   private boolean draft;
-  private Iterable<String> groups;
-  private boolean runHooks = true;
+  private List<String> groups = Collections.emptyList();
+  private boolean fireRevisionCreated = true;
   private boolean sendMail = true;
-  private Account.Id uploader;
   private boolean allowClosed;
+  private boolean copyApprovals = true;
 
   // Fields set during some phase of BatchUpdate.Op.
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
   private ChangeMessage changeMessage;
-  private SetMultimap<ReviewerState, Account.Id> oldReviewers;
+  private ReviewerSet oldReviewers;
 
   @AssistedInject
-  public PatchSetInserter(ChangeHooks hooks,
-      ApprovalsUtil approvalsUtil,
+  public PatchSetInserter(ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
       CommitValidators.Factory commitValidatorsFactory,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
-      @Assisted RefControl refControl,
+      PatchSetUtil psUtil,
+      RevisionCreated revisionCreated,
+      @Assisted ChangeControl ctl,
       @Assisted PatchSet.Id psId,
       @Assisted RevCommit commit) {
-    this.hooks = hooks;
     this.approvalsUtil = approvalsUtil;
     this.approvalCopier = approvalCopier;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.replacePatchSetFactory = replacePatchSetFactory;
+    this.psUtil = psUtil;
+    this.revisionCreated = revisionCreated;
 
-    this.refControl = refControl;
+    this.origCtl = ctl;
     this.psId = psId;
     this.commit = commit;
   }
@@ -150,18 +155,25 @@
     return this;
   }
 
+  public PatchSetInserter setCheckAddPatchSetPermission(
+      boolean checkAddPatchSetPermission) {
+    this.checkAddPatchSetPermission = checkAddPatchSetPermission;
+    return this;
+  }
+
   public PatchSetInserter setDraft(boolean draft) {
     this.draft = draft;
     return this;
   }
 
-  public PatchSetInserter setGroups(Iterable<String> groups) {
+  public PatchSetInserter setGroups(List<String> groups) {
+    checkNotNull(groups, "groups may not be null");
     this.groups = groups;
     return this;
   }
 
-  public PatchSetInserter setRunHooks(boolean runHooks) {
-    this.runHooks = runHooks;
+  public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
+    this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
@@ -175,8 +187,8 @@
     return this;
   }
 
-  public PatchSetInserter setUploader(Account.Id uploader) {
-    this.uploader = uploader;
+  public PatchSetInserter setCopyApprovals(boolean copyApprovals) {
+    this.copyApprovals = copyApprovals;
     return this;
   }
 
@@ -192,40 +204,38 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws ResourceConflictException, IOException {
+      throws AuthException, ResourceConflictException, IOException, OrmException {
     init();
     validate(ctx);
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
     ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(),
         commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
   }
 
   @Override
-  public void updateChange(ChangeContext ctx) throws OrmException,
-      InvalidChangeOperationException {
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, OrmException, IOException {
     ReviewDb db = ctx.getDb();
-    ChangeControl ctl = ctx.getChangeControl();
+    ChangeControl ctl = ctx.getControl();
 
     change = ctx.getChange();
-    Change.Id id = change.getId();
-    final PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.setSubjectForCommit("Create patch set " + psId.get());
+
     if (!change.getStatus().isOpen() && !allowClosed) {
-      throw new InvalidChangeOperationException(String.format(
-          "Change %s is closed", change.getId()));
+      throw new ResourceConflictException(String.format(
+          "Cannot create new patch set of change %s because it is %s",
+          change.getId(), change.getStatus().name().toLowerCase()));
     }
 
-    patchSet = new PatchSet(psId);
-    patchSet.setCreatedOn(ctx.getWhen());
-    patchSet.setUploader(firstNonNull(uploader, ctl.getChange().getOwner()));
-    patchSet.setRevision(new RevId(commit.name()));
-    patchSet.setDraft(draft);
-
-    if (groups != null) {
-      patchSet.setGroups(groups);
-    } else {
-      patchSet.setGroups(GroupCollector.getCurrentGroups(db, change));
+    List<String> newGroups = groups;
+    if (newGroups.isEmpty()) {
+      PatchSet prevPs = psUtil.current(db, ctx.getNotes());
+      if (prevPs != null) {
+        newGroups = prevPs.getGroups();
+      }
     }
-    db.patchSets().insert(Collections.singleton(patchSet));
+    patchSet = psUtil.insert(db, ctx.getRevWalk(), ctx.getUpdate(psId),
+        psId, commit, draft, newGroups, null);
 
     if (sendMail) {
       oldReviewers = approvalsUtil.getReviewers(db, ctl.getNotes());
@@ -233,40 +243,23 @@
 
     if (message != null) {
       changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctl.getChange().getId(), ChangeUtil.messageUUID(db)),
-          ctx.getUser().getAccountId(), ctx.getWhen(), patchSet.getId());
+          new ChangeMessage.Key(ctl.getId(), ChangeUtil.messageUUID(db)),
+          ctx.getAccountId(), ctx.getWhen(), patchSet.getId());
       changeMessage.setMessage(message);
     }
 
-    // TODO(dborowitz): Throw ResourceConflictException instead of using
-    // AtomicUpdate.
-    change = db.changes().atomicUpdate(id, new AtomicUpdate<Change>() {
-      @Override
-      public Change update(Change change) {
-        if (change.getStatus().isClosed() && !allowClosed) {
-          return null;
-        }
-        if (!change.currentPatchSetId().equals(currentPatchSetId)) {
-          return null;
-        }
-        if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
-          change.setStatus(Change.Status.NEW);
-        }
-        change.setCurrentPatchSet(patchSetInfo);
-        ChangeUtil.updated(change);
-        return change;
-      }
-    });
-    if (change == null) {
-      throw new ChangeModifiedException(String.format(
-          "Change %s was modified", id));
+    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
+      change.setStatus(Change.Status.NEW);
     }
-
-    approvalCopier.copy(db, ctl, patchSet);
+    change.setCurrentPatchSet(patchSetInfo);
+    if (copyApprovals) {
+      approvalCopier.copy(db, ctl, patchSet);
+    }
     if (changeMessage != null) {
-      cmUtil.addChangeMessage(db, ctx.getChangeUpdate(), changeMessage);
+      cmUtil.addChangeMessage(db, update, changeMessage);
     }
+    return true;
   }
 
   @Override
@@ -274,12 +267,12 @@
     if (sendMail) {
       try {
         ReplacePatchSetSender cm = replacePatchSetFactory.create(
-            change.getId());
-        cm.setFrom(ctx.getUser().getAccountId());
+            ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
         cm.setPatchSet(patchSet, patchSetInfo);
-        cm.setChangeMessage(changeMessage);
-        cm.addReviewers(oldReviewers.get(ReviewerState.REVIEWER));
-        cm.addExtraCC(oldReviewers.get(ReviewerState.CC));
+        cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        cm.addReviewers(oldReviewers.byState(REVIEWER));
+        cm.addExtraCC(oldReviewers.byState(CC));
         cm.send();
       } catch (Exception err) {
         log.error("Cannot send email for new patch set on change "
@@ -287,8 +280,12 @@
       }
     }
 
-    if (runHooks) {
-      hooks.doPatchsetCreatedHook(change, patchSet, ctx.getDb());
+    NotifyHandling notify = sendMail
+        ? NotifyHandling.ALL
+        : NotifyHandling.NONE;
+    if (fireRevisionCreated) {
+      revisionCreated.fire(change, patchSet, ctx.getAccount(),
+          ctx.getWhen(), notify);
     }
   }
 
@@ -299,9 +296,14 @@
   }
 
   private void validate(RepoContext ctx)
-      throws ResourceConflictException, IOException {
+      throws AuthException, ResourceConflictException, IOException,
+      OrmException {
     CommitValidators cv = commitValidatorsFactory.create(
-        refControl, sshInfo, ctx.getRepository());
+        origCtl.getRefControl(), sshInfo, ctx.getRepository());
+
+    if (checkAddPatchSetPermission && !origCtl.canAddPatchSet(ctx.getDb())) {
+      throw new AuthException("cannot add patch set");
+    }
 
     String refName = getPatchSetId().toRefName();
     CommitReceivedEvent event = new CommitReceivedEvent(
@@ -309,8 +311,9 @@
             ObjectId.zeroId(),
             commit.getId(),
             refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-        refControl.getProjectControl().getProject(), refControl.getRefName(),
-        commit, ctx.getUser().asIdentifiedUser());
+        origCtl.getProjectControl().getProject(),
+        origCtl.getRefControl().getRefName(),
+        commit, ctx.getIdentifiedUser());
 
     try {
       switch (validatePolicy) {
@@ -326,7 +329,7 @@
         break;
       }
     } catch (CommitValidationException e) {
-      throw new ResourceConflictException(e.getMessage());
+      throw new ResourceConflictException(e.getFullMessage());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index a3fc2e1..6d7720d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -52,7 +52,7 @@
           req.getChange().getProject(), req.getControl().getUser(),
           TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
-      bu.addOp(req.getChange().getId(), op);
+      bu.addOp(req.getId(), op);
       bu.execute();
       return Response.<ImmutableSortedSet<String>> ok(op.getUpdatedHashtags());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 7a575c8..6e2d51a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -17,34 +17,45 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.change.PutDraftComment.side;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.CommentRange;
@@ -58,11 +69,14 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
@@ -76,8 +90,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -90,10 +106,6 @@
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final Logger log = LoggerFactory.getLogger(PostReview.class);
 
-  static class Output {
-    Map<String, Short> labels;
-  }
-
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangesCollection changes;
@@ -101,10 +113,12 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchLineCommentsUtil plcUtil;
+  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountsCollection accounts;
   private final EmailReviewComments.Factory email;
-  private final ChangeHooks hooks;
+  private final CommentAdded commentAdded;
+  private final PostReviewers postReviewers;
 
   @Inject
   PostReview(Provider<ReviewDb> db,
@@ -114,31 +128,38 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountsCollection accounts,
       EmailReviewComments.Factory email,
-      ChangeHooks hooks) {
+      CommentAdded commentAdded,
+      PostReviewers postReviewers) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
     this.changeDataFactory = changeDataFactory;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.accounts = accounts;
     this.email = email;
-    this.hooks = hooks;
+    this.commentAdded = commentAdded;
+    this.postReviewers = postReviewers;
   }
 
   @Override
-  public Output apply(RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException {
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
+      throws RestApiException, UpdateException, OrmException, IOException {
     return apply(revision, input, TimeUtil.nowTs());
   }
 
-  public Output apply(RevisionResource revision, ReviewInput input,
-      Timestamp ts) throws RestApiException, UpdateException, OrmException {
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input,
+      Timestamp ts)
+      throws RestApiException, UpdateException, OrmException, IOException {
+    // Respect timestamp, but truncate at change created-on time.
+    ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
       throw new ResourceConflictException("cannot post review on edit");
     }
@@ -156,16 +177,72 @@
       input.notify = NotifyHandling.NONE;
     }
 
+    Map<String, AddReviewerResult> reviewerJsonResults = null;
+    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
+    boolean hasError = false;
+    boolean confirm = false;
+    if (input.reviewers != null) {
+      reviewerJsonResults = Maps.newHashMap();
+      for (AddReviewerInput reviewerInput : input.reviewers) {
+        // Prevent notifications because setting reviewers is batched.
+        reviewerInput.notify = NotifyHandling.NONE;
+
+        PostReviewers.Addition result = postReviewers.prepareApplication(
+            revision.getChangeResource(), reviewerInput);
+        reviewerJsonResults.put(reviewerInput.reviewer, result.result);
+        if (result.result.error != null) {
+          hasError = true;
+          continue;
+        }
+        if (result.result.confirm != null) {
+          confirm = true;
+          continue;
+        }
+        reviewerResults.add(result);
+      }
+    }
+
+    ReviewResult output = new ReviewResult();
+    output.reviewers = reviewerJsonResults;
+    if (hasError || confirm) {
+      return Response.withStatusCode(SC_BAD_REQUEST, output);
+    }
+    output.labels = input.labels;
+
     try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
           revision.getChange().getProject(), revision.getUser(), ts)) {
+      // Apply reviewer changes first. Revision emails should be sent to the
+      // updated set of reviewers.
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        bu.addOp(revision.getChange().getId(), reviewerResult.op);
+      }
       bu.addOp(
           revision.getChange().getId(),
           new Op(revision.getPatchSet().getId(), input));
       bu.execute();
+
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        reviewerResult.gatherResults();
+      }
+
+      emailReviewers(revision.getChange(), reviewerResults, input.notify);
     }
-    Output output = new Output();
-    output.labels = input.labels;
-    return output;
+
+    return Response.ok(output);
+  }
+
+  private void emailReviewers(Change change,
+      List<PostReviewers.Addition> reviewerAdditions, NotifyHandling notify) {
+    List<Account.Id> to = new ArrayList<>();
+    List<Account.Id> cc = new ArrayList<>();
+    for (PostReviewers.Addition addition : reviewerAdditions) {
+      if (addition.op.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.op.reviewers.keySet());
+      } else if (addition.op.state == ReviewerState.CC) {
+        cc.addAll(addition.op.reviewers.keySet());
+      }
+    }
+    postReviewers.emailReviewers(change, to, cc, notify);
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
@@ -220,10 +297,9 @@
         if (strict) {
           throw new BadRequestException(String.format(
               "label \"%s\" is not a configured label", ent.getKey()));
-        } else {
-          itr.remove();
-          continue;
         }
+        itr.remove();
+        continue;
       }
 
       if (ent.getValue() == null || ent.getValue() == 0) {
@@ -237,10 +313,9 @@
           throw new BadRequestException(String.format(
               "label \"%s\": %d is not a valid value",
               ent.getKey(), ent.getValue()));
-        } else {
-          itr.remove();
-          continue;
         }
+        itr.remove();
+        continue;
       }
 
       String name = lt.getName();
@@ -336,12 +411,13 @@
     private final ReviewInput in;
 
     private IdentifiedUser user;
-    private Change change;
+    private ChangeNotes notes;
     private PatchSet ps;
     private ChangeMessage message;
     private List<PatchLineComment> comments = new ArrayList<>();
     private List<String> labelDelta = new ArrayList<>();
-    private Map<String, Short> categories = new HashMap<>();
+    private Map<String, Short> approvals = new HashMap<>();
+    private Map<String, Short> oldApprovals = new HashMap<>();
 
     private Op(PatchSet.Id psId, ReviewInput in) {
       this.psId = psId;
@@ -349,21 +425,16 @@
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException {
-      user = ctx.getUser().asIdentifiedUser();
-      change = ctx.getChange();
-      if (change.getLastUpdatedOn().before(ctx.getWhen())) {
-        change.setLastUpdatedOn(ctx.getWhen());
-      }
-      ps = ctx.getDb().patchSets().get(psId);
-      ctx.getChangeUpdate().setPatchSetId(psId);
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, ResourceConflictException {
+      user = ctx.getIdentifiedUser();
+      notes = ctx.getNotes();
+      ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       boolean dirty = false;
       dirty |= insertComments(ctx);
       dirty |= updateLabels(ctx);
       dirty |= insertMessage(ctx);
-      if (dirty) {
-        ctx.getDb().changes().update(Collections.singleton(change));
-      }
+      return dirty;
     }
 
     @Override
@@ -374,18 +445,15 @@
       if (in.notify.compareTo(NotifyHandling.NONE) > 0) {
         email.create(
             in.notify,
-            change,
+            notes,
             ps,
             user,
             message,
             comments).sendAsync();
       }
-      try {
-        hooks.doCommentAddedHook(change, user.getAccount(), ps,
-            message.getMessage(), categories, ctx.getDb());
-      } catch (OrmException e) {
-        log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
-      }
+      commentAdded.fire(
+          notes.getChange(), ps, user.getAccount(), message.getMessage(),
+          approvals, oldApprovals, ctx.getWhen());
     }
 
     private boolean insertComments(ChangeContext ctx) throws OrmException {
@@ -403,8 +471,8 @@
         }
       }
 
-      List<PatchLineComment> del = Lists.newArrayList();
-      List<PatchLineComment> ups = Lists.newArrayList();
+      List<PatchLineComment> del = new ArrayList<>();
+      List<PatchLineComment> ups = new ArrayList<>();
 
       Set<CommentSetEntry> existingIds = in.omitDuplicateComments
           ? readExistingComments(ctx)
@@ -426,9 +494,10 @@
           }
           e.setStatus(PatchLineComment.Status.PUBLISHED);
           e.setWrittenOn(ctx.getWhen());
-          e.setSide(c.side == Side.PARENT ? (short) 0 : (short) 1);
+          e.setSide(side(c));
           setCommentRevId(e, patchListCache, ctx.getChange(), ps);
           e.setMessage(c.message);
+          e.setTag(in.tag);
           if (c.range != null) {
             e.setRange(new CommentRange(
                 c.range.startLine,
@@ -455,17 +524,17 @@
           del.addAll(drafts.values());
           break;
         case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
           for (PatchLineComment e : drafts.values()) {
-            e.setStatus(PatchLineComment.Status.PUBLISHED);
-            e.setWrittenOn(ctx.getWhen());
-            setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-            ups.add(e);
+            ups.add(publishComment(ctx, e, ps));
           }
           break;
+        case PUBLISH_ALL_REVISIONS:
+          publishAllRevisions(ctx, drafts, ups);
+          break;
       }
-      plcUtil.deleteComments(ctx.getDb(), ctx.getChangeUpdate(), del);
-      plcUtil.upsertComments(ctx.getDb(), ctx.getChangeUpdate(), ups);
+      ChangeUpdate u = ctx.getUpdate(psId);
+      plcUtil.deleteComments(ctx.getDb(), u, del);
+      plcUtil.putComments(ctx.getDb(), u, ups);
       comments.addAll(ups);
       return !del.isEmpty() || !ups.isEmpty();
     }
@@ -474,7 +543,7 @@
         throws OrmException {
       Set<CommentSetEntry> r = new HashSet<>();
       for (PatchLineComment c : plcUtil.publishedByChange(ctx.getDb(),
-            ctx.getChangeNotes())) {
+            ctx.getNotes())) {
         r.add(CommentSetEntry.create(c));
       }
       return r;
@@ -482,9 +551,10 @@
 
     private Map<String, PatchLineComment> changeDrafts(ChangeContext ctx)
         throws OrmException {
-      Map<String, PatchLineComment> drafts = Maps.newHashMap();
+      Map<String, PatchLineComment> drafts = new HashMap<>();
       for (PatchLineComment c : plcUtil.draftByChangeAuthor(
-          ctx.getDb(), ctx.getChangeNotes(), user.getAccountId())) {
+          ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
+        c.setTag(in.tag);
         drafts.put(c.getKey().get(), c);
       }
       return drafts;
@@ -492,68 +562,155 @@
 
     private Map<String, PatchLineComment> patchSetDrafts(ChangeContext ctx)
         throws OrmException {
-      Map<String, PatchLineComment> drafts = Maps.newHashMap();
+      Map<String, PatchLineComment> drafts = new HashMap<>();
       for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(ctx.getDb(),
-          psId, user.getAccountId(), ctx.getChangeNotes())) {
+          psId, user.getAccountId(), ctx.getNotes())) {
         drafts.put(c.getKey().get(), c);
       }
       return drafts;
     }
 
-    private boolean updateLabels(ChangeContext ctx) throws OrmException {
-      Map<String, Short> labels = in.labels;
-      if (labels == null) {
-        labels = Collections.emptyMap();
+    private Map<String, Short> approvalsByKey(
+        Collection<PatchSetApproval> patchsetApprovals) {
+      Map<String, Short> labels = new HashMap<>();
+      for (PatchSetApproval psa : patchsetApprovals) {
+        labels.put(psa.getLabel(), psa.getValue());
+      }
+      return labels;
+    }
+
+    private PatchLineComment publishComment(ChangeContext ctx,
+        PatchLineComment c, PatchSet ps) throws OrmException {
+      c.setStatus(PatchLineComment.Status.PUBLISHED);
+      c.setWrittenOn(ctx.getWhen());
+      c.setTag(in.tag);
+      setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
+      return c;
+    }
+
+    private void publishAllRevisions(ChangeContext ctx,
+        Map<String, PatchLineComment> drafts, List<PatchLineComment> ups)
+        throws OrmException {
+      boolean needOtherPatchSets = false;
+      for (PatchLineComment c : drafts.values()) {
+        if (!c.getPatchSetId().equals(psId)) {
+          needOtherPatchSets = true;
+          break;
+        }
+      }
+      Map<PatchSet.Id, PatchSet> patchSets = needOtherPatchSets
+          ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
+          : ImmutableMap.of(psId, ps);
+      for (PatchLineComment e : drafts.values()) {
+        ups.add(publishComment(ctx, e, patchSets.get(e.getPatchSetId())));
+      }
+    }
+
+    private Map<String, Short> getAllApprovals(LabelTypes labelTypes,
+        Map<String, Short> current, Map<String, Short> input) {
+      Map<String, Short> allApprovals = new HashMap<>();
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        allApprovals.put(lt.getName(), (short) 0);
+      }
+      // set approvals to existing votes
+      if (current != null) {
+        allApprovals.putAll(current);
+      }
+      // set approvals to new votes
+      if (input != null) {
+        allApprovals.putAll(input);
+      }
+      return allApprovals;
+    }
+
+    private Map<String, Short> getPreviousApprovals(
+        Map<String, Short> allApprovals, Map<String, Short> current) {
+      Map<String, Short> previous = new HashMap<>();
+      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+        // assume vote is 0 if there is no vote
+        if (!current.containsKey(approval.getKey())) {
+          previous.put(approval.getKey(), (short) 0);
+        } else {
+          previous.put(approval.getKey(), current.get(approval.getKey()));
+        }
+      }
+      return previous;
+    }
+
+    private boolean updateLabels(ChangeContext ctx)
+        throws OrmException, ResourceConflictException {
+      Map<String, Short> inLabels = MoreObjects.firstNonNull(in.labels,
+          Collections.<String, Short> emptyMap());
+
+      // If no labels were modified and change is closed, abort early.
+      // This avoids trying to record a modified label caused by a user
+      // losing access to a label after the change was submitted.
+      if (inLabels.isEmpty() && ctx.getChange().getStatus().isClosed()) {
+        return false;
       }
 
-      List<PatchSetApproval> del = Lists.newArrayList();
-      List<PatchSetApproval> ups = Lists.newArrayList();
+      List<PatchSetApproval> del = new ArrayList<>();
+      List<PatchSetApproval> ups = new ArrayList<>();
       Map<String, PatchSetApproval> current = scanLabels(ctx, del);
+      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      Map<String, Short> allApprovals = getAllApprovals(labelTypes,
+          approvalsByKey(current.values()), inLabels);
+      Map<String, Short> previous = getPreviousApprovals(allApprovals,
+          approvalsByKey(current.values()));
 
-      ChangeUpdate update = ctx.getChangeUpdate();
-      LabelTypes labelTypes = ctx.getChangeControl().getLabelTypes();
-      for (Map.Entry<String, Short> ent : labels.entrySet()) {
+      ChangeUpdate update = ctx.getUpdate(psId);
+      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
         LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
-        if (ctx.getChange().getStatus().isClosed()) {
-          // TODO Allow updating some labels even when closed.
-          continue;
-        }
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
+        approvals.put(normName, (short) 0);
         if (ent.getValue() == null || ent.getValue() == 0) {
           // User requested delete of this label.
+          oldApprovals.put(normName, null);
           if (c != null) {
             if (c.getValue() != 0) {
               addLabelDelta(normName, (short) 0);
+              oldApprovals.put(normName, previous.get(normName));
             }
             del.add(c);
-            update.putApproval(ent.getKey(), (short) 0);
+            update.putApproval(normName, (short) 0);
           }
         } else if (c != null && c.getValue() != ent.getValue()) {
           c.setValue(ent.getValue());
           c.setGranted(ctx.getWhen());
+          c.setTag(in.tag);
           ups.add(c);
           addLabelDelta(normName, c.getValue());
-          categories.put(normName, c.getValue());
-          update.putApproval(ent.getKey(), ent.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
+          update.putApproval(normName, ent.getValue());
         } else if (c != null && c.getValue() == ent.getValue()) {
           current.put(normName, c);
+          oldApprovals.put(normName, null);
+          approvals.put(normName, c.getValue());
         } else if (c == null) {
           c = new PatchSetApproval(new PatchSetApproval.Key(
                   psId,
                   user.getAccountId(),
                   lt.getLabelId()),
-              ent.getValue(), TimeUtil.nowTs());
+              ent.getValue(), ctx.getWhen());
+          c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
           addLabelDelta(normName, c.getValue());
-          categories.put(normName, c.getValue());
-          update.putApproval(ent.getKey(), ent.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
+          update.putReviewer(user.getAccountId(), REVIEWER);
+          update.putApproval(normName, ent.getValue());
         }
       }
 
+      if ((!del.isEmpty() || !ups.isEmpty())
+          && ctx.getChange().getStatus().isClosed()) {
+        throw new ResourceConflictException("change is closed");
+      }
       forceCallerAsReviewer(ctx, current, ups, del);
       ctx.getDb().patchSetApprovals().delete(del);
       ctx.getDb().patchSetApprovals().upsert(ups);
@@ -571,9 +728,10 @@
           PatchSetApproval c = new PatchSetApproval(new PatchSetApproval.Key(
               psId,
               user.getAccountId(),
-              ctx.getChangeControl().getLabelTypes().getLabelTypes().get(0)
+              ctx.getControl().getLabelTypes().getLabelTypes().get(0)
                   .getLabelId()),
-              (short) 0, TimeUtil.nowTs());
+              (short) 0, ctx.getWhen());
+          c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
         } else {
@@ -586,16 +744,18 @@
           ups.add(c);
         }
       }
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .putReviewer(user.getAccountId(), REVIEWER);
     }
 
     private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx,
         List<PatchSetApproval> del) throws OrmException {
-      LabelTypes labelTypes = ctx.getChangeControl().getLabelTypes();
-      Map<String, PatchSetApproval> current = Maps.newHashMap();
+      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      Map<String, PatchSetApproval> current = new HashMap<>();
 
       for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-          ctx.getDb(), ctx.getChangeControl(), psId, user.getAccountId())) {
-        if (a.isSubmit()) {
+          ctx.getDb(), ctx.getControl(), psId, user.getAccountId())) {
+        if (a.isLegacySubmit()) {
           continue;
         }
 
@@ -635,11 +795,12 @@
           user.getAccountId(),
           ctx.getWhen(),
           psId);
+      message.setTag(in.tag);
       message.setMessage(String.format(
           "Patch Set %d:%s",
           psId.get(),
           buf.toString()));
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(), message);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
       return true;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 3ab84ab..358e05b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -14,17 +14,23 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -34,20 +40,22 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.change.ReviewerJson.PostResult;
-import com.google.gerrit.server.change.ReviewerJson.ReviewerInfo;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.index.ChangeIndexer;
 import com.google.gerrit.server.mail.AddReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gwtorm.server.OrmException;
@@ -61,14 +69,18 @@
 
 import java.io.IOException;
 import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
 @Singleton
-public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log = LoggerFactory
-      .getLogger(PostReviewers.class);
+public class PostReviewers
+    implements RestModifyView<ChangeResource, AddReviewerInput> {
+  private static final Logger log =
+      LoggerFactory.getLogger(PostReviewers.class);
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
@@ -76,105 +88,123 @@
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory reviewerFactory;
   private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
   private final AddReviewerSender.Factory addReviewerSenderFactory;
   private final GroupsCollection groupsCollection;
   private final GroupMembers.Factory groupMembersFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
-  private final ChangeUpdate.Factory updateFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
-  private final ChangeHooks hooks;
   private final AccountCache accountCache;
   private final ReviewerJson json;
-  private final ChangeIndexer indexer;
+  private final ReviewerAdded reviewerAdded;
+  private final NotesMigration migration;
 
   @Inject
   PostReviewers(AccountsCollection accounts,
       ReviewerResource.Factory reviewerFactory,
       ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
       AddReviewerSender.Factory addReviewerSenderFactory,
       GroupsCollection groupsCollection,
       GroupMembers.Factory groupMembersFactory,
       AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
-      ChangeUpdate.Factory updateFactory,
+      BatchUpdate.Factory batchUpdateFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
-      ChangeHooks hooks,
       AccountCache accountCache,
       ReviewerJson json,
-      ChangeIndexer indexer) {
+      ReviewerAdded reviewerAdded,
+      NotesMigration migration) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
     this.addReviewerSenderFactory = addReviewerSenderFactory;
     this.groupsCollection = groupsCollection;
     this.groupMembersFactory = groupMembersFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
-    this.updateFactory = updateFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
     this.user = user;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
-    this.hooks = hooks;
     this.accountCache = accountCache;
     this.json = json;
-    this.indexer = indexer;
+    this.reviewerAdded = reviewerAdded;
+    this.migration = migration;
   }
 
   @Override
-  public PostResult apply(ChangeResource rsrc, AddReviewerInput input)
-      throws AuthException, BadRequestException, UnprocessableEntityException,
-      OrmException, IOException {
+  public AddReviewerResult apply(ChangeResource rsrc, AddReviewerInput input)
+      throws IOException, OrmException, RestApiException, UpdateException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
+    Addition addition = prepareApplication(rsrc, input);
+    if (addition.op == null) {
+      return addition.result;
+    }
+    try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
+        rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.addOp(id, addition.op);
+      bu.execute();
+      addition.gatherResults();
+    }
+    return addition.result;
+  }
+
+  public Addition prepareApplication(ChangeResource rsrc, AddReviewerInput input)
+      throws OrmException, RestApiException, IOException {
+    Account.Id accountId;
     try {
-      Account.Id accountId = accounts.parse(input.reviewer).getAccountId();
-      return putAccount(reviewerFactory.create(rsrc, accountId));
+      accountId = accounts.parse(input.reviewer).getAccountId();
     } catch (UnprocessableEntityException e) {
       try {
         return putGroup(rsrc, input);
       } catch (UnprocessableEntityException e2) {
-        throw new UnprocessableEntityException(MessageFormat.format(
-            ChangeMessages.get().reviewerNotFound,
-            input.reviewer));
+        throw new UnprocessableEntityException(MessageFormat
+            .format(ChangeMessages.get().reviewerNotFound, input.reviewer));
       }
     }
+    return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId),
+        input.state(), input.notify);
   }
 
-  private PostResult putAccount(ReviewerResource rsrc) throws OrmException,
-      IOException {
-    Account member = rsrc.getUser().getAccount();
-    ChangeControl control = rsrc.getControl();
-    PostResult result = new PostResult();
+  private Addition putAccount(String reviewer, ReviewerResource rsrc,
+      ReviewerState state, NotifyHandling notify)
+      throws UnprocessableEntityException {
+    Account member = rsrc.getReviewerUser().getAccount();
+    ChangeControl control = rsrc.getReviewerControl();
     if (isValidReviewer(member, control)) {
-      addReviewers(rsrc, result, ImmutableMap.of(member.getId(), control));
+      return new Addition(reviewer, rsrc.getChangeResource(),
+          ImmutableMap.of(member.getId(), control), state, notify);
     }
-    return result;
+    throw new UnprocessableEntityException("Change not visible to " + reviewer);
   }
 
-  private PostResult putGroup(ChangeResource rsrc, AddReviewerInput input)
-      throws BadRequestException,
-      UnprocessableEntityException, OrmException, IOException {
-    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
-    PostResult result = new PostResult();
+  private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
+      throws RestApiException, OrmException, IOException {
+    GroupDescription.Basic group =
+        groupsCollection.parseInternal(input.reviewer);
     if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupIsNotAllowed, group.getName());
-      return result;
+      return fail(input.reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed,
+          group.getName()));
     }
 
-    Map<Account.Id, ChangeControl> reviewers = Maps.newHashMap();
+    Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
     ChangeControl control = rsrc.getControl();
     Set<Account> members;
     try {
       members = groupMembersFactory.create(control.getUser()).listAccounts(
-              group.getGroupUUID(), control.getProject().getNameKey());
+          group.getGroupUUID(), control.getProject().getNameKey());
     } catch (NoSuchGroupException e) {
       throw new UnprocessableEntityException(e.getMessage());
     } catch (NoSuchProjectException e) {
@@ -186,22 +216,19 @@
     int maxAllowed =
         cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupHasTooManyMembers, group.getName());
-      return result;
+      return fail(input.reviewer, MessageFormat.format(
+          ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation =
-        cfg.getInt("addreviewer", "maxWithoutConfirmation",
-            DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    int maxWithoutConfirmation = cfg.getInt("addreviewer",
+        "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
     if (!input.confirmed() && maxWithoutConfirmation > 0
         && members.size() > maxWithoutConfirmation) {
-      result.confirm = true;
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupManyMembersConfirmation,
-          group.getName(), members.size());
-      return result;
+      return fail(input.reviewer, true,
+          MessageFormat.format(
+              ChangeMessages.get().groupManyMembersConfirmation,
+              group.getName(), members.size()));
     }
 
     for (Account member : members) {
@@ -210,8 +237,8 @@
       }
     }
 
-    addReviewers(rsrc, result, reviewers);
-    return result;
+    return new Addition(input.reviewer, rsrc, reviewers, input.state(),
+        input.notify);
   }
 
   private boolean isValidReviewer(Account member, ChangeControl control) {
@@ -224,48 +251,138 @@
     return false;
   }
 
-  private void addReviewers(ChangeResource rsrc, PostResult result,
-      Map<Account.Id, ChangeControl> reviewers)
-      throws OrmException, IOException {
-    ReviewDb db = dbProvider.get();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
-    List<PatchSetApproval> added;
-    db.changes().beginTransaction(rsrc.getChange().getId());
-    try {
-      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getChange().getId(), db);
-      added = approvalsUtil.addReviewers(db, rsrc.getNotes(), update,
-          rsrc.getControl().getLabelTypes(), rsrc.getChange(),
-          reviewers.keySet());
-      db.commit();
-    } finally {
-      db.rollback();
+  private Addition fail(String reviewer, String error) {
+    return fail(reviewer, false, error);
+  }
+
+  private Addition fail(String reviewer, boolean confirm, String error) {
+    Addition addition = new Addition(reviewer);
+    addition.result.confirm = confirm ? true : null;
+    addition.result.error = error;
+    return addition;
+  }
+
+  class Addition {
+    final AddReviewerResult result;
+    final Op op;
+
+    private final Map<Account.Id, ChangeControl> reviewers;
+
+    protected Addition(String reviewer) {
+      this(reviewer, null, null, REVIEWER, null);
     }
 
-    update.commit();
-    CheckedFuture<?, IOException> indexFuture =
-        indexer.indexAsync(rsrc.getChange().getId());
-    result.reviewers = Lists.newArrayListWithCapacity(added.size());
-    for (PatchSetApproval psa : added) {
-      // New reviewers have value 0, don't bother normalizing.
-      result.reviewers.add(json.format(
-          new ReviewerInfo(psa.getAccountId()),
-          reviewers.get(psa.getAccountId()),
-          ImmutableList.of(psa)));
+    protected Addition(String reviewer, ChangeResource rsrc,
+        Map<Account.Id, ChangeControl> reviewers, ReviewerState state,
+        NotifyHandling notify) {
+      result = new AddReviewerResult(reviewer);
+      if (reviewers == null) {
+        this.reviewers = ImmutableMap.of();
+        op = null;
+        return;
+      }
+      this.reviewers = reviewers;
+      op = new Op(rsrc, reviewers, state, notify);
     }
-    accountLoaderFactory.create(true).fill(result.reviewers);
-    indexFuture.checkedGet();
-    emailReviewers(rsrc.getChange(), added);
-    if (!added.isEmpty()) {
-      PatchSet patchSet = dbProvider.get().patchSets().get(rsrc.getChange().currentPatchSetId());
-      for (PatchSetApproval psa : added) {
-        Account account = accountCache.get(psa.getAccountId()).getAccount();
-        hooks.doReviewerAddedHook(rsrc.getChange(), account, patchSet, dbProvider.get());
+
+    void gatherResults() throws OrmException {
+      // Generate result details and fill AccountLoader. This occurs outside
+      // the Op because the accounts are in a different table.
+      if (migration.readChanges() && op.state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
+        for (Account.Id accountId : op.addedCCs) {
+          result.ccs.add(
+              json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+        }
+        accountLoaderFactory.create(true).fill(result.ccs);
+      } else {
+        result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
+        for (PatchSetApproval psa : op.addedReviewers) {
+          // New reviewers have value 0, don't bother normalizing.
+          result.reviewers.add(
+            json.format(new ReviewerInfo(psa.getAccountId().get()),
+                reviewers.get(psa.getAccountId()),
+                ImmutableList.of(psa)));
+        }
+        accountLoaderFactory.create(true).fill(result.reviewers);
       }
     }
   }
 
-  private void emailReviewers(Change change, List<PatchSetApproval> added) {
-    if (added.isEmpty()) {
+  class Op extends BatchUpdate.Op {
+    final Map<Account.Id, ChangeControl> reviewers;
+    final ReviewerState state;
+    final NotifyHandling notify;
+    List<PatchSetApproval> addedReviewers;
+    Collection<Account.Id> addedCCs;
+
+    private final ChangeResource rsrc;
+    private PatchSet patchSet;
+
+    Op(ChangeResource rsrc, Map<Account.Id, ChangeControl> reviewers,
+        ReviewerState state, NotifyHandling notify) {
+      this.rsrc = rsrc;
+      this.reviewers = reviewers;
+      this.state = state;
+      this.notify = notify;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws RestApiException, OrmException, IOException {
+      if (migration.readChanges() && state == CC) {
+        addedCCs = approvalsUtil.addCcs(ctx.getNotes(),
+            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+            reviewers.keySet());
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers = approvalsUtil.addReviewers(ctx.getDb(), ctx.getNotes(),
+            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+            rsrc.getControl().getLabelTypes(), rsrc.getChange(),
+            reviewers.keySet());
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
+      }
+
+      patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+      return true;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      if (addedReviewers != null || addedCCs != null) {
+        if (addedReviewers == null) {
+          addedReviewers = new ArrayList<>();
+        }
+        if (addedCCs == null) {
+          addedCCs = new ArrayList<>();
+        }
+        List<Account.Id> accounts = Lists.transform(addedReviewers,
+            new Function<PatchSetApproval, Account.Id>() {
+              @Override
+              public Account.Id apply(PatchSetApproval psa) {
+                return psa.getAccountId();
+              }
+            });
+
+        emailReviewers(rsrc.getChange(), accounts, addedCCs, notify);
+        if (!addedReviewers.isEmpty()) {
+          for (PatchSetApproval psa : addedReviewers) {
+            Account account = accountCache.get(psa.getAccountId()).getAccount();
+            reviewerAdded.fire(rsrc.getChange(), patchSet, account,
+              ctx.getAccount(), ctx.getWhen());
+          }
+        }
+      }
+    }
+  }
+
+  public void emailReviewers(Change change, Collection<Account.Id> added,
+      Collection<Account.Id> copied, NotifyHandling notify) {
+    if (added.isEmpty() && copied.isEmpty()) {
       return;
     }
 
@@ -274,22 +391,32 @@
     // The user knows they added themselves, don't bother emailing them.
     List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
     Account.Id userId = user.get().getAccountId();
-    for (PatchSetApproval psa : added) {
-      if (!psa.getAccountId().equals(userId)) {
-        toMail.add(psa.getAccountId());
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
       }
     }
-    if (!toMail.isEmpty()) {
-      try {
-        AddReviewerSender cm = addReviewerSenderFactory.create(change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(toMail);
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot send email to new reviewers of change "
-            + change.getId(), err);
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
       }
     }
+    if (toMail.isEmpty() && toCopy.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory
+          .create(change.getProject(), change.getId(), notify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addExtraCC(toCopy);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change "
+          + change.getId(), err);
+    }
   }
 
   public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index e137ac4..c86e98f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -84,7 +84,7 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, Publish.Input in)
-        throws NoSuchProjectException, IOException, OrmException,
+        throws NoSuchChangeException, IOException, OrmException,
         RestApiException, UpdateException {
       Capable r =
           rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
index 9f2a3f9..d17d69b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelTypes;
@@ -37,16 +36,18 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.PublishDraftPatchSet.Input;
+import com.google.gerrit.server.extensions.events.DraftPublished;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.mail.CreateChangeSender;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -61,7 +62,6 @@
 
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 
 @Singleton
@@ -73,33 +73,36 @@
   public static class Input {
   }
 
-  private final Provider<ReviewDb> dbProvider;
-  private final BatchUpdate.Factory updateFactory;
-  private final ChangeHooks hooks;
-  private final ApprovalsUtil approvalsUtil;
   private final AccountResolver accountResolver;
-  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final BatchUpdate.Factory updateFactory;
   private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final PatchSetUtil psUtil;
+  private final Provider<ReviewDb> dbProvider;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final DraftPublished draftPublished;
 
   @Inject
   public PublishDraftPatchSet(
-      Provider<ReviewDb> dbProvider,
-      BatchUpdate.Factory updateFactory,
-      ChangeHooks hooks,
-      ApprovalsUtil approvalsUtil,
       AccountResolver accountResolver,
-      PatchSetInfoFactory patchSetInfoFactory,
+      ApprovalsUtil approvalsUtil,
+      BatchUpdate.Factory updateFactory,
       CreateChangeSender.Factory createChangeSenderFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory) {
-    this.dbProvider = dbProvider;
-    this.updateFactory = updateFactory;
-    this.hooks = hooks;
-    this.approvalsUtil = approvalsUtil;
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetUtil psUtil,
+      Provider<ReviewDb> dbProvider,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      DraftPublished draftPublished) {
     this.accountResolver = accountResolver;
-    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.updateFactory = updateFactory;
     this.createChangeSenderFactory = createChangeSenderFactory;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.psUtil = psUtil;
+    this.dbProvider = dbProvider;
     this.replacePatchSetFactory = replacePatchSetFactory;
+    this.draftPublished = draftPublished;
   }
 
   @Override
@@ -123,6 +126,7 @@
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
       return new UiAction.Description()
+        .setLabel("Publish")
         .setTitle(String.format("Publish revision %d",
             rsrc.getPatchSet().getPatchSetId()))
         .setVisible(rsrc.getPatchSet().isDraft()
@@ -155,7 +159,6 @@
     private PatchSet patchSet;
     private Change change;
     private boolean wasDraftChange;
-    private RevCommit commit;
     private PatchSetInfo patchSetInfo;
     private MailRecipients recipients;
 
@@ -165,74 +168,63 @@
     }
 
     @Override
-    public void updateRepo(RepoContext ctx)
+    public boolean updateChange(ChangeContext ctx)
         throws RestApiException, OrmException, IOException {
-      PatchSet ps = patchSet;
-      if (ps == null) {
-        // Don't save in patchSet, since we're not in a transaction. Here we
-        // just need the revision, which is immutable.
-        ps = ctx.getDb().patchSets().get(psId);
-        if (ps == null) {
+      if (!ctx.getControl().canPublish(ctx.getDb())) {
+        throw new AuthException("Cannot publish this draft patch set");
+      }
+      if (patchSet == null) {
+        patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+        if (patchSet == null) {
           throw new ResourceNotFoundException(psId.toString());
         }
       }
-      commit = ctx.getRevWalk().parseCommit(
-          ObjectId.fromString(ps.getRevision().get()));
-      patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
-    }
-
-    @Override
-    public void updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException {
-      if (!ctx.getChangeControl().canPublish(ctx.getDb())) {
-        throw new AuthException("Cannot publish this draft patch set");
-      }
       saveChange(ctx);
       savePatchSet(ctx);
       addReviewers(ctx);
+      return true;
     }
 
-    private void saveChange(ChangeContext ctx) throws OrmException {
+    private void saveChange(ChangeContext ctx) {
       change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(psId);
       wasDraftChange = change.getStatus() == Change.Status.DRAFT;
       if (wasDraftChange) {
         change.setStatus(Change.Status.NEW);
-        ChangeUtil.updated(change);
-        ctx.getDb().changes().update(Collections.singleton(change));
+        update.setStatus(change.getStatus());
       }
     }
 
     private void savePatchSet(ChangeContext ctx)
         throws RestApiException, OrmException {
-      patchSet = ctx.getDb().patchSets().get(psId);
       if (!patchSet.isDraft()) {
         throw new ResourceConflictException("Patch set is not a draft");
       }
-      patchSet.setDraft(false);
-      // Force ETag invalidation if not done already
-      if (!wasDraftChange) {
-        ChangeUtil.updated(change);
-        ctx.getDb().changes().update(Collections.singleton(change));
-      }
-      ctx.getDb().patchSets().update(Collections.singleton(patchSet));
+      psUtil.publish(ctx.getDb(), ctx.getUpdate(psId), patchSet);
     }
 
-    private void addReviewers(ChangeContext ctx) throws OrmException {
-      LabelTypes labelTypes = ctx.getChangeControl().getLabelTypes();
+    private void addReviewers(ChangeContext ctx)
+        throws OrmException, IOException {
+      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
       Collection<Account.Id> oldReviewers = approvalsUtil.getReviewers(
-          ctx.getDb(), ctx.getChangeNotes()).values();
+          ctx.getDb(), ctx.getNotes()).all();
+      RevCommit commit = ctx.getRevWalk().parseCommit(
+          ObjectId.fromString(patchSet.getRevision().get()));
+      patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+
       List<FooterLine> footerLines = commit.getFooterLines();
-      recipients =
-          getRecipientsFromFooters(accountResolver, patchSet, footerLines);
-      recipients.remove(ctx.getUser().getAccountId());
-      approvalsUtil.addReviewers(ctx.getDb(), ctx.getChangeUpdate(), labelTypes,
+      recipients = getRecipientsFromFooters(
+          ctx.getDb(), accountResolver, patchSet.isDraft(), footerLines);
+      recipients.remove(ctx.getAccountId());
+      approvalsUtil.addReviewers(ctx.getDb(), ctx.getUpdate(psId), labelTypes,
           change, patchSet, patchSetInfo, recipients.getReviewers(),
           oldReviewers);
     }
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
-      hooks.doDraftPublishedHook(change, patchSet, ctx.getDb());
+      draftPublished.fire(change, patchSet, ctx.getAccount(),
+          ctx.getWhen());
       if (patchSet.isDraft() && change.getStatus() == Change.Status.DRAFT) {
         // Skip emails if the patch set is still a draft.
         return;
@@ -250,8 +242,8 @@
 
     private void sendCreateChange(Context ctx) throws EmailException {
       CreateChangeSender cm =
-          createChangeSenderFactory.create(change.getId());
-      cm.setFrom(ctx.getUser().getAccountId());
+          createChangeSenderFactory.create(ctx.getProject(), change.getId());
+      cm.setFrom(ctx.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfo);
       cm.addReviewers(recipients.getReviewers());
       cm.addExtraCC(recipients.getCcOnly());
@@ -260,17 +252,17 @@
 
     private void sendReplacePatchSet(Context ctx)
         throws EmailException, OrmException {
-      Account.Id accountId = ctx.getUser().getAccountId();
+      Account.Id accountId = ctx.getAccountId();
       ChangeMessage msg =
           new ChangeMessage(new ChangeMessage.Key(change.getId(),
               ChangeUtil.messageUUID(ctx.getDb())), accountId,
               ctx.getWhen(), psId);
       msg.setMessage("Uploaded patch set " + psId.get() + ".");
       ReplacePatchSetSender cm =
-          replacePatchSetFactory.create(change.getId());
+          replacePatchSetFactory.create(ctx.getProject(), change.getId());
       cm.setFrom(accountId);
       cm.setPatchSet(patchSet, patchSetInfo);
-      cm.setChangeMessage(msg);
+      cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
       cm.addReviewers(recipients.getReviewers());
       cm.addExtraCC(recipients.getCcOnly());
       cm.send();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index a4a5e16..655e07d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -16,18 +16,27 @@
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
 
+import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gwtorm.server.OrmException;
@@ -35,7 +44,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.Collections;
 
 @Singleton
@@ -44,7 +53,8 @@
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
   private final PatchLineCommentsUtil plcUtil;
-  private final ChangeUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
+  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
 
@@ -52,12 +62,14 @@
   PutDraftComment(Provider<ReviewDb> db,
       DeleteDraftComment delete,
       PatchLineCommentsUtil plcUtil,
-      ChangeUpdate.Factory updateFactory,
+      PatchSetUtil psUtil,
+      BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
     this.db = db;
     this.delete = delete;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.patchListCache = patchListCache;
@@ -65,9 +77,7 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in) throws
-      BadRequestException, OrmException, IOException {
-    PatchLineComment c = rsrc.getComment();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
+      RestApiException, UpdateException, OrmException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
       return delete.apply(rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
@@ -78,36 +88,83 @@
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    if (in.path != null
-        && !in.path.equals(c.getKey().getParentKey().getFileName())) {
-      // Updating the path alters the primary key, which isn't possible.
-      // Delete then recreate the comment instead of an update.
-
-      plcUtil.deleteComments(db.get(), update, Collections.singleton(c));
-      c = new PatchLineComment(
-          new PatchLineComment.Key(
-              new Patch.Key(rsrc.getPatchSet().getId(), in.path),
-              c.getKey().get()),
-          c.getLine(),
-          rsrc.getAuthorId(),
-          c.getParentUuid(), TimeUtil.nowTs());
-      setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-      plcUtil.insertComments(db.get(), update,
-          Collections.singleton(update(c, in)));
-    } else {
-      if (c.getRevId() == null) {
-        setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-      }
-      plcUtil.updateComments(db.get(), update,
-          Collections.singleton(update(c, in)));
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
+        TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().getKey(), in);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      return Response.ok(
+          commentJson.get().setFillAccounts(false).format(op.comment));
     }
-    update.commit();
-    return Response.ok(commentJson.get().setFillAccounts(false).format(c));
   }
 
-  private PatchLineComment update(PatchLineComment e, DraftInput in) {
+  private class Op extends BatchUpdate.Op {
+    private final PatchLineComment.Key key;
+    private final DraftInput in;
+
+    private PatchLineComment comment;
+
+    private Op(PatchLineComment.Key key, DraftInput in) {
+      this.key = key;
+      this.in = in;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      Optional<PatchLineComment> maybeComment =
+          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+      if (!maybeComment.isPresent()) {
+        // Disappeared out from under us. Can't easily fall back to insert,
+        // because the input might be missing required fields. Just give up.
+        throw new ResourceNotFoundException("comment not found: " + key);
+      }
+      PatchLineComment origComment = maybeComment.get();
+      comment = new PatchLineComment(origComment);
+
+      PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
+      ChangeUpdate update = ctx.getUpdate(psId);
+
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      if (in.path != null
+          && !in.path.equals(comment.getKey().getParentKey().getFileName())) {
+        // Updating the path alters the primary key, which isn't possible.
+        // Delete then recreate the comment instead of an update.
+
+        plcUtil.deleteComments(
+            ctx.getDb(), update, Collections.singleton(origComment));
+        comment = new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(psId, in.path),
+                comment.getKey().get()),
+            comment.getLine(),
+            ctx.getAccountId(),
+            comment.getParentUuid(), ctx.getWhen());
+        comment.setTag(origComment.getTag());
+        setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+        plcUtil.putComments(ctx.getDb(), update,
+            Collections.singleton(update(comment, in, ctx.getWhen())));
+      } else {
+        if (comment.getRevId() == null) {
+          setCommentRevId(
+              comment, patchListCache, ctx.getChange(), ps);
+        }
+        plcUtil.putComments(ctx.getDb(), update,
+            Collections.singleton(update(comment, in, ctx.getWhen())));
+      }
+      ctx.bumpLastUpdatedOn(false);
+      return true;
+    }
+  }
+
+  private static PatchLineComment update(PatchLineComment e, DraftInput in,
+      Timestamp when) {
     if (in.side != null) {
-      e.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
+      e.setSide(side(in));
     }
     if (in.inReplyTo != null) {
       e.setParentUuid(Url.decode(in.inReplyTo));
@@ -117,7 +174,18 @@
       e.setRange(in.range);
       e.setLine(in.range != null ? in.range.endLine : in.line);
     }
-    e.setWrittenOn(TimeUtil.nowTs());
+    e.setWrittenOn(when);
+    if (in.tag != null) {
+      // TODO(dborowitz): Can we support changing tags via PUT?
+      e.setTag(in.tag);
+    }
     return e;
   }
+
+  static short side(Comment c) {
+    if (c.side == Side.PARENT) {
+      return (short) (c.parent == null ? 0 : -c.parent.shortValue());
+    }
+    return 1;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index ae12497..31ae892 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -28,27 +27,26 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.PutTopic.Input;
+import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.util.Collections;
-
 @Singleton
 public class PutTopic implements RestModifyView<ChangeResource, Input>,
     UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
-  private final ChangeHooks hooks;
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final TopicEdited topicEdited;
 
   public static class Input {
     @DefaultInput
@@ -57,13 +55,13 @@
 
   @Inject
   PutTopic(Provider<ReviewDb> dbProvider,
-      ChangeHooks hooks,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      TopicEdited topicEdited) {
     this.dbProvider = dbProvider;
-    this.hooks = hooks;
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.topicEdited = topicEdited;
   }
 
   @Override
@@ -74,10 +72,10 @@
       throw new AuthException("changing topic not permitted");
     }
 
-    Op op = new Op(ctl, input != null ? input : new Input());
+    Op op = new Op(input != null ? input : new Input());
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), op);
+      u.addOp(req.getId(), op);
       u.execute();
     }
     return Strings.isNullOrEmpty(op.newTopicName)
@@ -87,24 +85,23 @@
 
   private class Op extends BatchUpdate.Op {
     private final Input input;
-    private final IdentifiedUser caller;
 
     private Change change;
     private String oldTopicName;
     private String newTopicName;
 
-    public Op(ChangeControl ctl, Input input) {
+    Op(Input input) {
       this.input = input;
-      this.caller = ctl.getUser().asIdentifiedUser();
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException {
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
       change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       newTopicName = Strings.nullToEmpty(input.topic);
       oldTopicName = Strings.nullToEmpty(change.getTopic());
       if (oldTopicName.equals(newTopicName)) {
-        return;
+        return false;
       }
       String summary;
       if (oldTopicName.isEmpty()) {
@@ -116,24 +113,26 @@
             oldTopicName, newTopicName);
       }
       change.setTopic(Strings.emptyToNull(newTopicName));
-      ChangeUtil.updated(change);
-      ctx.getDb().changes().update(Collections.singleton(change));
+      update.setTopic(change.getTopic());
 
       ChangeMessage cmsg = new ChangeMessage(
           new ChangeMessage.Key(
               change.getId(),
               ChangeUtil.messageUUID(ctx.getDb())),
-          caller.getAccountId(), ctx.getWhen(),
+          ctx.getAccountId(), ctx.getWhen(),
           change.currentPatchSetId());
       cmsg.setMessage(summary);
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(), cmsg);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      return true;
     }
 
     @Override
-    public void postUpdate(Context ctx) throws OrmException {
+    public void postUpdate(Context ctx) {
       if (change != null) {
-        hooks.doTopicChangedHook(change, caller.getAccount(),
-            oldTopicName, ctx.getDb());
+        topicEdited.fire(change,
+            ctx.getAccount(),
+            oldTopicName,
+            ctx.getWhen());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 60f285f..4b81c31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.primitives.Ints;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
@@ -29,13 +28,14 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -63,6 +63,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final RebaseChangeOp.Factory rebaseFactory;
+  private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
 
@@ -70,11 +71,13 @@
   public Rebase(BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
+      RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
+    this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.dbProvider = dbProvider;
   }
@@ -82,7 +85,7 @@
   @Override
   public ChangeInfo apply(RevisionResource rsrc, RebaseInput input)
       throws EmailException, OrmException, UpdateException, RestApiException,
-      IOException {
+      IOException, NoSuchChangeException {
     ChangeControl control = rsrc.getControl();
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
@@ -90,7 +93,7 @@
         ObjectInserter oi = repo.newObjectInserter();
         BatchUpdate bu = updateFactory.create(dbProvider.get(),
           change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!control.canRebase()) {
+      if (!control.canRebase(dbProvider.get())) {
         throw new AuthException("rebase not permitted");
       } else if (!change.getStatus().isOpen()) {
         throw new ResourceConflictException("change is "
@@ -104,43 +107,41 @@
             control, rsrc.getPatchSet(),
             findBaseRev(rw, rsrc, input))
           .setForceContentMerge(true)
-          .setRunHooks(true)
+          .setFireRevisionCreated(true)
           .setValidatePolicy(CommitValidators.Policy.GERRIT));
       bu.execute();
     }
-    return json.create(OPTIONS).format(change.getId());
+    return json.create(OPTIONS).format(change.getProject(), change.getId());
   }
 
   private String findBaseRev(RevWalk rw, RevisionResource rsrc,
       RebaseInput input) throws AuthException, ResourceConflictException,
-      OrmException, IOException {
+      OrmException, IOException, NoSuchChangeException {
     if (input == null || input.base == null) {
       return null;
     }
 
     Change change = rsrc.getChange();
-    String base = input.base.trim();
-    if (base.equals("")) {
+    String str = input.base.trim();
+    if (str.equals("")) {
       // remove existing dependency to other patch set
       return change.getDest().get();
     }
 
     @SuppressWarnings("resource")
     ReviewDb db = dbProvider.get();
-    PatchSet basePatchSet = parseBase(base);
-    if (basePatchSet == null) {
-      throw new ResourceConflictException("base revision is missing: " + base);
-    } else if (!rsrc.getControl().isPatchVisible(basePatchSet, db)) {
-      throw new AuthException("base revision not accessible: " + base);
-    } else if (change.getId().equals(basePatchSet.getId().getParentKey())) {
-      throw new ResourceConflictException("cannot depend on self");
+    Base base = rebaseUtil.parseBase(rsrc, str);
+    if (base == null) {
+      throw new ResourceConflictException("base revision is missing: " + str);
+    }
+    PatchSet.Id baseId = base.patchSet().getId();
+    if (!base.control().isPatchVisible(base.patchSet(), db)) {
+      throw new AuthException("base revision not accessible: " + str);
+    } else if (change.getId().equals(baseId.getParentKey())) {
+      throw new ResourceConflictException("cannot rebase change onto itself");
     }
 
-    Change baseChange = db.changes().get(basePatchSet.getId().getParentKey());
-    if (baseChange == null) {
-      return null;
-    }
-
+    Change baseChange = base.control().getChange();
     if (!baseChange.getProject().equals(change.getProject())) {
       throw new ResourceConflictException(
           "base change is in wrong project: " + baseChange.getProject());
@@ -150,12 +151,12 @@
     } else if (baseChange.getStatus() == Status.ABANDONED) {
       throw new ResourceConflictException(
           "base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), basePatchSet)) {
+    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
       throw new ResourceConflictException(
           "base change " + baseChange.getKey()
-          + " is a descendant of the current  change - recursion not allowed");
+          + " is a descendant of the current change - recursion not allowed");
     }
-    return basePatchSet.getRevision().get();
+    return base.patchSet().getRevision().get();
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip)
@@ -165,40 +166,6 @@
     return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
-  private PatchSet parseBase(String base) throws OrmException {
-    ReviewDb db = dbProvider.get();
-
-    PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
-    if (basePatchSetId != null) {
-      // Try parsing the base as a ref string.
-      return db.patchSets().get(basePatchSetId);
-    }
-
-    // Try parsing base as a change number (assume current patch set).
-    PatchSet basePatchSet = null;
-    Integer baseChangeId = Ints.tryParse(base);
-    if (baseChangeId != null) {
-      for (PatchSet ps : db.patchSets().byChange(new Change.Id(baseChangeId))) {
-        if (basePatchSet == null
-            || basePatchSet.getId().get() < ps.getId().get()) {
-          basePatchSet = ps;
-        }
-      }
-      if (basePatchSet != null) {
-        return basePatchSet;
-      }
-    }
-
-    // Try parsing as SHA-1.
-    for (PatchSet ps : db.patchSets().byRevision(new RevId(base))) {
-      if (basePatchSet == null
-          || basePatchSet.getId().get() < ps.getId().get()) {
-        basePatchSet = ps;
-      }
-    }
-    return basePatchSet;
-  }
-
   private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
     // Prevent rebase of exotic changes (merge commit, no ancestor).
     RevCommit c = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
@@ -209,17 +176,22 @@
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet patchSet = resource.getPatchSet();
     Branch.NameKey dest = resource.getChange().getDest();
+    boolean canRebase = false;
+    try {
+      canRebase = resource.getControl().canRebase(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canRebase status. Assuming false.", e);
+    }
     boolean visible = resource.getChange().getStatus().isOpen()
           && resource.isCurrent()
-          && resource.getControl().canRebase();
+          && canRebase;
     boolean enabled = true;
 
     if (visible) {
       try (Repository repo = repoManager.openRepository(dest.getParentKey());
           RevWalk rw = new RevWalk(repo)) {
         visible = hasOneParent(rw, resource.getPatchSet());
-        enabled =
-            RebaseUtil.canRebase(patchSet, dest, repo, rw, dbProvider.get());
+        enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
       } catch (IOException e) {
         log.error("Failed to check if patch set can be rebased: "
             + resource.getPatchSet(), e);
@@ -246,7 +218,7 @@
     @Override
     public ChangeInfo apply(ChangeResource rsrc, RebaseInput input)
         throws EmailException, OrmException, UpdateException, RestApiException,
-        IOException {
+        IOException, NoSuchChangeException {
       PatchSet ps =
           rebase.dbProvider.get().patchSets()
               .get(rsrc.getChange().currentPatchSetId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index 1420b6c..baa0990 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -79,14 +80,17 @@
 
     private final ChangeEditModifier editModifier;
     private final ChangeEditUtil editUtil;
+    private final PatchSetUtil psUtil;
     private final Provider<ReviewDb> db;
 
     @Inject
     Rebase(ChangeEditModifier editModifier,
         ChangeEditUtil editUtil,
+        PatchSetUtil psUtil,
         Provider<ReviewDb> db) {
       this.editModifier = editModifier;
       this.editUtil = editUtil;
+      this.psUtil = psUtil;
       this.db = db;
     }
 
@@ -101,8 +105,7 @@
             rsrc.getChange().getChangeId()));
       }
 
-      PatchSet current = db.get().patchSets().get(
-          rsrc.getChange().currentPatchSetId());
+      PatchSet current = psUtil.current(db.get(), rsrc.getNotes());
       if (current.getId().equals(edit.get().getBasePatchSet().getId())) {
         throw new ResourceConflictException(String.format(
             "edit for change %s is already on latest patch set: %s",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index e737293..20dbfb3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -31,9 +32,9 @@
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -45,8 +46,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 
 public class RebaseChangeOp extends BatchUpdate.Op {
   public interface Factory {
@@ -56,15 +55,19 @@
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtil.Factory mergeUtilFactory;
+  private final RebaseUtil rebaseUtil;
+  private final ChangeResource.Factory changeResourceFactory;
 
   private final ChangeControl ctl;
   private final PatchSet originalPatchSet;
 
   private String baseCommitish;
   private PersonIdent committerIdent;
-  private boolean runHooks = true;
+  private boolean fireRevisionCreated = true;
   private CommitValidators.Policy validate;
+  private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
+  private boolean copyApprovals = true;
 
   private RevCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -75,11 +78,15 @@
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
       @Assisted ChangeControl ctl,
       @Assisted PatchSet originalPatchSet,
       @Assisted @Nullable String baseCommitish) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.changeResourceFactory = changeResourceFactory;
     this.ctl = ctl;
     this.originalPatchSet = originalPatchSet;
     this.baseCommitish = baseCommitish;
@@ -95,8 +102,14 @@
     return this;
   }
 
-  public RebaseChangeOp setRunHooks(boolean runHooks) {
-    this.runHooks = runHooks;
+  public RebaseChangeOp setCheckAddPatchSetPermission(
+      boolean checkAddPatchSetPermission) {
+    this.checkAddPatchSetPermission = checkAddPatchSetPermission;
+    return this;
+  }
+
+  public RebaseChangeOp setFireRevisionCreated(boolean fireRevisionCreated) {
+    this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
@@ -105,10 +118,15 @@
     return this;
   }
 
+  public RebaseChangeOp setCopyApprovals(boolean copyApprovals) {
+    this.copyApprovals = copyApprovals;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx) throws MergeConflictException,
        InvalidChangeOperationException, RestApiException, IOException,
-       OrmException {
+       OrmException, NoSuchChangeException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevId oldRev = originalPatchSet.getRevision();
@@ -120,39 +138,36 @@
     if (baseCommitish != null) {
        baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
     } else {
-       baseCommit = rw.parseCommit(RebaseUtil.findBaseRevision(
+       baseCommit = rw.parseCommit(rebaseUtil.findBaseRevision(
            originalPatchSet, ctl.getChange().getDest(),
-           ctx.getRepository(), ctx.getRevWalk(), ctx.getDb()));
+           ctx.getRepository(), ctx.getRevWalk()));
     }
 
-    ObjectId newId = rebaseCommit(ctx, original, baseCommit);
-    rebasedCommit = rw.parseCommit(newId);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit);
 
-    List<String> baseCommitPatchSetGroups = new ArrayList<>();
-    List<String> groups;
-    RevId patchSetByRev = new RevId((baseCommitish != null) ? baseCommitish
+    RevId baseRevId = new RevId((baseCommitish != null) ? baseCommitish
         : ObjectId.toString(baseCommit.getId()));
-    ResultSet<PatchSet> relatedPatchSets =
-        ctx.getDb().patchSets().byRevision(patchSetByRev);
-    for (PatchSet ps : relatedPatchSets) {
-      groups = ps.getGroups();
-      if (groups != null) {
-        baseCommitPatchSetGroups.addAll(groups);
-      }
-    }
+    Base base = rebaseUtil.parseBase(
+        new RevisionResource(
+            changeResourceFactory.create(ctl), originalPatchSet),
+        baseRevId.get());
 
     rebasedPatchSetId = ChangeUtil.nextPatchSetId(
         ctx.getRepository(), ctl.getChange().currentPatchSetId());
     patchSetInserter = patchSetInserterFactory
-        .create(ctl.getRefControl(), rebasedPatchSetId, rebasedCommit)
-        .setGroups(baseCommitPatchSetGroups)
+        .create(ctl, rebasedPatchSetId, rebasedCommit)
         .setDraft(originalPatchSet.isDraft())
-        .setUploader(ctx.getUser().getAccountId())
         .setSendMail(false)
-        .setRunHooks(runHooks)
+        .setFireRevisionCreated(fireRevisionCreated)
+        .setCopyApprovals(copyApprovals)
+        .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
         .setMessage(
           "Patch Set " + rebasedPatchSetId.get()
           + ": Patch Set " + originalPatchSet.getId().get() + " was rebased");
+
+    if (base != null) {
+      patchSetInserter.setGroups(base.patchSet().getGroups());
+    }
     if (validate != null) {
       patchSetInserter.setValidatePolicy(validate);
     }
@@ -160,10 +175,11 @@
   }
 
   @Override
-  public void updateChange(ChangeContext ctx)
-      throws OrmException, InvalidChangeOperationException {
-    patchSetInserter.updateChange(ctx);
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, OrmException, IOException {
+    boolean ret = patchSetInserter.updateChange(ctx);
     rebasedPatchSet = patchSetInserter.getPatchSet();
+    return ret;
   }
 
   @Override
@@ -171,6 +187,18 @@
     patchSetInserter.postUpdate(ctx);
   }
 
+  public RevCommit getRebasedCommit() {
+    checkState(rebasedCommit != null,
+        "getRebasedCommit() only valid after updateRepo");
+    return rebasedCommit;
+  }
+
+  public PatchSet.Id getPatchSetId() {
+    checkState(rebasedPatchSetId != null,
+        "getPatchSetId() only valid after updateRepo");
+    return rebasedPatchSetId;
+  }
+
   public PatchSet getPatchSet() {
     checkState(rebasedPatchSet != null,
         "getPatchSet() only valid after executing update");
@@ -221,7 +249,7 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.getUser().asIdentifiedUser()
+      cb.setCommitter(ctx.getIdentifiedUser()
           .newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
index aea49f1..0956f9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -23,7 +25,12 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -42,35 +49,26 @@
 public class RebaseUtil {
   private static final Logger log = LoggerFactory.getLogger(RebaseUtil.class);
 
-  private final Provider<ReviewDb> db;
-  private final GitRepositoryManager gitManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory notesFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final PatchSetUtil psUtil;
 
   @Inject
-  RebaseUtil(Provider<ReviewDb> db,
-      GitRepositoryManager gitManager) {
-    this.db = db;
-    this.gitManager = gitManager;
+  RebaseUtil(Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider,
+      PatchSetUtil psUtil) {
+    this.queryProvider = queryProvider;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+    this.psUtil = psUtil;
   }
 
-  public boolean canRebase(RevisionResource r) {
-    PatchSet patchSet = r.getPatchSet();
-    Branch.NameKey dest = r.getChange().getDest();
-    try (Repository git = gitManager.openRepository(dest.getParentKey());
-        RevWalk rw = new RevWalk(git)) {
-      return canRebase(
-          r.getPatchSet(), dest, git, rw, db.get());
-    } catch (IOException e) {
-      log.warn(String.format(
-          "Error checking if patch set %s on %s can be rebased",
-          patchSet.getId(), dest), e);
-      return false;
-    }
-  }
-
-  public static boolean canRebase(PatchSet patchSet, Branch.NameKey dest,
-      Repository git, RevWalk rw, ReviewDb db) {
+  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest,
+      Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw, db);
+      findBaseRevision(patchSet, dest, git, rw);
       return true;
     } catch (RestApiException e) {
       return false;
@@ -82,6 +80,72 @@
     }
   }
 
+  @AutoValue
+  abstract static class Base {
+    private static Base create(ChangeControl ctl, PatchSet ps) {
+      if (ctl == null) {
+        return null;
+      }
+      return new AutoValue_RebaseUtil_Base(ctl, ps);
+    }
+
+    abstract ChangeControl control();
+    abstract PatchSet patchSet();
+  }
+
+  Base parseBase(RevisionResource rsrc, String base)
+      throws OrmException, NoSuchChangeException {
+    ReviewDb db = dbProvider.get();
+
+    // Try parsing the base as a ref string.
+    PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
+    if (basePatchSetId != null) {
+      Change.Id baseChangeId = basePatchSetId.getParentKey();
+      ChangeControl baseCtl = controlFor(rsrc, baseChangeId);
+      if (baseCtl != null) {
+        return Base.create(
+            controlFor(rsrc, basePatchSetId.getParentKey()),
+            psUtil.get(db, baseCtl.getNotes(), basePatchSetId));
+      }
+    }
+
+    // Try parsing base as a change number (assume current patch set).
+    Integer baseChangeId = Ints.tryParse(base);
+    if (baseChangeId != null) {
+      ChangeControl baseCtl = controlFor(rsrc, new Change.Id(baseChangeId));
+      if (baseCtl != null) {
+        return Base.create(baseCtl, psUtil.current(db, baseCtl.getNotes()));
+      }
+    }
+
+    // Try parsing as SHA-1.
+    Base ret = null;
+    for (ChangeData cd : queryProvider.get()
+        .byProjectCommit(rsrc.getProject(), base)) {
+      for (PatchSet ps : cd.patchSets()) {
+        if (!ps.getRevision().matches(base)) {
+          continue;
+        }
+        if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
+          ret = Base.create(
+              rsrc.getControl().getProjectControl().controlFor(cd.notes()),
+              ps);
+        }
+      }
+    }
+    return ret;
+  }
+
+  private ChangeControl controlFor(RevisionResource rsrc, Change.Id id)
+      throws OrmException, NoSuchChangeException {
+    if (rsrc.getChange().getId().equals(id)) {
+      return rsrc.getControl();
+    }
+    ChangeNotes notes =
+        notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
+    return rsrc.getControl().getProjectControl().controlFor(notes);
+  }
+
   /**
    * Find the commit onto which a patch set should be rebased.
    * <p>
@@ -98,8 +162,8 @@
    * @throws IOException if accessing the repository fails.
    * @throws OrmException if accessing the database fails.
    */
-  static ObjectId findBaseRevision(PatchSet patchSet,
-      Branch.NameKey destBranch, Repository git, RevWalk rw, ReviewDb db)
+  ObjectId findBaseRevision(PatchSet patchSet, Branch.NameKey destBranch,
+      Repository git, RevWalk rw)
       throws RestApiException, IOException, OrmException {
     String baseRev = null;
     RevCommit commit = rw.parseCommit(
@@ -116,30 +180,29 @@
 
     RevId parentRev = new RevId(commit.getParent(0).name());
 
-    for (PatchSet depPatchSet : db.patchSets().byRevision(parentRev)) {
-      Change.Id depChangeId = depPatchSet.getId().getParentKey();
-      Change depChange = db.changes().get(depChangeId);
-      if (!depChange.getDest().equals(destBranch)) {
-        continue;
-      }
-
-      if (depChange.getStatus() == Status.ABANDONED) {
-        throw new ResourceConflictException(
-            "Cannot rebase a change with an abandoned parent: "
-            + depChange.getKey());
-      }
-
-      if (depChange.getStatus().isOpen()) {
-        if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
-          throw new ResourceConflictException(
-              "Change is already based on the latest patch set of the"
-              + " dependent change.");
+    CHANGES: for (ChangeData cd : queryProvider.get()
+        .byBranchCommit(destBranch, parentRev.get())) {
+      for (PatchSet depPatchSet : cd.patchSets()) {
+        if (!depPatchSet.getRevision().equals(parentRev)) {
+          continue;
         }
-        PatchSet latestDepPatchSet =
-            db.patchSets().get(depChange.currentPatchSetId());
-        baseRev = latestDepPatchSet.getRevision().get();
+        Change depChange = cd.change();
+        if (depChange.getStatus() == Status.ABANDONED) {
+          throw new ResourceConflictException(
+              "Cannot rebase a change with an abandoned parent: "
+              + depChange.getKey());
+        }
+
+        if (depChange.getStatus().isOpen()) {
+          if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
+            throw new ResourceConflictException(
+                "Change is already based on the latest patch set of the"
+                + " dependent change.");
+          }
+          baseRev = cd.currentPatchSet().getRevision().get();
+        }
+        break CHANGES;
       }
-      break;
     }
 
     if (baseRev == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
new file mode 100644
index 0000000..5fe0e0b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.Rebuild.Input;
+import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+
+@Singleton
+public class Rebuild implements RestModifyView<ChangeResource, Input> {
+  public static class Input {
+  }
+
+  private final Provider<ReviewDb> db;
+  private final NotesMigration migration;
+  private final ChangeRebuilder rebuilder;
+
+  @Inject
+  Rebuild(Provider<ReviewDb> db,
+      NotesMigration migration,
+      ChangeRebuilder rebuilder) {
+    this.db = db;
+    this.migration = migration;
+    this.rebuilder = rebuilder;
+  }
+
+  @Override
+  public Response<?> apply(ChangeResource rsrc, Input input)
+      throws ResourceNotFoundException, IOException, OrmException,
+      ConfigInvalidException {
+    if (!migration.commitChangeWrites()) {
+      throw new ResourceNotFoundException();
+    }
+    try {
+      rebuilder.rebuild(db.get(), rsrc.getId());
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(
+          IdString.fromDecoded(rsrc.getId().toString()));
+    }
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 8cd82a9..74d7552 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -222,7 +222,7 @@
       throws OrmException {
     // Reuse existing project control rather than lazily creating a new one for
     // each ChangeData.
-    return ctl.controlFor(psd.data().change())
+    return ctl.controlFor(psd.data().notes())
         .isPatchVisible(psd.patchSet(), psd.data());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index 5b0eb6d..9c4c6d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -31,13 +30,15 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.mail.ReplyToChangeSender;
 import com.google.gerrit.server.mail.RestoredSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -47,47 +48,48 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-
 @Singleton
 public class Restore implements RestModifyView<ChangeResource, RestoreInput>,
     UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Restore.class);
 
-  private final ChangeHooks hooks;
   private final RestoredSender.Factory restoredSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeRestored changeRestored;
 
   @Inject
-  Restore(ChangeHooks hooks,
-      RestoredSender.Factory restoredSenderFactory,
+  Restore(RestoredSender.Factory restoredSenderFactory,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
-    this.hooks = hooks;
+      PatchSetUtil psUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeRestored changeRestored) {
     this.restoredSenderFactory = restoredSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.changeRestored = changeRestored;
   }
 
   @Override
   public ChangeInfo apply(ChangeResource req, RestoreInput input)
       throws RestApiException, UpdateException, OrmException {
     ChangeControl ctl = req.getControl();
-    if (!ctl.canRestore()) {
+    if (!ctl.canRestore(dbProvider.get())) {
       throw new AuthException("restore not permitted");
     }
 
     Op op = new Op(input);
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), op).execute();
+      u.addOp(req.getId(), op).execute();
     }
     return json.create(ChangeJson.NO_OPTIONS).format(op.change);
   }
@@ -98,30 +100,31 @@
     private Change change;
     private PatchSet patchSet;
     private ChangeMessage message;
-    private IdentifiedUser caller;
 
     private Op(RestoreInput input) {
       this.input = input;
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException,
+    public boolean updateChange(ChangeContext ctx) throws OrmException,
         ResourceConflictException {
-      caller = ctx.getUser().asIdentifiedUser();
       change = ctx.getChange();
       if (change == null || change.getStatus() != Status.ABANDONED) {
         throw new ResourceConflictException("change is " + status(change));
       }
-      patchSet = ctx.getDb().patchSets().get(change.currentPatchSetId());
+      PatchSet.Id psId = change.currentPatchSetId();
+      ChangeUpdate update = ctx.getUpdate(psId);
+      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       change.setStatus(Status.NEW);
       change.setLastUpdatedOn(ctx.getWhen());
-      ctx.getDb().changes().update(Collections.singleton(change));
+      update.setStatus(change.getStatus());
 
-      message = newMessage(ctx.getDb());
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(), message);
+      message = newMessage(ctx);
+      cmUtil.addChangeMessage(ctx.getDb(), update, message);
+      return true;
     }
 
-    private ChangeMessage newMessage(ReviewDb db) throws OrmException {
+    private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
       StringBuilder msg = new StringBuilder();
       msg.append("Restored");
       if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
@@ -132,9 +135,9 @@
       ChangeMessage message = new ChangeMessage(
           new ChangeMessage.Key(
               change.getId(),
-              ChangeUtil.messageUUID(db)),
-          caller.getAccountId(),
-          change.getLastUpdatedOn(),
+              ChangeUtil.messageUUID(ctx.getDb())),
+          ctx.getAccountId(),
+          ctx.getWhen(),
           change.currentPatchSetId());
       message.setMessage(msg.toString());
       return message;
@@ -143,28 +146,34 @@
     @Override
     public void postUpdate(Context ctx) throws OrmException {
       try {
-        ReplyToChangeSender cm = restoredSenderFactory.create(change.getId());
-        cm.setFrom(caller.getAccountId());
-        cm.setChangeMessage(message);
+        ReplyToChangeSender cm =
+            restoredSenderFactory.create(ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
+        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
         cm.send();
       } catch (Exception e) {
         log.error("Cannot email update for change " + change.getId(), e);
       }
-      hooks.doChangeRestoredHook(change,
-          caller.getAccount(),
-          patchSet,
+      changeRestored.fire(change, patchSet,
+          ctx.getAccount(),
           Strings.emptyToNull(input.message),
-          ctx.getDb());
+          ctx.getWhen());
     }
   }
 
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
+    boolean canRestore = false;
+    try {
+      canRestore = resource.getControl().canRestore(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canRestore status. Assuming false.", e);
+    }
     return new UiAction.Description()
       .setLabel("Restore")
       .setTitle("Restore the change")
       .setVisible(resource.getChange().getStatus() == Status.ABANDONED
-          && resource.getControl().canRestore());
+          && canRestore);
   }
 
   private static String status(Change change) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index dc2ed5d..3ca496a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -24,59 +25,204 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.mail.RevertedSender;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.HashSet;
+import java.util.Set;
 
 @Singleton
 public class Revert implements RestModifyView<ChangeResource, RevertInput>,
     UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Revert.class);
+
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager repoManager;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final BatchUpdate.Factory updateFactory;
+  private final Sequences seq;
+  private final PatchSetUtil psUtil;
+  private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeJson.Factory json;
-  private final ChangeUtil changeUtil;
-  private final PersonIdent myIdent;
+  private final PersonIdent serverIdent;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeReverted changeReverted;
 
   @Inject
-  Revert(ChangeJson.Factory json,
-      ChangeUtil changeUtil,
-      @GerritPersonIdent PersonIdent myIdent) {
+  Revert(Provider<ReviewDb> db,
+      GitRepositoryManager repoManager,
+      ChangeInserter.Factory changeInserterFactory,
+      ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory updateFactory,
+      Sequences seq,
+      PatchSetUtil psUtil,
+      RevertedSender.Factory revertedSenderFactory,
+      ChangeJson.Factory json,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ApprovalsUtil approvalsUtil,
+      ChangeReverted changeReverted) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.changeInserterFactory = changeInserterFactory;
+    this.cmUtil = cmUtil;
+    this.updateFactory = updateFactory;
+    this.seq = seq;
+    this.psUtil = psUtil;
+    this.revertedSenderFactory = revertedSenderFactory;
     this.json = json;
-    this.changeUtil = changeUtil;
-    this.myIdent = myIdent;
+    this.serverIdent = serverIdent;
+    this.approvalsUtil = approvalsUtil;
+    this.changeReverted = changeReverted;
   }
 
   @Override
   public ChangeInfo apply(ChangeResource req, RevertInput input)
       throws IOException, OrmException, RestApiException,
-      UpdateException {
-    ChangeControl control = req.getControl();
+      UpdateException, NoSuchChangeException {
+    RefControl refControl = req.getControl().getRefControl();
+    ProjectControl projectControl = req.getControl().getProjectControl();
+
+    Capable capable = projectControl.canPushToAtLeastOneRef();
+    if (capable != Capable.OK) {
+      throw new AuthException(capable.getMessage());
+    }
+
     Change change = req.getChange();
-    if (!control.canAddPatchSet()) {
+    if (!refControl.canUpload()) {
       throw new AuthException("revert not permitted");
     } else if (change.getStatus() != Status.MERGED) {
       throw new ResourceConflictException("change is " + status(change));
     }
 
-    Change.Id revertedChangeId;
-    try {
-      revertedChangeId = changeUtil.revert(control,
-            change.currentPatchSetId(),
-            Strings.emptyToNull(input.message),
-            new PersonIdent(myIdent, TimeUtil.nowTs()));
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
+    Change.Id revertedChangeId =
+        revert(req.getControl(), Strings.emptyToNull(input.message));
+    return json.create(ChangeJson.NO_OPTIONS).format(req.getProject(),
+        revertedChangeId);
+  }
+
+  private Change.Id revert(ChangeControl ctl, String message)
+      throws OrmException, IOException, RestApiException, UpdateException {
+    Change.Id changeIdToRevert = ctl.getChange().getId();
+    PatchSet.Id patchSetId = ctl.getChange().currentPatchSetId();
+    PatchSet patch = psUtil.get(db.get(), ctl.getNotes(), patchSetId);
+    if (patch == null) {
+      throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
-    return json.create(ChangeJson.NO_OPTIONS).format(revertedChangeId);
+
+    Project.NameKey project = ctl.getProject().getNameKey();
+    CurrentUser user = ctl.getUser();
+    try (Repository git = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(git)) {
+      RevCommit commitToRevert =
+          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      if (commitToRevert.getParentCount() == 0) {
+        throw new ResourceConflictException("Cannot revert initial commit");
+      }
+
+      Timestamp now = TimeUtil.nowTs();
+      PersonIdent committerIdent = new PersonIdent(serverIdent, now);
+      PersonIdent authorIdent = user.asIdentifiedUser()
+          .newCommitterIdent(now, committerIdent.getTimeZone());
+
+      RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
+      revWalk.parseHeaders(parentToCommitToRevert);
+
+      CommitBuilder revertCommitBuilder = new CommitBuilder();
+      revertCommitBuilder.addParentId(commitToRevert);
+      revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
+      revertCommitBuilder.setAuthor(authorIdent);
+      revertCommitBuilder.setCommitter(authorIdent);
+
+      Change changeToRevert = ctl.getChange();
+      if (message == null) {
+        message = MessageFormat.format(
+            ChangeMessages.get().revertChangeDefaultMessage,
+            changeToRevert.getSubject(), patch.getRevision().get());
+      }
+
+      ObjectId computedChangeId =
+          ChangeIdUtil.computeChangeId(parentToCommitToRevert.getTree(),
+              commitToRevert, authorIdent, committerIdent, message);
+      revertCommitBuilder.setMessage(
+          ChangeIdUtil.insertId(message, computedChangeId, true));
+
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      try (ObjectInserter oi = git.newObjectInserter()) {
+        ObjectId id = oi.insert(revertCommitBuilder);
+        oi.flush();
+        RevCommit revertCommit = revWalk.parseCommit(id);
+
+        ChangeInserter ins = changeInserterFactory.create(
+            changeId, revertCommit, ctl.getChange().getDest().get())
+            .setValidatePolicy(CommitValidators.Policy.GERRIT)
+            .setTopic(changeToRevert.getTopic());
+        ins.setMessage("Uploaded patch set 1.");
+
+        Set<Account.Id> reviewers = new HashSet<>();
+        reviewers.add(changeToRevert.getOwner());
+        reviewers.addAll(
+            approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
+        reviewers.remove(user.getAccountId());
+        ins.setReviewers(reviewers);
+
+        try (BatchUpdate bu = updateFactory.create(
+            db.get(), project, user, now)) {
+          bu.setRepository(git, revWalk, oi);
+          bu.insertChange(ins);
+          bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
+          bu.addOp(changeToRevert.getId(),
+              new PostRevertedMessageOp(computedChangeId));
+          bu.execute();
+        }
+      }
+      return changeId;
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(changeIdToRevert.toString(), e);
+    }
   }
 
   @Override
@@ -90,5 +236,55 @@
 
   private static String status(Change change) {
     return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-   }
- }
+  }
+
+  private class NotifyOp extends BatchUpdate.Op {
+    private final Change change;
+    private final ChangeInserter ins;
+
+    NotifyOp(Change change, ChangeInserter ins) {
+      this.change = change;
+      this.ins = ins;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+      Change.Id changeId = ins.getChange().getId();
+      try {
+        RevertedSender cm =
+            revertedSenderFactory.create(ctx.getProject(), changeId);
+        cm.setFrom(ctx.getAccountId());
+        cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
+        cm.send();
+      } catch (Exception err) {
+        log.error("Cannot send email for revert change " + changeId, err);
+      }
+    }
+  }
+
+  private class PostRevertedMessageOp extends BatchUpdate.Op {
+    private final ObjectId computedChangeId;
+
+    PostRevertedMessageOp(ObjectId computedChangeId) {
+      this.computedChangeId = computedChangeId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+      PatchSet.Id patchSetId = change.currentPatchSetId();
+      ChangeMessage changeMessage = new ChangeMessage(
+          new ChangeMessage.Key(change.getId(),
+              ChangeUtil.messageUUID(db.get())),
+          ctx.getAccountId(), ctx.getWhen(), patchSetId);
+      StringBuilder msgBuf = new StringBuilder();
+      msgBuf.append("Created a revert of this change as ")
+          .append("I").append(computedChangeId.name());
+      changeMessage.setMessage(msgBuf.toString());
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId),
+          changeMessage);
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
index 120a414..997a8f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
@@ -14,79 +14,60 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.util.Collections;
-
 public class Reviewed {
   public static class Input {
   }
 
   @Singleton
-  public static class PutReviewed implements RestModifyView<FileResource, Input> {
-    private final Provider<ReviewDb> dbProvider;
+  public static class PutReviewed
+      implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    PutReviewed(Provider<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
+    PutReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
     public Response<String> apply(FileResource resource, Input input)
         throws OrmException {
-      ReviewDb db = dbProvider.get();
-      AccountPatchReview apr = getExisting(db, resource);
-      if (apr == null) {
-        try {
-          db.accountPatchReviews().insert(
-              Collections.singleton(new AccountPatchReview(resource.getPatchKey(),
-                  resource.getAccountId())));
-        } catch (OrmDuplicateKeyException e) {
-          return Response.ok("");
-        }
+      if (accountPatchReviewStore.get().markReviewed(
+          resource.getPatchKey().getParentKey(), resource.getAccountId(),
+          resource.getPatchKey().getFileName())) {
         return Response.created("");
-      } else {
-        return Response.ok("");
       }
+      return Response.ok("");
     }
   }
 
   @Singleton
-  public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
-    private final Provider<ReviewDb> dbProvider;
+  public static class DeleteReviewed
+      implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    DeleteReviewed(Provider<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
+    DeleteReviewed(
+        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
     public Response<?> apply(FileResource resource, Input input)
         throws OrmException {
-      ReviewDb db = dbProvider.get();
-      AccountPatchReview apr = getExisting(db, resource);
-      if (apr != null) {
-        db.accountPatchReviews().delete(Collections.singleton(apr));
-      }
+      accountPatchReviewStore.get().clearReviewed(
+          resource.getPatchKey().getParentKey(), resource.getAccountId(),
+          resource.getPatchKey().getFileName());
       return Response.none();
     }
   }
 
-  private static AccountPatchReview getExisting(ReviewDb db,
-      FileResource resource) throws OrmException {
-    AccountPatchReview.Key key = new AccountPatchReview.Key(
-        resource.getPatchKey(), resource.getAccountId());
-    return db.accountPatchReviews().get(key);
-  }
-
   private Reviewed() {
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index 7c10b11..69cd439 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -40,7 +40,6 @@
 
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.TreeMap;
 
 @Singleton
@@ -67,8 +66,8 @@
     AccountLoader loader = accountLoaderFactory.create(true);
     for (ReviewerResource rsrc : rsrcs) {
       ReviewerInfo info = format(new ReviewerInfo(
-          rsrc.getUser().getAccountId()),
-          rsrc.getUserControl());
+          rsrc.getReviewerUser().getAccountId().get()),
+          rsrc.getReviewerControl());
       loader.put(info);
       infos.add(info);
     }
@@ -110,7 +109,6 @@
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
       for (SubmitRecord rec : new SubmitRuleEvaluator(cd)
-          .setPatchSet(ps)
           .setFastEvalLabels(true)
           .setAllowDraft(true)
           .evaluate()) {
@@ -133,18 +131,4 @@
 
     return out;
   }
-
-  public static class ReviewerInfo extends AccountInfo {
-    Map<String, String> approvals;
-
-    protected ReviewerInfo(Account.Id id) {
-      super(id.get());
-    }
-  }
-
-  public static class PostResult {
-    public List<ReviewerInfo> reviewers;
-    public String error;
-    Boolean confirm;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index f7b5228..aac9252 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,48 +14,64 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
-public class ReviewerResource extends ChangeResource {
+public class ReviewerResource implements RestResource {
   public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
       new TypeLiteral<RestView<ReviewerResource>>() {};
 
-  public static interface Factory {
-    ReviewerResource create(ChangeResource rsrc, IdentifiedUser user);
-    ReviewerResource create(ChangeResource rsrc, Account.Id id);
+  public interface Factory {
+    ReviewerResource create(ChangeResource change, Account.Id id);
   }
 
+  private final ChangeResource change;
   private final IdentifiedUser user;
 
   @AssistedInject
-  ReviewerResource(@Assisted ChangeResource rsrc,
-      @Assisted IdentifiedUser user) {
-    super(rsrc);
-    this.user = user;
-  }
-
-  @AssistedInject
   ReviewerResource(IdentifiedUser.GenericFactory userFactory,
-      @Assisted ChangeResource rsrc,
+      @Assisted ChangeResource change,
       @Assisted Account.Id id) {
-    this(rsrc, userFactory.create(id));
+    this.change = change;
+    this.user = userFactory.create(id);
   }
 
-  public IdentifiedUser getUser() {
+  public ChangeResource getChangeResource() {
+    return change;
+  }
+
+  public Change.Id getChangeId() {
+    return change.getId();
+  }
+
+  public Change getChange() {
+    return change.getChange();
+  }
+
+  public IdentifiedUser getReviewerUser() {
     return user;
   }
 
   /**
+   * @return the control for the caller's user (as opposed to the reviewer's
+   *     user as returned by {@link #getReviewerControl()}).
+   */
+  public ChangeControl getControl() {
+    return change.getControl();
+  }
+
+  /**
    * @return the control for the reviewer's user (as opposed to the caller's
    *     user as returned by {@link #getControl()}).
    */
-  public ChangeControl getUserControl() {
-    return getControl().forUser(user);
+  public ChangeControl getReviewerControl() {
+    return change.getControl().forUser(user);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestionCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestionCache.java
deleted file mode 100644
index 20078cc..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestionCache.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.Splitter;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.AccountExternalIdAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.apache.lucene.analysis.standard.StandardAnalyzer;
-import org.apache.lucene.analysis.util.CharArraySet;
-import org.apache.lucene.document.Document;
-import org.apache.lucene.document.Field.Store;
-import org.apache.lucene.document.IntField;
-import org.apache.lucene.document.StringField;
-import org.apache.lucene.document.TextField;
-import org.apache.lucene.index.DirectoryReader;
-import org.apache.lucene.index.IndexWriter;
-import org.apache.lucene.index.IndexWriterConfig;
-import org.apache.lucene.index.IndexWriterConfig.OpenMode;
-import org.apache.lucene.index.IndexableField;
-import org.apache.lucene.index.Term;
-import org.apache.lucene.search.BooleanClause.Occur;
-import org.apache.lucene.search.BooleanQuery;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.PrefixQuery;
-import org.apache.lucene.search.ScoreDoc;
-import org.apache.lucene.search.TopDocs;
-import org.apache.lucene.store.RAMDirectory;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-/**
- * The suggest oracle may be called many times in rapid succession during the
- * course of one operation.
- * It would be easy to have a simple {@code Cache<Boolean, List<Account>>}
- * with a short expiration time of 30s.
- * Cache only has a single key we're just using Cache for the expiration behavior.
- */
-@Singleton
-public class ReviewerSuggestionCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(ReviewerSuggestionCache.class);
-
-  private static final String ID = "id";
-  private static final String NAME = "name";
-  private static final String EMAIL = "email";
-  private static final String USERNAME = "username";
-  private static final String[] ALL = {ID, NAME, EMAIL, USERNAME};
-
-  private final LoadingCache<Boolean, IndexSearcher> cache;
-  private final Provider<ReviewDb> db;
-
-  @Inject
-  ReviewerSuggestionCache(Provider<ReviewDb> db,
-      @GerritServerConfig Config cfg) {
-    this.db = db;
-    long expiration = ConfigUtil.getTimeUnit(cfg,
-        "suggest", null, "fullTextSearchRefresh",
-        TimeUnit.HOURS.toMillis(1),
-        TimeUnit.MILLISECONDS);
-    this.cache =
-        CacheBuilder.newBuilder().maximumSize(1)
-            .refreshAfterWrite(expiration, TimeUnit.MILLISECONDS)
-            .build(new CacheLoader<Boolean, IndexSearcher>() {
-              @Override
-              public IndexSearcher load(Boolean key) throws Exception {
-                return index();
-              }
-            });
-  }
-
-  public List<AccountInfo> search(String query, int n) throws IOException {
-    IndexSearcher searcher = get();
-    if (searcher == null) {
-      return Collections.emptyList();
-    }
-
-    List<String> segments = Splitter.on(' ').omitEmptyStrings().splitToList(
-        query.toLowerCase());
-    BooleanQuery.Builder q = new BooleanQuery.Builder();
-    for (String field : ALL) {
-      BooleanQuery.Builder and = new BooleanQuery.Builder();
-      for (String s : segments) {
-        and.add(new PrefixQuery(new Term(field, s)), Occur.MUST);
-      }
-      q.add(and.build(), Occur.SHOULD);
-    }
-
-    TopDocs results = searcher.search(q.build(), n);
-    ScoreDoc[] hits = results.scoreDocs;
-
-    List<AccountInfo> result = new LinkedList<>();
-
-    for (ScoreDoc h : hits) {
-      Document doc = searcher.doc(h.doc);
-
-      IndexableField idField = checkNotNull(doc.getField(ID));
-      AccountInfo info = new AccountInfo(idField.numericValue().intValue());
-      info.name = doc.get(NAME);
-      info.email = doc.get(EMAIL);
-      info.username = doc.get(USERNAME);
-      result.add(info);
-    }
-
-    return result;
-  }
-
-  private IndexSearcher get() {
-    try {
-      return cache.get(true);
-    } catch (ExecutionException e) {
-      log.warn("Cannot fetch reviewers from cache", e);
-      return null;
-    }
-  }
-
-  private IndexSearcher index() throws IOException, OrmException {
-    RAMDirectory idx = new RAMDirectory();
-    IndexWriterConfig config = new IndexWriterConfig(
-        new StandardAnalyzer(CharArraySet.EMPTY_SET));
-    config.setOpenMode(OpenMode.CREATE);
-
-    try (IndexWriter writer = new IndexWriter(idx, config)) {
-      for (Account a : db.get().accounts().all()) {
-        if (a.isActive()) {
-          addAccount(writer, a);
-        }
-      }
-    }
-
-    return new IndexSearcher(DirectoryReader.open(idx));
-  }
-
-  private void addAccount(IndexWriter writer, Account a)
-      throws IOException, OrmException {
-    Document doc = new Document();
-    doc.add(new IntField(ID, a.getId().get(), Store.YES));
-    if (a.getFullName() != null) {
-      doc.add(new TextField(NAME, a.getFullName(), Store.YES));
-    }
-    if (a.getPreferredEmail() != null) {
-      doc.add(new TextField(EMAIL, a.getPreferredEmail(), Store.YES));
-      doc.add(new StringField(EMAIL, a.getPreferredEmail().toLowerCase(),
-          Store.YES));
-    }
-    AccountExternalIdAccess extIdAccess = db.get().accountExternalIds();
-    String username = AccountState.getUserName(
-        extIdAccess.byAccount(a.getId()).toList());
-    if (username != null) {
-      doc.add(new StringField(USERNAME, username, Store.YES));
-    }
-    writer.addDocument(doc);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 74a8866..d45d260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -83,6 +83,6 @@
   private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc)
       throws OrmException {
     return approvalsUtil.getReviewers(
-        dbProvider.get(), rsrc.getNotes()).values();
+        dbProvider.get(), rsrc.getNotes()).all();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index 6731dd9..a8fd013 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -21,7 +21,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
@@ -63,6 +64,10 @@
     return getControl().getChange();
   }
 
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
   public ChangeNotes getNotes() {
     return getChangeResource().getNotes();
   }
@@ -83,8 +88,8 @@
     return getUser().getAccountId();
   }
 
-  IdentifiedUser getUser() {
-    return getControl().getUser().asIdentifiedUser();
+  CurrentUser getUser() {
+    return getControl().getUser();
   }
 
   RevisionResource doNotCache() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index bb5775b..30a09cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,19 +23,19 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
@@ -47,14 +44,17 @@
   private final DynamicMap<RestView<RevisionResource>> views;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeEditUtil editUtil;
+  private final PatchSetUtil psUtil;
 
   @Inject
   Revisions(DynamicMap<RestView<RevisionResource>> views,
       Provider<ReviewDb> dbProvider,
-      ChangeEditUtil editUtil) {
+      ChangeEditUtil editUtil,
+      PatchSetUtil psUtil) {
     this.views = views;
     this.dbProvider = dbProvider;
     this.editUtil = editUtil;
+    this.psUtil = psUtil;
   }
 
   @Override
@@ -72,8 +72,7 @@
       throws ResourceNotFoundException, AuthException, OrmException,
       IOException {
     if (id.equals("current")) {
-      PatchSet.Id p = change.getChange().currentPatchSetId();
-      PatchSet ps = p != null ? dbProvider.get().patchSets().get(p) : null;
+      PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
       if (ps != null && visible(change, ps)) {
         return new RevisionResource(change, ps).doNotCache();
       }
@@ -105,7 +104,7 @@
 
   private List<RevisionResource> find(ChangeResource change, String id)
       throws OrmException, IOException, AuthException {
-    if (id.equals("0")) {
+    if (id.equals("0") || id.equals("edit")) {
       return loadEdit(change, null);
     } else if (id.length() < 6 && id.matches("^[1-9][0-9]{0,4}$")) {
       // Legacy patch set number syntax.
@@ -114,55 +113,36 @@
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
       return Collections.emptyList();
-    } else if (id.length() >= 8) {
-      // Commit names are rather unique. Query for the commit and later
-      // match to the change. This is most likely going to identify 1 or
-      // at most 2 patch sets to consider, which is smaller than looking
-      // for all patch sets in the change.
-      RevId revid = new RevId(id);
-      if (revid.isComplete()) {
-        List<RevisionResource> m = toResources(change, findExactMatch(revid));
-        return m.isEmpty() ? loadEdit(change, revid)  : m;
-      }
-      return toResources(change, findByPrefix(revid));
     } else {
-      // Chance of collision rises; look at all patch sets on the change.
-      List<RevisionResource> out = Lists.newArrayList();
-      for (PatchSet ps : dbProvider.get().patchSets()
-          .byChange(change.getChange().getId())) {
+      List<RevisionResource> out = new ArrayList<>();
+      for (PatchSet ps : psUtil.byChange(dbProvider.get(), change.getNotes())) {
         if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
           out.add(new RevisionResource(change, ps));
         }
       }
+      // Not an existing patch set on a change, but might be an edit.
+      if (out.isEmpty() && id.length() == RevId.LEN) {
+        return loadEdit(change, new RevId(id));
+      }
       return out;
     }
   }
 
   private List<RevisionResource> byLegacyPatchSetId(ChangeResource change,
       String id) throws OrmException {
-    PatchSet ps = dbProvider.get().patchSets().get(new PatchSet.Id(
-        change.getChange().getId(),
-        Integer.parseInt(id)));
+    PatchSet ps = psUtil.get(dbProvider.get(), change.getNotes(),
+        new PatchSet.Id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
     }
     return Collections.emptyList();
   }
 
-  private ResultSet<PatchSet> findExactMatch(RevId revid) throws OrmException {
-    return dbProvider.get().patchSets().byRevision(revid);
-  }
-
-  private ResultSet<PatchSet> findByPrefix(RevId revid) throws OrmException {
-    return dbProvider.get().patchSets().byRevisionRange(revid, revid.max());
-  }
-
   private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
-      throws AuthException, IOException {
+      throws AuthException, IOException, OrmException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getChange());
     if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(
-          change.getChange().getId(), 0));
+      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
       ps.setRevision(edit.get().getRevision());
       if (revid == null || edit.get().getRevision().equals(revid)) {
         return Collections.singletonList(
@@ -171,21 +151,4 @@
     }
     return Collections.emptyList();
   }
-
-  private static List<RevisionResource> toResources(final ChangeResource change,
-      Iterable<PatchSet> patchSets) {
-    final Change.Id changeId = change.getChange().getId();
-    return FluentIterable.from(patchSets)
-        .filter(new Predicate<PatchSet>() {
-          @Override
-          public boolean apply(PatchSet in) {
-            return changeId.equals(in.getId().getParentKey());
-          }
-        }).transform(new Function<PatchSet, RevisionResource>() {
-          @Override
-          public RevisionResource apply(PatchSet in) {
-            return new RevisionResource(change, in);
-          }
-        }).toList();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 61baeb2..50f6e74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -17,19 +17,25 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.change.HashtagsUtil.extractTags;
 
+import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
@@ -46,11 +52,13 @@
     SetHashtagsOp create(HashtagsInput input);
   }
 
-  private final ChangeHooks hooks;
+  private final NotesMigration notesMigration;
+  private final ChangeMessagesUtil cmUtil;
   private final DynamicSet<HashtagValidationListener> validationListeners;
+  private final HashtagsEdited hashtagsEdited;
   private final HashtagsInput input;
 
-  private boolean runHooks = true;
+  private boolean fireEvent = true;
 
   private Change change;
   private Set<String> toAdd;
@@ -59,32 +67,40 @@
 
   @AssistedInject
   SetHashtagsOp(
-      ChangeHooks hooks,
+      NotesMigration notesMigration,
+      ChangeMessagesUtil cmUtil,
       DynamicSet<HashtagValidationListener> validationListeners,
+      HashtagsEdited hashtagsEdited,
       @Assisted @Nullable HashtagsInput input) {
-    this.hooks = hooks;
+    this.notesMigration = notesMigration;
+    this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
+    this.hashtagsEdited = hashtagsEdited;
     this.input = input;
   }
 
-  public SetHashtagsOp setRunHooks(boolean runHooks) {
-    this.runHooks = runHooks;
+  public SetHashtagsOp setFireEvent(boolean fireEvent) {
+    this.fireEvent = fireEvent;
     return this;
   }
 
   @Override
-  public void updateChange(ChangeContext ctx)
+  public boolean updateChange(ChangeContext ctx)
       throws AuthException, BadRequestException, OrmException, IOException {
+    if (!notesMigration.readChanges()) {
+      throw new BadRequestException("Cannot add hashtags; NoteDb is disabled");
+    }
     if (input == null
         || (input.add == null && input.remove == null)) {
       updatedHashtags = ImmutableSortedSet.of();
-      return;
+      return false;
     }
-    if (!ctx.getChangeControl().canEditHashtags()) {
+    if (!ctx.getControl().canEditHashtags()) {
       throw new AuthException("Editing hashtags not permitted");
     }
-    ChangeUpdate update = ctx.getChangeUpdate();
-    ChangeNotes notes = update.getChangeNotes().load();
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    ChangeNotes notes = update.getNotes().load();
 
     Set<String> existingHashtags = notes.getHashtags();
     Set<String> updated = new HashSet<>();
@@ -99,28 +115,59 @@
       throw new BadRequestException(e.getMessage());
     }
 
-    if (existingHashtags != null && !existingHashtags.isEmpty()) {
-      updated.addAll(existingHashtags);
-      toAdd.removeAll(existingHashtags);
-      toRemove.retainAll(existingHashtags);
-    }
+    updated.addAll(existingHashtags);
+    toAdd.removeAll(existingHashtags);
+    toRemove.retainAll(existingHashtags);
     if (updated()) {
       updated.addAll(toAdd);
       updated.removeAll(toRemove);
       update.setHashtags(updated);
+      addMessage(ctx, update);
     }
 
-    change = update.getChange();
     updatedHashtags = ImmutableSortedSet.copyOf(updated);
+    return true;
+  }
+
+  private void addMessage(Context ctx, ChangeUpdate update)
+      throws OrmException {
+    StringBuilder msg = new StringBuilder();
+    appendHashtagMessage(msg, "added", toAdd);
+    appendHashtagMessage(msg, "removed", toRemove);
+    ChangeMessage cmsg = new ChangeMessage(
+        new ChangeMessage.Key(
+            change.getId(),
+            ChangeUtil.messageUUID(ctx.getDb())),
+        ctx.getAccountId(), ctx.getWhen(),
+        change.currentPatchSetId());
+    cmsg.setMessage(msg.toString());
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+
+  private void appendHashtagMessage(StringBuilder b, String action,
+      Set<String> hashtags) {
+    if (isNullOrEmpty(hashtags)) {
+      return;
+    }
+
+    if (b.length() > 0) {
+      b.append("\n");
+    }
+    b.append("Hashtag");
+    if (hashtags.size() > 1) {
+      b.append("s");
+    }
+    b.append(" ");
+    b.append(action);
+    b.append(": ");
+    b.append(Joiner.on(", ").join(Ordering.natural().sortedCopy(hashtags)));
   }
 
   @Override
   public void postUpdate(Context ctx) throws OrmException {
-    if (updated() && runHooks) {
-      hooks.doHashtagsChangedHook(
-          change, ctx.getUser().asIdentifiedUser().getAccount(),
-          toAdd, toRemove, updatedHashtags,
-          ctx.getDb());
+    if (updated() && fireEvent) {
+      hashtagsEdited.fire(change, ctx.getAccount(), updatedHashtags,
+          toAdd, toRemove, ctx.getWhen());
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index 22bdae5..4750197 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
@@ -29,6 +30,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -40,7 +42,9 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -48,6 +52,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeOp;
 import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -108,11 +113,26 @@
     }
   }
 
+  /**
+   * Subclass of {@link SubmitInput} with special bits that may be flipped for
+   * testing purposes only.
+   */
+  @VisibleForTesting
+  public static class TestSubmitInput extends SubmitInput {
+    public final boolean failAfterRefUpdates;
+
+    public TestSubmitInput(SubmitInput base, boolean failAfterRefUpdates) {
+      this.onBehalfOf = base.onBehalfOf;
+      this.notify = base.notify;
+      this.failAfterRefUpdates = failAfterRefUpdates;
+    }
+  }
+
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final MergeSuperSet mergeSuperSet;
   private final AccountsCollection accounts;
@@ -125,24 +145,26 @@
   private final ParameterizedString submitTopicTooltip;
   private final boolean submitWholeTopic;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final PatchSetUtil psUtil;
 
   @Inject
   Submit(Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
-      ChangeControl.GenericFactory changeControlFactory,
+      ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       MergeSuperSet mergeSuperSet,
       AccountsCollection accounts,
       ChangesCollection changes,
       @GerritServerConfig Config cfg,
-      Provider<InternalChangeQuery> queryProvider) {
+      Provider<InternalChangeQuery> queryProvider,
+      PatchSetUtil psUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
-    this.changeControlFactory = changeControlFactory;
+    this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accounts = accounts;
@@ -169,13 +191,13 @@
         cfg.getString("change", null, "submitTopicTooltip"),
         DEFAULT_TOPIC_TOOLTIP));
     this.queryProvider = queryProvider;
+    this.psUtil = psUtil;
   }
 
   @Override
   public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws AuthException, ResourceConflictException,
-      RepositoryNotFoundException, IOException, OrmException,
-      UnprocessableEntityException {
+      throws RestApiException, RepositoryNotFoundException, IOException,
+      OrmException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     if (input.onBehalfOf != null) {
       rsrc = onBehalfOf(rsrc, input);
@@ -198,17 +220,17 @@
           rsrc.getPatchSet().getRevision().get()));
     }
 
-    try {
+    try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      mergeOpProvider.get().merge(db, change, caller, true);
-      change = db.changes().get(change.getId());
-    } catch (NoSuchChangeException e) {
-      throw new OrmException("Submission failed", e);
+      op.merge(db, change, caller, true, input);
+      try {
+        change = changeNotesFactory
+            .createChecked(db, change.getProject(), change.getId()).getChange();
+      } catch (NoSuchChangeException e) {
+        throw new ResourceConflictException("change is deleted");
+      }
     }
 
-    if (change == null) {
-      throw new ResourceConflictException("change is deleted");
-    }
     switch (change.getStatus()) {
       case MERGED:
         return new Output(change);
@@ -218,6 +240,8 @@
           throw new ResourceConflictException(msg.getMessage());
         }
         //$FALL-THROUGH$
+      case ABANDONED:
+      case DRAFT:
       default:
         throw new ResourceConflictException("change is " + status(change));
     }
@@ -226,18 +250,19 @@
   /**
    * @param cd the change the user is currently looking at
    * @param cs set of changes to be submitted at once
-   * @param identifiedUser the user who is checking to submit
+   * @param user the user who is checking to submit
    * @return a reason why any of the changes is not submittable or null
    */
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs,
-      IdentifiedUser identifiedUser) {
+      CurrentUser user) {
     try {
       @SuppressWarnings("resource")
       ReviewDb db = dbProvider.get();
-      for (PatchSet.Id psId : cs.patchIds()) {
-        ChangeControl changeControl = changeControlFactory
-            .controlFor(psId.getParentKey(), identifiedUser);
-        ChangeData c = changeDataFactory.create(db, changeControl);
+      if (cs.furtherHiddenChanges()) {
+        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
+      }
+      for (ChangeData c : cs.changes()) {
+        ChangeControl changeControl = c.changeControl(user);
 
         if (!changeControl.isVisible(db)) {
           return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
@@ -268,7 +293,7 @@
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
-    } catch (NoSuchChangeException | OrmException | IOException e) {
+    } catch (OrmException | IOException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
@@ -282,7 +307,7 @@
    * @param cd the change to check for submittability
    * @return if the change has any problems for submission
    */
-  public boolean submittable(ChangeData cd) {
+  public static boolean submittable(ChangeData cd) {
     try {
       MergeOp.checkSubmitRule(cd);
       return true;
@@ -320,7 +345,8 @@
 
     ChangeSet cs;
     try {
-      cs = mergeSuperSet.completeChangeSet(db, cd.change());
+      cs = mergeSuperSet.completeChangeSet(
+          db, cd.change(), resource.getControl().getUser());
     } catch (OrmException | IOException e) {
       throw new OrmRuntimeException("Could not determine complete set of " +
           "changes to be submitted", e);
@@ -372,21 +398,20 @@
               submitTopicTooltip.replace(params)))
           .setVisible(true)
           .setEnabled(Boolean.TRUE.equals(enabled));
-    } else {
-      RevId revId = resource.getPatchSet().getRevision();
-      Map<String, String> params = ImmutableMap.of(
-          "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-          "branch", resource.getChange().getDest().getShortName(),
-          "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
-          "submitSize", String.valueOf(cs.size()));
-      ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors :
-          titlePattern;
-      return new UiAction.Description()
-        .setLabel(cs.size() > 1 ? labelWithParents : label)
-        .setTitle(Strings.emptyToNull(tp.replace(params)))
-        .setVisible(true)
-        .setEnabled(Boolean.TRUE.equals(enabled));
     }
+    RevId revId = resource.getPatchSet().getRevision();
+    Map<String, String> params = ImmutableMap.of(
+        "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
+        "branch", resource.getChange().getDest().getShortName(),
+        "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+        "submitSize", String.valueOf(cs.size()));
+    ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors :
+        titlePattern;
+    return new UiAction.Description()
+      .setLabel(cs.size() > 1 ? labelWithParents : label)
+      .setTitle(Strings.emptyToNull(tp.replace(params)))
+      .setVisible(true)
+      .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
   /**
@@ -466,16 +491,12 @@
       Collection<ChangeData> changes, Project.NameKey project)
           throws IOException, OrmException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
-    if (!changes.isEmpty()) {
-      try (Repository repo = repoManager.openRepository(project);
-          RevWalk walk = new RevWalk(repo)) {
-        for (ChangeData change : changes) {
-          PatchSet patchSet = dbProvider.get().patchSets()
-              .get(change.change().currentPatchSetId());
-          String commitId = patchSet.getRevision().get();
-          RevCommit commit = walk.parseCommit(ObjectId.fromString(commitId));
-          commits.put(change.getId(), commit);
-        }
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      for (ChangeData change : changes) {
+        RevCommit commit = walk.parseCommit(ObjectId.fromString(
+            psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
+        commits.put(change.getId(), commit);
       }
     }
     return commits;
@@ -521,23 +542,24 @@
     private final Provider<ReviewDb> dbProvider;
     private final Submit submit;
     private final ChangeJson.Factory json;
+    private final PatchSetUtil psUtil;
 
     @Inject
     CurrentRevision(Provider<ReviewDb> dbProvider,
         Submit submit,
-        ChangeJson.Factory json) {
+        ChangeJson.Factory json,
+        PatchSetUtil psUtil) {
       this.dbProvider = dbProvider;
       this.submit = submit;
       this.json = json;
+      this.psUtil = psUtil;
     }
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws AuthException, ResourceConflictException,
-        RepositoryNotFoundException, IOException, OrmException,
-        UnprocessableEntityException {
-      PatchSet ps = dbProvider.get().patchSets()
-        .get(rsrc.getChange().currentPatchSetId());
+        throws RestApiException, RepositoryNotFoundException, IOException,
+        OrmException {
+      PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       } else if (!rsrc.getControl().isPatchVisible(ps, dbProvider.get())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
index 7fe738d..c4c0e98 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,6 +34,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -47,12 +49,19 @@
   private static final Logger log = LoggerFactory.getLogger(
       SubmittedTogether.class);
 
+  private final EnumSet<SubmittedTogetherOption> options =
+      EnumSet.noneOf(SubmittedTogetherOption.class);
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
   private final MergeSuperSet mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
+  @Option(name = "-o", usage = "Output options")
+  void addOption(SubmittedTogetherOption o) {
+    options.add(o);
+  }
+
   @Inject
   SubmittedTogether(ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
@@ -67,21 +76,45 @@
   }
 
   @Override
-  public List<ChangeInfo> apply(ChangeResource resource)
+  public Object apply(ChangeResource resource)
       throws AuthException, BadRequestException,
-      ResourceConflictException, Exception {
+      ResourceConflictException, IOException, OrmException {
+    SubmittedTogetherInfo info = apply(resource, options);
+    if (options.isEmpty()) {
+      return info.changes;
+    }
+    return info;
+  }
+
+  public SubmittedTogetherInfo apply(ChangeResource resource,
+      EnumSet<SubmittedTogetherOption> options)
+      throws AuthException, IOException, OrmException {
+    Change c = resource.getChange();
     try {
-      Change c = resource.getChange();
       List<ChangeData> cds;
+      int hidden;
+
       if (c.getStatus().isOpen()) {
-        cds = getForOpenChange(c);
+        ChangeSet cs =
+            mergeSuperSet.completeChangeSet(
+                dbProvider.get(), c, resource.getControl().getUser());
+        cds = cs.changes().asList();
+        hidden = cs.nonVisibleChanges().size();
       } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
-        cds = getForMergedChange(c);
+        cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
+        hidden = 0;
       } else {
-        cds = getForAbandonedChange();
+        cds = Collections.emptyList();
+        hidden = 0;
       }
 
-      if (cds.size() <= 1) {
+      if (hidden != 0
+          && !options.contains(SubmittedTogetherOption.NON_VISIBLE_CHANGES)) {
+        throw new AuthException(
+            "change would be submitted with a change that you cannot see");
+      }
+
+      if (cds.size() <= 1 && hidden == 0) {
         cds = Collections.emptyList();
       } else {
         // Skip sorting for singleton lists, to avoid WalkSorter opening the
@@ -89,30 +122,19 @@
         cds = sort(cds);
       }
 
-      return json.create(EnumSet.of(
+      SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+      info.changes = json.create(EnumSet.of(
           ListChangesOption.CURRENT_REVISION,
           ListChangesOption.CURRENT_COMMIT))
         .formatChangeDatas(cds);
+      info.nonVisibleChanges = hidden;
+      return info;
     } catch (OrmException | IOException e) {
       log.error("Error on getting a ChangeSet", e);
       throw e;
     }
   }
 
-  private List<ChangeData> getForOpenChange(Change c)
-      throws OrmException, IOException {
-    ChangeSet cs = mergeSuperSet.completeChangeSet(dbProvider.get(), c);
-    return cs.changes().asList();
-  }
-
-  private List<ChangeData> getForMergedChange(Change c) throws OrmException {
-    return queryProvider.get().bySubmissionId(c.getSubmissionId());
-  }
-
-  private List<ChangeData> getForAbandonedChange() {
-    return Collections.emptyList();
-  }
-
   private List<ChangeData> sort(List<ChangeData> cds)
       throws OrmException, IOException {
     List<ChangeData> sorted = new ArrayList<>(cds.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index 46fbe67..02d3afe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -60,17 +60,15 @@
           return true;
         }
       };
-    } else {
-      return new VisibilityControl() {
-        @Override
-        public boolean isVisibleTo(Account.Id account) throws OrmException {
-          IdentifiedUser who =
-              identifiedUserFactory.create(dbProvider, account);
-          // we can't use changeControl directly as it won't suggest reviewers
-          // to drafts
-          return rsrc.getControl().forUser(who).isRefVisible();
-        }
-      };
     }
+    return new VisibilityControl() {
+      @Override
+      public boolean isVisibleTo(Account.Id account) throws OrmException {
+        IdentifiedUser who = identifiedUserFactory.create(account);
+        // we can't use changeControl directly as it won't suggest reviewers
+        // to drafts
+        return rsrc.getControl().forUser(who).isRefVisible();
+      }
+    };
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
index 3b61033..f159c69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
@@ -27,7 +27,6 @@
 
 public class SuggestReviewers {
   private static final int DEFAULT_MAX_SUGGESTED = 10;
-  private static final int DEFAULT_MAX_MATCHES = 100;
 
   protected final Provider<ReviewDb> dbProvider;
   protected final IdentifiedUser.GenericFactory identifiedUserFactory;
@@ -36,10 +35,9 @@
   private final boolean suggestAccounts;
   private final int suggestFrom;
   private final int maxAllowed;
+  private final int maxAllowedWithoutConfirmation;
   protected int limit;
   protected String query;
-  private boolean useFullTextSearch;
-  private final int fullTextMaxMatches;
   protected final int maxSuggestedReviewers;
 
   @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT",
@@ -68,14 +66,6 @@
     return suggestFrom;
   }
 
-  public boolean getUseFullTextSearch() {
-    return useFullTextSearch;
-  }
-
-  public int getFullTextMaxMatches() {
-    return fullTextMaxMatches;
-  }
-
   public int getLimit() {
     return limit;
   }
@@ -84,6 +74,10 @@
     return maxAllowed;
   }
 
+  public int getMaxAllowedWithoutConfirmation() {
+    return maxAllowedWithoutConfirmation;
+  }
+
   @Inject
   public SuggestReviewers(AccountVisibility av,
       IdentifiedUser.GenericFactory identifiedUserFactory,
@@ -96,20 +90,19 @@
     this.maxSuggestedReviewers =
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
     this.limit = this.maxSuggestedReviewers;
-    this.fullTextMaxMatches =
-        cfg.getInt("suggest", "fullTextSearchMaxMatches",
-            DEFAULT_MAX_MATCHES);
     String suggest = cfg.getString("suggest", null, "accounts");
     if ("OFF".equalsIgnoreCase(suggest)
         || "false".equalsIgnoreCase(suggest)) {
       this.suggestAccounts = false;
     } else {
-      this.useFullTextSearch = cfg.getBoolean("suggest", "fullTextSearch", false);
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
     this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
         PostReviewers.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowedWithoutConfirmation = cfg.getInt(
+        "addreviewer", "maxWithoutConfirmation",
+        PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
index 95a701e..45d9669 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
@@ -16,16 +16,15 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.rules.RulesCache;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.TestSubmitRule.Input;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -34,20 +33,12 @@
 
 import org.kohsuke.args4j.Option;
 
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
-public class TestSubmitRule implements RestModifyView<RevisionResource, Input> {
-  public enum Filters {
-    RUN, SKIP
-  }
-
-  public static class Input {
-    @DefaultInput
-    public String rule;
-    public Filters filters;
-  }
-
+public class TestSubmitRule
+    implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
@@ -68,10 +59,10 @@
   }
 
   @Override
-  public List<Record> apply(RevisionResource rsrc, Input input)
+  public List<Record> apply(RevisionResource rsrc, TestSubmitRuleInput input)
       throws AuthException, OrmException {
     if (input == null) {
-      input = new Input();
+      input = new TestSubmitRuleInput();
     }
     if (input.rule != null && !rules.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
@@ -125,31 +116,31 @@
       switch (n.status) {
         case OK:
           if (ok == null) {
-            ok = Maps.newLinkedHashMap();
+            ok = new LinkedHashMap<>();
           }
           ok.put(n.label, who);
           break;
         case REJECT:
           if (reject == null) {
-            reject = Maps.newLinkedHashMap();
+            reject = new LinkedHashMap<>();
           }
           reject.put(n.label, who);
           break;
         case NEED:
           if (need == null) {
-            need = Maps.newLinkedHashMap();
+            need = new LinkedHashMap<>();
           }
           need.put(n.label, new None());
           break;
         case MAY:
           if (may == null) {
-            may = Maps.newLinkedHashMap();
+            may = new LinkedHashMap<>();
           }
           may.put(n.label, who);
           break;
         case IMPOSSIBLE:
           if (impossible == null) {
-            impossible = Maps.newLinkedHashMap();
+            impossible = new LinkedHashMap<>();
           }
           impossible.put(n.label, new None());
           break;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
index f6016b5..4855012 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
@@ -17,14 +17,14 @@
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.change.TestSubmitRule.Filters;
-import com.google.gerrit.server.change.TestSubmitRule.Input;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -33,7 +33,8 @@
 
 import org.kohsuke.args4j.Option;
 
-public class TestSubmitType implements RestModifyView<RevisionResource, Input> {
+public class TestSubmitType
+    implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
@@ -51,10 +52,10 @@
   }
 
   @Override
-  public SubmitType apply(RevisionResource rsrc, Input input)
+  public SubmitType apply(RevisionResource rsrc, TestSubmitRuleInput input)
       throws AuthException, BadRequestException, OrmException {
     if (input == null) {
-      input = new Input();
+      input = new TestSubmitRuleInput();
     }
     if (input.rule != null && !rules.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
@@ -71,13 +72,13 @@
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new BadRequestException(String.format(
           "rule %s produced invalid result: %s",
-          evaluator.getSubmitRule(), rec));
+          evaluator.getSubmitRuleName(), rec));
     }
 
     return rec.type;
   }
 
-  static class Get implements RestReadView<RevisionResource> {
+  public static class Get implements RestReadView<RevisionResource> {
     private final TestSubmitType test;
 
     @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
new file mode 100644
index 0000000..4dfaff0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class VoteResource implements RestResource {
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
+      new TypeLiteral<RestView<VoteResource>>() {};
+
+  private final ReviewerResource reviewer;
+  private final String label;
+
+  public VoteResource(ReviewerResource reviewer, String label) {
+    this.reviewer = reviewer;
+    this.label = label;
+  }
+
+  public ReviewerResource getReviewer() {
+    return reviewer;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
new file mode 100644
index 0000000..3bba37e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+@Singleton
+public class Votes implements ChildCollection<ReviewerResource, VoteResource> {
+  private final DynamicMap<RestView<VoteResource>> views;
+  private final List list;
+
+  @Inject
+  Votes(DynamicMap<RestView<VoteResource>> views,
+      List list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<VoteResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ReviewerResource> list() throws AuthException {
+    return list;
+  }
+
+  @Override
+  public VoteResource parse(ReviewerResource reviewer, IdString id)
+      throws ResourceNotFoundException, OrmException, AuthException {
+    return new VoteResource(reviewer, id.get());
+  }
+
+  @Singleton
+  public static class List implements RestReadView<ReviewerResource> {
+    private final Provider<ReviewDb> db;
+    private final ApprovalsUtil approvalsUtil;
+
+    @Inject
+    List(Provider<ReviewDb> db,
+        ApprovalsUtil approvalsUtil) {
+      this.db = db;
+      this.approvalsUtil = approvalsUtil;
+    }
+
+    @Override
+    public Map<String, Short> apply(ReviewerResource rsrc) throws OrmException {
+      Map<String, Short> votes = new TreeMap<>();
+      Iterable<PatchSetApproval> byPatchSetUser = approvalsUtil.byPatchSetUser(
+          db.get(),
+          rsrc.getControl(),
+          rsrc.getChange().currentPatchSetId(),
+          rsrc.getReviewerUser().getAccountId());
+      for (PatchSetApproval psa : byPatchSetUser) {
+        votes.put(psa.getLabel(), psa.getValue());
+      }
+      return votes;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
new file mode 100644
index 0000000..caeb771
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+/**
+ * Groups that can always exercise {@code administrateServer} capability.
+ *
+ * <pre>
+ * [capability]
+ *     administrateServer = group Administrators
+ * </pre>
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface AdministrateServerGroups {
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
new file mode 100644
index 0000000..dd3b8329
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ServerRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Loads {@link AdministrateServerGroups} from {@code gerrit.config}. */
+public class AdministrateServerGroupsProvider implements Provider<ImmutableSet<GroupReference>> {
+  private final ImmutableSet<GroupReference> groups;
+
+  @Inject
+  public AdministrateServerGroupsProvider(GroupBackend groupBackend,
+      @GerritServerConfig Config config,
+      ThreadLocalRequestContext threadContext,
+      ServerRequestContext serverCtx) {
+    RequestContext ctx = threadContext.setContext(serverCtx);
+    try {
+      ImmutableSet.Builder<GroupReference> builder = ImmutableSet.builder();
+      for (String value : config.getStringList("capability", null, "administrateServer")) {
+        PermissionRule rule = PermissionRule.fromString(value, false);
+        String name = rule.getGroup().getName();
+        GroupReference g = GroupBackends.findBestSuggestion(groupBackend, name);
+        if (g != null) {
+          builder.add(g);
+        } else {
+          Logger log = LoggerFactory.getLogger(getClass());
+          log.warn("Group \"{}\" not available, skipping.", name);
+        }
+      }
+      groups = builder.build();
+    } finally {
+      threadContext.setContext(ctx);
+    }
+  }
+
+  @Override
+  public ImmutableSet<GroupReference> get() {
+    return groups;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
index 2f25da4..af681db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.Config;
 
+@Singleton
 public class AllProjectsNameProvider implements Provider<AllProjectsName> {
   public static final String DEFAULT = "All-Projects";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
index f5aa127..09f1c50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.Config;
 
+@Singleton
 public class AllUsersNameProvider implements Provider<AllUsersName> {
   public static final String DEFAULT = "All-Users";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index c3bd519..3511705 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
@@ -58,9 +59,11 @@
   private final List<OpenIdProviderPattern> trustedOpenIDs;
   private final List<OpenIdProviderPattern> allowedOpenIDs;
   private final String cookiePath;
+  private final String cookieDomain;
   private final boolean cookieSecure;
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
+  private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
   AuthConfig(@GerritServerConfig final Config cfg)
@@ -84,10 +87,12 @@
     trustedOpenIDs = toPatterns(cfg, "trustedOpenID");
     allowedOpenIDs = toPatterns(cfg, "allowedOpenID");
     cookiePath = cfg.getString("auth", null, "cookiepath");
+    cookieDomain = cfg.getString("auth", null, "cookiedomain");
     cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
     gitBasicAuth = cfg.getBoolean("auth", "gitBasicAuth", false);
+    gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
     useContributorAgreements =
         cfg.getBoolean("auth", "contributoragreements", false);
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
@@ -122,6 +127,12 @@
     return cfg.getEnum("auth", null, "type", AuthType.OPENID);
   }
 
+  private GitBasicAuthPolicy getBasicAuthPolicy(Config cfg) {
+    GitBasicAuthPolicy defaultAuthPolicy =
+        isLdapAuthType() ? GitBasicAuthPolicy.LDAP : GitBasicAuthPolicy.HTTP;
+    return cfg.getEnum("auth", null, "gitBasicAuthPolicy", defaultAuthPolicy);
+  }
+
   /** Type of user authentication used by this Gerrit server. */
   public AuthType getAuthType() {
     return authType;
@@ -179,6 +190,10 @@
     return cookiePath;
   }
 
+  public String getCookieDomain() {
+    return cookieDomain;
+  }
+
   public boolean getCookieSecure() {
     return cookieSecure;
   }
@@ -212,6 +227,10 @@
     return gitBasicAuth;
   }
 
+  public GitBasicAuthPolicy getGitBasicAuthPolicy() {
+    return gitBasicAuthPolicy;
+  }
+
   /** Whether contributor agreements are used. */
   public boolean isUseContributorAgreements() {
     return useContributorAgreements;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
index ca4a9d2..8e181a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.InternalAuthBackend;
 import com.google.gerrit.server.auth.ldap.LdapModule;
+import com.google.gerrit.server.auth.oauth.OAuthRealm;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 
@@ -42,9 +43,17 @@
         install(new LdapModule());
         break;
 
+      case OAUTH:
+        bind(Realm.class).to(OAuthRealm.class);
+        break;
+
       case CUSTOM_EXTENSION:
         break;
 
+      case DEVELOPMENT_BECOME_ANY_ACCOUNT:
+      case HTTP:
+      case OPENID:
+      case OPENID_SSO:
       default:
         bind(Realm.class).to(DefaultRealm.class);
         DynamicSet.bind(binder(), AuthBackend.class).to(InternalAuthBackend.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
index afb972b6..05ad33d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
@@ -51,8 +51,7 @@
   public static String cacheNameOf(String plugin, String name) {
     if ("gerrit".equals(plugin)) {
       return name;
-    } else {
-      return plugin + "-" + name;
     }
+    return plugin + "-" + name;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
index be9d984..eaeb850 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -24,7 +23,9 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -282,7 +283,9 @@
         f.setAccessible(true);
         Object c = f.get(s);
         Object d = f.get(defaults);
-        Preconditions.checkNotNull(d, "Default cannot be null");
+        if (!isString(t) && !isCollectionOrMap(t)) {
+          Preconditions.checkNotNull(d, "Default cannot be null for: " + n);
+        }
         if (c == null || c.equals(d)) {
           cfg.unset(section, sub, n);
         } else {
@@ -296,6 +299,9 @@
             cfg.setBoolean(section, sub, n, (Boolean) c);
           } else if (t.isEnum()) {
             cfg.setEnum(section, sub, n, (Enum<?>) c);
+          } else if (isCollectionOrMap(t)) {
+            // TODO(davido): accept closure passed in from caller
+            continue;
           } else {
             throw new ConfigInvalidException("type is unknown: " + t.getName());
           }
@@ -336,9 +342,15 @@
         String n = f.getName();
         f.setAccessible(true);
         Object d = f.get(defaults);
-        Preconditions.checkNotNull(d, "Default cannot be null");
+        if (!isString(t) && !isCollectionOrMap(t)) {
+          Preconditions.checkNotNull(d, "Default cannot be null for: " + n);
+        }
         if (isString(t)) {
-          f.set(s, MoreObjects.firstNonNull(cfg.getString(section, sub, n), d));
+          String v = cfg.getString(section, sub, n);
+          if (v == null) {
+            v = (String)d;
+          }
+          f.set(s, v);
         } else if (isInteger(t)) {
           f.set(s, cfg.getInt(section, sub, n, (Integer) d));
         } else if (isLong(t)) {
@@ -350,6 +362,9 @@
           }
         } else if (t.isEnum()) {
           f.set(s, cfg.getEnum(section, sub, n, (Enum<?>) d));
+        } else if (isCollectionOrMap(t)) {
+          // TODO(davido): accept closure passed in from caller
+          continue;
         } else {
           throw new ConfigInvalidException("type is unknown: " + t.getName());
         }
@@ -367,11 +382,16 @@
     return s;
   }
 
-  private static boolean skipField(Field field) {
+  public static boolean skipField(Field field) {
     int modifiers = field.getModifiers();
     return Modifier.isFinal(modifiers) || Modifier.isTransient(modifiers);
   }
 
+  private static boolean isCollectionOrMap(Class<?> t) {
+    return Collection.class.isAssignableFrom(t)
+        || Map.class.isAssignableFrom(t);
+  }
+
   private static boolean isString(Class<?> t) {
     return String.class == t;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
index df3c37f..81a3366 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
@@ -30,6 +30,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
   public static class Input {
@@ -53,7 +55,7 @@
   @Override
   public Response<?> apply(ConfigResource rsrc, Input input)
       throws AuthException, UnprocessableEntityException, AccountException,
-      OrmException {
+      OrmException, IOException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
@@ -72,9 +74,8 @@
       if (accId.equals(token.getAccountId())) {
         accountManager.link(accId, token.toAuthRequest());
         return Response.none();
-      } else {
-        throw new UnprocessableEntityException("invalid token");
       }
+      throw new UnprocessableEntityException("invalid token");
     } catch (EmailTokenVerifier.InvalidTokenException e) {
       throw new UnprocessableEntityException("invalid token");
     } catch (AccountException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
index f435a2b..2db4ec9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.inject.Inject;
@@ -94,9 +94,8 @@
       int m = Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL;
       if ((f.getModifiers() & m) == m && f.getType() == String.class) {
         return (String) f.get(null);
-      } else {
-        return null;
       }
+      return null;
     } catch (NoSuchFieldException | SecurityException | IllegalArgumentException
         | IllegalAccessException e) {
       return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 90249c8..704682a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -19,19 +19,39 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
+import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.ChangeRevertedListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PluginEventListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.UsageDataPublishedListener;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -40,9 +60,11 @@
 import com.google.gerrit.extensions.webui.DiffWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
 import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.rules.PrologModule;
 import com.google.gerrit.rules.RulesCache;
 import com.google.gerrit.server.AnonymousUser;
@@ -50,6 +72,7 @@
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
@@ -58,6 +81,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
+import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.ChangeUserName;
 import com.google.gerrit.server.account.EmailExpander;
@@ -65,25 +89,34 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.account.GroupDetailFactory;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
-import com.google.gerrit.server.account.GroupInfoCacheFactory;
 import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheRemovalListener;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
+import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.EmailMerge;
 import com.google.gerrit.server.git.GitModule;
+import com.google.gerrit.server.git.GitModules;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.ReplaceOp;
+import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.strategy.SubmitStrategy;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
@@ -93,15 +126,16 @@
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.group.GroupInfoCache;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.index.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterUpdate;
 import com.google.gerrit.server.mail.AddKeySender;
 import com.google.gerrit.server.mail.AddReviewerSender;
 import com.google.gerrit.server.mail.CreateChangeSender;
+import com.google.gerrit.server.mail.DeleteReviewerSender;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.FromAddressGenerator;
 import com.google.gerrit.server.mail.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.MergeFailSender;
 import com.google.gerrit.server.mail.MergedSender;
 import com.google.gerrit.server.mail.RegisterNewEmailSender;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
@@ -115,7 +149,6 @@
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.CommentLinkInfo;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.PermissionCollection;
 import com.google.gerrit.server.project.ProjectCacheImpl;
@@ -124,22 +157,26 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.util.IdGenerator;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gitiles.blame.BlameCache;
+import com.google.gitiles.blame.BlameCacheImpl;
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
 
 import org.apache.velocity.runtime.RuntimeInstance;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.PostUploadHook;
 import org.eclipse.jgit.transport.PreUploadHook;
 
 import java.util.List;
@@ -147,10 +184,14 @@
 
 /** Starts global state with standard dependencies. */
 public class GerritGlobalModule extends FactoryModule {
+  private final Config cfg;
   private final AuthModule authModule;
 
   @Inject
-  GerritGlobalModule(AuthModule authModule) {
+  GerritGlobalModule(
+      @GerritServerConfig Config cfg,
+      AuthModule authModule) {
+    this.cfg = cfg;
     this.authModule = authModule;
   }
 
@@ -161,6 +202,8 @@
 
     bind(IdGenerator.class);
     bind(RulesCache.class);
+    bind(BlameCache.class).to(BlameCacheImpl.class);
+    bind(Sequences.class);
     install(authModule);
     install(AccountByEmailCacheImpl.module());
     install(AccountCacheImpl.module());
@@ -172,14 +215,16 @@
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
     install(SectionSortCache.module());
+    install(SubmitStrategy.module());
     install(TagCache.module());
+    install(OAuthTokenCache.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
     install(new GitModule());
     install(new GroupModule());
-    install(new NoteDbModule());
+    install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
@@ -188,18 +233,19 @@
 
     factory(AccountInfoCacheFactory.Factory.class);
     factory(AddReviewerSender.Factory.class);
+    factory(DeleteReviewerSender.Factory.class);
     factory(AddKeySender.Factory.class);
     factory(BatchUpdate.Factory.class);
+    factory(CapabilityCollection.Factory.class);
     factory(CapabilityControl.Factory.class);
     factory(ChangeData.Factory.class);
     factory(ChangeJson.Factory.class);
     factory(CreateChangeSender.Factory.class);
     factory(GroupDetailFactory.Factory.class);
-    factory(GroupInfoCacheFactory.Factory.class);
+    factory(GroupInfoCache.Factory.class);
     factory(GroupMembers.Factory.class);
     factory(EmailMerge.Factory.class);
     factory(MergedSender.Factory.class);
-    factory(MergeFailSender.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PluginUser.Factory.class);
@@ -212,7 +258,6 @@
         .toProvider(AccountVisibilityProvider.class)
         .in(SINGLETON);
     factory(ProjectOwnerGroupsProvider.Factory.class);
-    bind(RepositoryConfig.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -224,7 +269,6 @@
     bind(ToolsCatalog.class);
     bind(EventFactory.class);
     bind(TransferConfig.class);
-    bind(GitwebConfig.class);
 
     bind(GcConfig.class);
     bind(ChangeCleanupConfig.class);
@@ -258,9 +302,26 @@
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), CommentAddedListener.class);
+    DynamicSet.setOf(binder(), DraftPublishedListener.class);
+    DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+    DynamicSet.setOf(binder(), ChangeMergedListener.class);
+    DynamicSet.setOf(binder(), ChangeRestoredListener.class);
+    DynamicSet.setOf(binder(), ChangeRevertedListener.class);
+    DynamicSet.setOf(binder(), ReviewerAddedListener.class);
+    DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
+    DynamicSet.setOf(binder(), VoteDeletedListener.class);
+    DynamicSet.setOf(binder(), RevisionCreatedListener.class);
+    DynamicSet.setOf(binder(), TopicEditedListener.class);
+    DynamicSet.setOf(binder(), AgreementSignupListener.class);
+    DynamicSet.setOf(binder(), PluginEventListener.class);
     DynamicSet.setOf(binder(), ReceivePackInitializer.class);
     DynamicSet.setOf(binder(), PostReceiveHook.class);
     DynamicSet.setOf(binder(), PreUploadHook.class);
+    DynamicSet.setOf(binder(), PostUploadHook.class);
+    DynamicSet.setOf(binder(), AccountIndexedListener.class);
+    DynamicSet.setOf(binder(), ChangeIndexedListener.class);
     DynamicSet.setOf(binder(), NewProjectCreatedListener.class);
     DynamicSet.setOf(binder(), ProjectDeletedListener.class);
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
@@ -270,6 +331,8 @@
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
+    DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
+    DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), MergeValidationListener.class);
@@ -284,18 +347,27 @@
     DynamicMap.mapOf(binder(), DownloadScheme.class);
     DynamicMap.mapOf(binder(), DownloadCommand.class);
     DynamicMap.mapOf(binder(), CloneCommand.class);
-    DynamicMap.mapOf(binder(), ExternalIncludedIn.class);
+    DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
     DynamicSet.setOf(binder(), FileHistoryWebLink.class);
     DynamicSet.setOf(binder(), DiffWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
+    DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
+    DynamicItem.itemOf(binder(), OAuthTokenEncrypter.class);
+    DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
+    DynamicSet.setOf(binder(), WebUiPlugin.class);
+    DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    install(new GitwebConfig.LegacyModule(cfg));
+
     bind(AnonymousUser.class);
 
     factory(CommitValidators.Factory.class);
@@ -303,7 +375,11 @@
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
     factory(NotesBranchUtil.Factory.class);
-    factory(SubmoduleSectionParser.Factory.class);
+    factory(ReplaceOp.Factory.class);
+    factory(MergedByPushOp.Factory.class);
+    factory(GitModules.Factory.class);
+    factory(VersionedAuthorizedKeys.Factory.class);
+    factory(SubmoduleOp.Factory.class);
 
     bind(AccountManager.class);
     factory(ChangeUserName.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
new file mode 100644
index 0000000..f3fa9b1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on a string holding a unique identifier for the server.
+ * <p>
+ * This value is generated on first use and stored in {@code
+ * $site_path/etc/uuid}.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritServerId {
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
new file mode 100644
index 0000000..9479438
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.UUID;
+
+public class GerritServerIdProvider implements Provider<String> {
+  public static final String SECTION = "gerrit";
+  public static final String KEY = "serverId";
+
+  public static String generate() {
+    return UUID.randomUUID().toString();
+  }
+
+  private final String id;
+
+  @Inject
+  GerritServerIdProvider(@GerritServerConfig Config cfg,
+      SitePaths sitePaths) throws IOException, ConfigInvalidException {
+    String origId = cfg.getString(SECTION, null, KEY);
+    if (!Strings.isNullOrEmpty(origId)) {
+      id = origId;
+      return;
+    }
+
+    // We're not generally supposed to do work in provider constructors, but
+    // this is a bit of a special case because we really need to have the ID
+    // available by the time the dbInjector is created. This even applies during
+    // RebuildNoteDb, which otherwise would have been a reasonable place to do
+    // the ID generation. Fortunately, it's not much work, and it happens once.
+    id = generate();
+    Config newCfg = readGerritConfig(sitePaths);
+    newCfg.setString(SECTION, null, KEY, id);
+    Files.write(sitePaths.gerrit_config, newCfg.toText().getBytes(UTF_8));
+  }
+
+  @Override
+  public String get() {
+    return id;
+  }
+
+  private static Config readGerritConfig(SitePaths sitePaths)
+      throws IOException, ConfigInvalidException {
+    // Reread gerrit.config from disk before writing. We can't just use
+    // cfg.toText(), as the @GerritServerConfig only has gerrit.config as a
+    // fallback.
+    FileBasedConfig cfg =
+        new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      return new Config();
+    }
+    cfg.load();
+    return cfg;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
new file mode 100644
index 0000000..9f18fc3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+@Singleton
+public class GetDiffPreferences implements RestReadView<ConfigResource> {
+
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  GetDiffPreferences(GitRepositoryManager gitManager,
+      AllUsersName allUsersName) {
+    this.allUsersName = allUsersName;
+    this.gitManager = gitManager;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(ConfigResource configResource)
+      throws BadRequestException, ResourceConflictException, IOException,
+      ConfigInvalidException {
+    return readFromGit(gitManager, allUsersName, null);
+  }
+
+  static DiffPreferencesInfo readFromGit(GitRepositoryManager gitMgr,
+             AllUsersName allUsersName, DiffPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      // Load all users prefs.
+      VersionedAccountPreferences dp =
+          VersionedAccountPreferences.forDefault();
+      dp.load(git);
+      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+      loadSection(dp.getConfig(), UserConfigSections.DIFF, null, allUserPrefs,
+          DiffPreferencesInfo.defaults(), in);
+      return allUserPrefs;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
index d120275..66e45b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
@@ -14,38 +14,57 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.GetPreferences.PreferenceInfo;
+import com.google.gerrit.server.account.GeneralPreferencesLoader;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
 
 @Singleton
 public class GetPreferences implements RestReadView<ConfigResource> {
-  private final AllUsersName allUsersName;
+  private final GeneralPreferencesLoader loader;
   private final GitRepositoryManager gitMgr;
+  private final AllUsersName allUsersName;
 
   @Inject
-  public GetPreferences(AllUsersName allUsersName,
-      GitRepositoryManager gitMgr) {
-    this.allUsersName = allUsersName;
+  public GetPreferences(GeneralPreferencesLoader loader,
+      GitRepositoryManager gitMgr, AllUsersName allUsersName) {
+    this.loader = loader;
     this.gitMgr = gitMgr;
+    this.allUsersName = allUsersName;
   }
 
   @Override
-  public PreferenceInfo apply(ConfigResource rsrc)
+  public GeneralPreferencesInfo apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
+    return readFromGit(gitMgr, loader, allUsersName, null);
+  }
+
+  static GeneralPreferencesInfo readFromGit(GitRepositoryManager gitMgr,
+      GeneralPreferencesLoader loader, AllUsersName allUsersName,
+      GeneralPreferencesInfo in) throws IOException, ConfigInvalidException,
+          RepositoryNotFoundException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p =
-          VersionedAccountPreferences.forDefault();
+      VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
       p.load(git);
-      return new PreferenceInfo(null, p, git);
+
+      GeneralPreferencesInfo r = loadSection(p.getConfig(),
+          UserConfigSections.GENERAL, null, new GeneralPreferencesInfo(),
+          GeneralPreferencesInfo.defaults(), in);
+
+      // TODO(davido): Maintain cache of default values in AllUsers repository
+      return loader.loadMyMenusAndUrlAliases(r, p, null);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index 9eca842..aebe74a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -20,13 +20,15 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GitwebType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.EnableSignedPush;
@@ -35,6 +37,8 @@
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.server.change.Submit;
+import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.Config;
@@ -57,13 +61,15 @@
   private final DynamicMap<DownloadScheme> downloadSchemes;
   private final DynamicMap<DownloadCommand> downloadCommands;
   private final DynamicMap<CloneCommand> cloneCommands;
+  private final DynamicSet<WebUiPlugin> plugins;
   private final GetArchive.AllowedFormats archiveFormats;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final String anonymousCowardName;
-  private final GitwebConfig gitwebConfig;
   private final DynamicItem<AvatarProvider> avatar;
   private final boolean enableSignedPush;
+  private final QueryDocumentationExecutor docSearcher;
+  private final NotesMigration migration;
 
   @Inject
   public GetServerInfo(
@@ -73,26 +79,30 @@
       DynamicMap<DownloadScheme> downloadSchemes,
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands,
+      DynamicSet<WebUiPlugin> webUiPlugins,
       GetArchive.AllowedFormats archiveFormats,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
       @AnonymousCowardName String anonymousCowardName,
-      GitwebConfig gitwebConfig,
       DynamicItem<AvatarProvider> avatar,
-      @EnableSignedPush boolean enableSignedPush) {
+      @EnableSignedPush boolean enableSignedPush,
+      QueryDocumentationExecutor docSearcher,
+      NotesMigration migration) {
     this.config = config;
     this.authConfig = authConfig;
     this.realm = realm;
     this.downloadSchemes = downloadSchemes;
     this.downloadCommands = downloadCommands;
     this.cloneCommands = cloneCommands;
+    this.plugins = webUiPlugins;
     this.archiveFormats = archiveFormats;
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.anonymousCowardName = anonymousCowardName;
-    this.gitwebConfig = gitwebConfig;
     this.avatar = avatar;
     this.enableSignedPush = enableSignedPush;
+    this.docSearcher = docSearcher;
+    this.migration = migration;
   }
 
   @Override
@@ -104,7 +114,7 @@
         getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands,
             archiveFormats);
     info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
-    info.gitweb = getGitwebInfo(gitwebConfig);
+    info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
     info.sshd = getSshdInfo(config);
     info.suggest = getSuggestInfo(config);
@@ -123,6 +133,8 @@
     info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
     info.switchAccountUrl = cfg.getSwitchAccountUrl();
+    info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
+    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
 
     switch (info.authType) {
       case LDAP:
@@ -130,7 +142,6 @@
         info.registerUrl = cfg.getRegisterUrl();
         info.registerText = cfg.getRegisterText();
         info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
         break;
 
       case CUSTOM_EXTENSION:
@@ -158,6 +169,7 @@
 
   private ChangeConfigInfo getChangeInfo(Config cfg) {
     ChangeConfigInfo info = new ChangeConfigInfo();
+    info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
     info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
     info.largeChange = cfg.getInt("change", "largeChange", 500);
     info.replyTooltip =
@@ -238,6 +250,7 @@
     info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
     info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
     info.docUrl = getDocUrl(cfg);
+    info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys = toBoolean(enableSignedPush
         && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
     return info;
@@ -251,20 +264,19 @@
     return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
   }
 
-  private GitwebInfo getGitwebInfo(GitwebConfig cfg) {
-    if (cfg.getUrl() == null || cfg.getGitwebType() == null) {
-      return null;
-    }
-
-    GitwebInfo info = new GitwebInfo();
-    info.url = cfg.getUrl();
-    info.type = cfg.getGitwebType();
-    return info;
+  private boolean isNoteDbEnabled() {
+    return migration.readChanges();
   }
 
   private PluginConfigInfo getPluginInfo() {
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.get() != null);
+    info.jsResourcePaths = new ArrayList<>();
+    for (WebUiPlugin u : plugins) {
+      info.jsResourcePaths.add(String.format("plugins/%s/%s",
+          u.getPluginName(),
+          u.getJavaScriptResourcePath()));
+    }
     return info;
   }
 
@@ -318,7 +330,7 @@
     public ChangeConfigInfo change;
     public DownloadInfo download;
     public GerritInfo gerrit;
-    public GitwebInfo gitweb;
+    public Boolean noteDbEnabled;
     public PluginConfigInfo plugin;
     public SshdInfo sshd;
     public SuggestInfo suggest;
@@ -339,9 +351,11 @@
     public String editFullNameUrl;
     public String httpPasswordUrl;
     public Boolean isGitBasicAuth;
+    public GitBasicAuthPolicy gitBasicAuthPolicy;
   }
 
   public static class ChangeConfigInfo {
+    public Boolean allowBlame;
     public Boolean allowDrafts;
     public int largeChange;
     public String replyLabel;
@@ -366,19 +380,16 @@
   public static class GerritInfo {
     public String allProjects;
     public String allUsers;
+    public Boolean docSearch;
     public String docUrl;
+    public Boolean editGpgKeys;
     public String reportBugUrl;
     public String reportBugText;
-    public Boolean editGpgKeys;
-  }
-
-  public static class GitwebInfo {
-    public String url;
-    public GitwebType type;
   }
 
   public static class PluginConfigInfo {
     public Boolean hasAvatars;
+    public List<String> jsResourcePaths;
   }
 
   public static class SshdInfo {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
index 5dd2784..49b3467 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
@@ -30,8 +31,8 @@
       @GerritServerConfig Config config,
       ThreadLocalRequestContext threadContext,
       ServerRequestContext serverCtx) {
-    super(gb, threadContext, serverCtx, config.getStringList("receive", null,
-        "allowGroup"));
+    super(gb, threadContext, serverCtx, ImmutableList.copyOf(
+        config.getStringList("receive", null, "allowGroup")));
 
     // If no group was set, default to "registered users"
     //
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
index 545f48b..b772089 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -29,8 +30,8 @@
       @GerritServerConfig Config config,
       ThreadLocalRequestContext threadContext,
       ServerRequestContext serverCtx) {
-    super(gb, threadContext, serverCtx, config.getStringList("upload", null,
-        "allowGroup"));
+    super(gb, threadContext, serverCtx, ImmutableList.copyOf(
+        config.getStringList("upload", null, "allowGroup")));
 
     // If no group was set, default to "registered users" and "anonymous"
     //
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
index 4a58f73..830579f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
@@ -53,8 +53,12 @@
 
     String cfgCgi = cfg.getString("gitweb", null, "cgi");
     Path pkgCgi = Paths.get("/usr/lib/cgi-bin/gitweb.cgi");
-    String[] resourcePaths = {"/usr/share/gitweb/static", "/usr/share/gitweb",
-        "/var/www/static", "/var/www"};
+    String[] resourcePaths = {
+        "/usr/share/gitweb/static",
+        "/usr/share/gitweb",
+        "/var/www/static",
+        "/var/www",
+    };
     Path cgi;
 
     if (cfgCgi != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
index 10b9fb0..7d86aa2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -15,12 +15,24 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.base.Strings.isNullOrEmpty;
 import static com.google.common.base.Strings.nullToEmpty;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GitwebType;
+import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.webui.BranchWebLink;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.gerrit.extensions.webui.ParentWebLink;
+import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
@@ -31,7 +43,46 @@
 
   public static boolean isDisabled(Config cfg) {
     return isEmptyString(cfg, "gitweb", null, "url")
-        || isEmptyString(cfg, "gitweb", null, "cgi");
+        || isEmptyString(cfg, "gitweb", null, "cgi")
+        || "disabled".equals(cfg.getString("gitweb", null, "type"));
+  }
+
+  public static class LegacyModule extends AbstractModule {
+    private final Config cfg;
+
+    public LegacyModule(Config cfg) {
+      this.cfg = cfg;
+    }
+
+    @Override
+    protected void configure() {
+      GitwebType type = typeFromConfig(cfg);
+      if (type != null) {
+        bind(GitwebType.class).toInstance(type);
+
+        if (!isNullOrEmpty(type.getBranch())) {
+          DynamicSet.bind(binder(), BranchWebLink.class).to(GitwebLinks.class);
+        }
+
+        if (!isNullOrEmpty(type.getFile())
+            || !isNullOrEmpty(type.getRootTree())) {
+          DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
+        }
+
+        if (!isNullOrEmpty(type.getFileHistory())) {
+          DynamicSet.bind(binder(), FileHistoryWebLink.class).to(GitwebLinks.class);
+        }
+
+        if (!isNullOrEmpty(type.getRevision())) {
+          DynamicSet.bind(binder(), PatchSetWebLink.class).to(GitwebLinks.class);
+          DynamicSet.bind(binder(), ParentWebLink.class).to(GitwebLinks.class);
+        }
+
+        if (!isNullOrEmpty(type.getProject())) {
+          DynamicSet.bind(binder(), ProjectWebLink.class).to(GitwebLinks.class);
+        }
+      }
+    }
   }
 
   private static boolean isEmptyString(Config cfg, String section,
@@ -39,16 +90,10 @@
     // This is currently the only way to check for the empty string in a JGit
     // config. Fun!
     String[] values = cfg.getStringList(section, subsection, name);
-    return values.length > 0 && Strings.isNullOrEmpty(values[0]);
+    return values.length > 0 && isNullOrEmpty(values[0]);
   }
 
-  /**
-   * Get a GitwebType based on the given config.
-   *
-   * @param cfg Gerrit config.
-   * @return GitwebType from the given name, else null if not found.
-   */
-  public static GitwebType typeFromConfig(Config cfg) {
+  private static GitwebType typeFromConfig(Config cfg) {
     GitwebType defaultType = defaultType(cfg.getString("gitweb", null, "type"));
     if (defaultType == null) {
       return null;
@@ -76,9 +121,6 @@
     type.setFileHistory(firstNonNull(
         cfg.getString("gitweb", null, "filehistory"),
         defaultType.getFileHistory()));
-    type.setLinkDrafts(
-        cfg.getBoolean("gitweb", null, "linkdrafts",
-            defaultType.getLinkDrafts()));
     type.setUrlEncode(
         cfg.getBoolean("gitweb", null, "urlencode",
             defaultType.getUrlEncode()));
@@ -103,7 +145,6 @@
   private static GitwebType defaultType(String typeName) {
     GitwebType type = new GitwebType();
     switch (nullToEmpty(typeName)) {
-      case "":
       case "gitweb":
         type.setLinkName("gitweb");
         type.setProject("?p=${project}.git;a=summary");
@@ -133,6 +174,8 @@
         type.setFile("");
         type.setFileHistory("");
         break;
+      case "":
+      case "disabled":
       default:
         return null;
     }
@@ -147,48 +190,18 @@
     if (isDisabled(cfg)) {
       type = null;
       url = null;
-      return;
-    }
-
-    String cfgUrl = cfg.getString("gitweb", null, "url");
-    GitwebType type = typeFromConfig(cfg);
-    if (type == null) {
-      this.type = null;
-      url = null;
-      return;
-    } else if (cgiConfig.getGitwebCgi() == null) {
-      // Use an externally managed gitweb instance, and not an internal one.
-      url = cfgUrl;
     } else {
-      url = firstNonNull(cfgUrl, "gitweb");
+      String cfgUrl = cfg.getString("gitweb", null, "url");
+      type = typeFromConfig(cfg);
+      if (type == null) {
+        url = null;
+      } else if (cgiConfig.getGitwebCgi() == null) {
+        // Use an externally managed gitweb instance, and not an internal one.
+        url = cfgUrl;
+      } else {
+        url = firstNonNull(cfgUrl, "gitweb");
+      }
     }
-
-    if (isNullOrEmpty(type.getBranch())) {
-      log.warn("No Pattern specified for gitweb.branch, disabling.");
-      this.type = null;
-    } else if (isNullOrEmpty(type.getProject())) {
-      log.warn("No Pattern specified for gitweb.project, disabling.");
-      this.type = null;
-    } else if (isNullOrEmpty(type.getRevision())) {
-      log.warn("No Pattern specified for gitweb.revision, disabling.");
-      this.type = null;
-    } else if (isNullOrEmpty(type.getRootTree())) {
-      log.warn("No Pattern specified for gitweb.roottree, disabling.");
-      this.type = null;
-    } else if (isNullOrEmpty(type.getFile())) {
-      log.warn("No Pattern specified for gitweb.file, disabling.");
-      this.type = null;
-    } else if (isNullOrEmpty(type.getFileHistory())) {
-      log.warn("No Pattern specified for gitweb.filehistory, disabling.");
-      this.type = null;
-    } else {
-      this.type = type;
-    }
-  }
-
-  /** @return GitwebType for gitweb viewer. */
-  public GitwebType getGitwebType() {
-    return type;
   }
 
   /**
@@ -226,4 +239,109 @@
         return false;
     }
   }
+
+  @Singleton
+  static class GitwebLinks implements BranchWebLink, FileHistoryWebLink,
+      FileWebLink, PatchSetWebLink, ParentWebLink, ProjectWebLink {
+    private final String url;
+    private final GitwebType type;
+    private final ParameterizedString branch;
+    private final ParameterizedString file;
+    private final ParameterizedString fileHistory;
+    private final ParameterizedString project;
+    private final ParameterizedString revision;
+
+    @Inject
+    GitwebLinks(GitwebConfig config, GitwebType type) {
+      this.url = config.getUrl();
+      this.type = type;
+      this.branch = parse(type.getBranch());
+      this.file = parse(firstNonNull(
+          emptyToNull(type.getFile()),
+          nullToEmpty(type.getRootTree())));
+      this.fileHistory = parse(type.getFileHistory());
+      this.project = parse(type.getProject());
+      this.revision = parse(type.getRevision());
+    }
+
+    @Override
+    public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
+      if (branch != null) {
+        return link(branch
+            .replace("project", encode(projectName))
+            .replace("branch", encode(branchName))
+            .toString());
+      }
+      return null;
+    }
+
+    @Override
+    public WebLinkInfo getFileHistoryWebLink(String projectName,
+        String revision, String fileName) {
+      if (fileHistory != null) {
+        return link(fileHistory
+            .replace("project", encode(projectName))
+            .replace("branch", encode(revision))
+            .replace("file", encode(fileName))
+            .toString());
+      }
+      return null;
+    }
+
+    @Override
+    public WebLinkInfo getFileWebLink(String projectName, String revision,
+        String fileName) {
+      if (file != null) {
+        return link(file
+            .replace("project", encode(projectName))
+            .replace("commit", encode(revision))
+            .replace("file", encode(fileName))
+            .toString());
+      }
+      return null;
+    }
+
+    @Override
+    public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+      if (revision != null) {
+        return link(revision
+            .replace("project", encode(projectName))
+            .replace("commit", encode(commit))
+            .toString());
+      }
+      return null;
+    }
+
+    @Override
+    public WebLinkInfo getParentWebLink(String projectName, String commit) {
+      // For Gitweb treat parent revision links the same as patch set links
+      return getPatchSetWebLink(projectName, commit);
+    }
+
+    @Override
+    public WebLinkInfo getProjectWeblink(String projectName) {
+      if (project != null) {
+        return link(project.replace("project", encode(projectName)).toString());
+      }
+      return null;
+    }
+
+    private String encode(String val) {
+      if (type.getUrlEncode()) {
+        return Url.encode(type.replacePathSeparator(val));
+      }
+      return val;
+    }
+
+    private WebLinkInfo link(String rest) {
+      return new WebLinkInfo(type.getLinkName(), null, url + rest, null);
+    }
+
+    private static ParameterizedString parse(String pattern) {
+      if (!isNullOrEmpty(pattern)) {
+        return new ParameterizedString(pattern);
+      }
+      return null;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
new file mode 100644
index 0000000..2a2c316
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.server.securestore.SecureStore;
+
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Plugin configuration in etc/$PLUGIN.config and etc/$PLUGIN.secure.config.
+ */
+public class GlobalPluginConfig extends Config {
+  private final SecureStore secureStore;
+  private final String pluginName;
+
+  GlobalPluginConfig(String pluginName, Config baseConfig,
+      SecureStore secureStore) {
+    super(baseConfig);
+    this.pluginName = pluginName;
+    this.secureStore = secureStore;
+  }
+
+  @Override
+  public String getString(String section, String subsection, String name) {
+    String secure = secureStore.getForPlugin(
+        pluginName, section, subsection, name);
+    if (secure != null) {
+      return secure;
+    }
+    return super.getString(section, subsection, name);
+  }
+
+  @Override
+  public String[] getStringList(String section, String subsection, String name) {
+    String[] secure = secureStore.getListForPlugin(
+        pluginName, section, subsection, name);
+    if (secure != null && secure.length > 0) {
+      return secure;
+    }
+    return super.getStringList(section, subsection, name);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
index 9e55a7f..78af1ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -22,34 +22,33 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.inject.Inject;
 import com.google.inject.Provider;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.List;
 import java.util.Set;
 
+/** Parses groups referenced in the {@code gerrit.config} file. */
 public abstract class GroupSetProvider implements
     Provider<Set<AccountGroup.UUID>> {
-  private static final Logger log =
-      LoggerFactory.getLogger(GroupSetProvider.class);
 
   protected Set<AccountGroup.UUID> groupIds;
 
-  @Inject
   protected GroupSetProvider(GroupBackend groupBackend,
       ThreadLocalRequestContext threadContext,
-      ServerRequestContext serverCtx, String[] groupNames) {
+      ServerRequestContext serverCtx, List<String> groupNames) {
     RequestContext ctx = threadContext.setContext(serverCtx);
     try {
       ImmutableSet.Builder<AccountGroup.UUID> builder = ImmutableSet.builder();
       for (String n : groupNames) {
         GroupReference g = GroupBackends.findBestSuggestion(groupBackend, n);
-        if (g == null) {
-          log.warn("Group \"{}\" not in database, skipping.", n);
-        } else {
+        if (g != null) {
           builder.add(g.getUUID());
+        } else {
+          Logger log = LoggerFactory.getLogger(getClass());
+          log.warn("Group \"{}\" not available, skipping.", n);
         }
       }
       groupIds = builder.build();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
index 382c8fd..15981d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
@@ -41,7 +41,7 @@
 public class ListCaches implements RestReadView<ConfigResource> {
   private final DynamicMap<Cache<?, ?>> cacheMap;
 
-  public static enum OutputFormat {
+  public enum OutputFormat {
     LIST, TEXT_LIST
   }
 
@@ -58,31 +58,33 @@
     this.cacheMap = cacheMap;
   }
 
+  public Map<String, CacheInfo> getCacheInfos() {
+    Map<String, CacheInfo> cacheInfos = new TreeMap<>();
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      cacheInfos.put(cacheNameOf(e.getPluginName(), e.getExportName()),
+          new CacheInfo(e.getProvider().get()));
+    }
+    return cacheInfos;
+  }
+
   @Override
   public Object apply(ConfigResource rsrc) {
     if (format == null) {
-      Map<String, CacheInfo> cacheInfos = new TreeMap<>();
-      for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-        cacheInfos.put(cacheNameOf(e.getPluginName(), e.getExportName()),
-            new CacheInfo(e.getProvider().get()));
-      }
-      return cacheInfos;
-    } else {
-      List<String> cacheNames = new ArrayList<>();
-      for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-        cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
-      }
-      Collections.sort(cacheNames);
-
-      if (OutputFormat.TEXT_LIST.equals(format)) {
-        return BinaryResult.create(Joiner.on('\n').join(cacheNames))
-            .base64()
-            .setContentType("text/plain")
-            .setCharacterEncoding(UTF_8);
-      } else {
-        return cacheNames;
-      }
+      return getCacheInfos();
     }
+    List<String> cacheNames = new ArrayList<>();
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      cacheNames.add(cacheNameOf(e.getPluginName(), e.getExportName()));
+    }
+    Collections.sort(cacheNames);
+
+    if (OutputFormat.TEXT_LIST.equals(format)) {
+      return BinaryResult.create(Joiner.on('\n').join(cacheNames))
+          .base64()
+          .setContentType("text/plain")
+          .setCharacterEncoding(UTF_8);
+    }
+    return cacheNames;
   }
 
   public enum CacheType {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
index c6ba605..3a87239 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -28,6 +27,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.Map;
+import java.util.TreeMap;
 
 /** List capabilities visible to the calling user. */
 @Singleton
@@ -43,7 +43,7 @@
   @Override
   public Map<String, CapabilityInfo> apply(ConfigResource resource)
       throws IllegalAccessException, NoSuchFieldException {
-    Map<String, CapabilityInfo> output = Maps.newTreeMap();
+    Map<String, CapabilityInfo> output = new TreeMap<>();
     collectCoreCapabilities(output);
     collectPluginCapabilities(output);
     return output;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
index 5c651a1..b96d5d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
@@ -64,25 +64,24 @@
     List<TaskInfo> allTasks = getTasks();
     if (user.getCapabilities().canViewQueue()) {
       return allTasks;
-    } else {
-      Map<String, Boolean> visibilityCache = new HashMap<>();
+    }
+    Map<String, Boolean> visibilityCache = new HashMap<>();
 
-      List<TaskInfo> visibleTasks = new ArrayList<>();
-      for (TaskInfo task : allTasks) {
-        if (task.projectName != null) {
-          Boolean visible = visibilityCache.get(task.projectName);
-          if (visible == null) {
-            ProjectState e = projectCache.get(new Project.NameKey(task.projectName));
-            visible = e != null ? e.controlFor(user).isVisible() : false;
-            visibilityCache.put(task.projectName, visible);
-          }
-          if (visible) {
-            visibleTasks.add(task);
-          }
+    List<TaskInfo> visibleTasks = new ArrayList<>();
+    for (TaskInfo task : allTasks) {
+      if (task.projectName != null) {
+        Boolean visible = visibilityCache.get(task.projectName);
+        if (visible == null) {
+          ProjectState e = projectCache.get(new Project.NameKey(task.projectName));
+          visible = e != null ? e.controlFor(user).isVisible() : false;
+          visibilityCache.put(task.projectName, visible);
+        }
+        if (visible) {
+          visibleTasks.add(task);
         }
       }
-      return visibleTasks;
     }
+    return visibleTasks;
   }
 
   private List<TaskInfo> getTasks() {
@@ -114,6 +113,7 @@
     public String command;
     public String remoteName;
     public String projectName;
+    public String queueName;
 
     public TaskInfo(Task<?> task) {
       this.id = IdGenerator.format(task.getTaskId());
@@ -121,6 +121,7 @@
       this.startTime = new Timestamp(task.getStartTime().getTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
       this.command = task.toString();
+      this.queueName = task.getQueueName();
 
       if (task instanceof ProjectTask) {
         ProjectTask<?> projectTask = ((ProjectTask<?>) task);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
index 291e48b..c7d10a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
 import java.util.List;
 
 @Singleton
@@ -34,7 +34,7 @@
 
   @Override
   public List<TopMenu.MenuEntry> apply(ConfigResource resource) {
-    List<TopMenu.MenuEntry> entries = Lists.newArrayList();
+    List<TopMenu.MenuEntry> entries = new ArrayList<>();
     for (TopMenu extension : extensions) {
       entries.addAll(extension.getEntries());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index e909f17..a05058e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -38,6 +38,8 @@
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
+    get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
+    put(CONFIG_KIND, "preferences.diff").to(SetDiffPreferences.class);
     put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
index 0600712..d8485fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
@@ -85,10 +85,9 @@
   public String getString(String name, String defaultValue) {
     if (defaultValue == null) {
       return cfg.getString(PLUGIN, pluginName, name);
-    } else {
-      return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name),
-          defaultValue);
     }
+    return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name),
+        defaultValue);
   }
 
   public void setString(String name, String value) {
@@ -152,6 +151,6 @@
   }
 
   public Set<String> getNames() {
-    return cfg.getNames(PLUGIN, pluginName);
+    return cfg.getNames(PLUGIN, pluginName, true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
index a2aa937..eb6169e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.ProjectLevelConfig;
 import com.google.gerrit.server.plugins.Plugin;
@@ -22,7 +21,9 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,6 +37,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.HashMap;
 import java.util.Map;
 
 @Singleton
@@ -45,23 +47,29 @@
   private static final String EXTENSION = ".config";
 
   private final SitePaths site;
-  private final GerritServerConfigProvider cfgProvider;
+  private final Provider<Config> cfgProvider;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
+  private final SecureStore secureStore;
   private final Map<String, Config> pluginConfigs;
 
   private volatile FileSnapshot cfgSnapshot;
   private volatile Config cfg;
 
   @Inject
-  PluginConfigFactory(SitePaths site, GerritServerConfigProvider cfgProvider,
-      ProjectCache projectCache, ProjectState.Factory projectStateFactory) {
+  PluginConfigFactory(
+      SitePaths site,
+      @GerritServerConfig Provider<Config> cfgProvider,
+      ProjectCache projectCache,
+      ProjectState.Factory projectStateFactory,
+      SecureStore secureStore) {
     this.site = site;
     this.cfgProvider = cfgProvider;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.pluginConfigs = Maps.newHashMap();
+    this.secureStore = secureStore;
 
+    this.pluginConfigs = new HashMap<>();
     this.cfgSnapshot = FileSnapshot.save(site.gerrit_config.toFile());
     this.cfg = cfgProvider.get();
   }
@@ -256,10 +264,12 @@
     Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
     FileBasedConfig cfg =
         new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
-    pluginConfigs.put(pluginName, cfg);
+    GlobalPluginConfig pluginConfig =
+        new GlobalPluginConfig(pluginName, cfg, secureStore);
+    pluginConfigs.put(pluginName, pluginConfig);
     if (!cfg.getFile().exists()) {
       log.info("No " + pluginConfigFile.toAbsolutePath() + "; assuming defaults");
-      return cfg;
+      return pluginConfig;
     }
 
     try {
@@ -268,7 +278,7 @@
       log.warn("Failed to load " + pluginConfigFile.toAbsolutePath(), e);
     }
 
-    return cfg;
+    return pluginConfig;
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 5178210..33a458e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -52,7 +52,7 @@
     }
   }
 
-  public static enum Operation {
+  public enum Operation {
     FLUSH_ALL, FLUSH
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index b907866..cc7857c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,13 +17,14 @@
 import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -32,6 +33,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -41,15 +43,11 @@
 
 @ExtensionPoint
 public class ProjectConfigEntry {
-  public enum Type {
-    STRING, INT, LONG, BOOLEAN, LIST, ARRAY
-  }
-
   private final String displayName;
   private final String description;
   private final boolean inheritable;
   private final String defaultValue;
-  private final Type type;
+  private final ProjectConfigEntryType type;
   private final List<String> permittedValues;
 
   public ProjectConfigEntry(String displayName, String defaultValue) {
@@ -63,7 +61,8 @@
 
   public ProjectConfigEntry(String displayName, String defaultValue,
       boolean inheritable, String description) {
-    this(displayName, defaultValue, Type.STRING, null, inheritable, description);
+    this(displayName, defaultValue, ProjectConfigEntryType.STRING, null,
+        inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, int defaultValue) {
@@ -77,8 +76,8 @@
 
   public ProjectConfigEntry(String displayName, int defaultValue,
       boolean inheritable, String description) {
-    this(displayName, Integer.toString(defaultValue), Type.INT, null,
-        inheritable, description);
+    this(displayName, Integer.toString(defaultValue),
+        ProjectConfigEntryType.INT, null, inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, long defaultValue) {
@@ -92,8 +91,8 @@
 
   public ProjectConfigEntry(String displayName, long defaultValue,
       boolean inheritable, String description) {
-    this(displayName, Long.toString(defaultValue), Type.LONG, null,
-        inheritable, description);
+    this(displayName, Long.toString(defaultValue),
+        ProjectConfigEntryType.LONG, null, inheritable, description);
   }
 
   // For inheritable boolean use 'LIST' type with InheritableBoolean
@@ -104,8 +103,8 @@
   //For inheritable boolean use 'LIST' type with InheritableBoolean
   public ProjectConfigEntry(String displayName, boolean defaultValue,
       String description) {
-    this(displayName, Boolean.toString(defaultValue), Type.BOOLEAN, null,
-        false, description);
+    this(displayName, Boolean.toString(defaultValue),
+        ProjectConfigEntryType.BOOLEAN, null, false, description);
   }
 
   public ProjectConfigEntry(String displayName, String defaultValue,
@@ -120,8 +119,8 @@
 
   public ProjectConfigEntry(String displayName, String defaultValue,
       List<String> permittedValues, boolean inheritable, String description) {
-    this(displayName, defaultValue, Type.LIST, permittedValues, inheritable,
-        description);
+    this(displayName, defaultValue, ProjectConfigEntryType.LIST,
+        permittedValues, inheritable, description);
   }
 
   public <T extends Enum<?>> ProjectConfigEntry(String displayName,
@@ -137,26 +136,27 @@
   public <T extends Enum<?>> ProjectConfigEntry(String displayName,
       T defaultValue, Class<T> permittedValues, boolean inheritable,
       String description) {
-    this(displayName, defaultValue.name(), Type.LIST, Lists.transform(
-        Arrays.asList(permittedValues.getEnumConstants()),
-        new Function<Enum<?>, String>() {
-          @Override
-          public String apply(Enum<?> e) {
-            return e.name();
-          }
-        }), inheritable, description);
+    this(displayName, defaultValue.name(), ProjectConfigEntryType.LIST,
+        Lists.transform(
+            Arrays.asList(permittedValues.getEnumConstants()),
+            new Function<Enum<?>, String>() {
+              @Override
+              public String apply(Enum<?> e) {
+                return e.name();
+              }
+            }), inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, String defaultValue,
-      Type type, List<String> permittedValues, boolean inheritable,
-      String description) {
+      ProjectConfigEntryType type, List<String> permittedValues,
+      boolean inheritable, String description) {
     this.displayName = displayName;
     this.defaultValue = defaultValue;
     this.type = type;
     this.permittedValues = permittedValues;
     this.inheritable = inheritable;
     this.description = description;
-    if (type == Type.ARRAY && inheritable) {
+    if (type == ProjectConfigEntryType.ARRAY && inheritable) {
       throw new ProvisionException(
           "ARRAY doesn't support inheritable values");
     }
@@ -178,7 +178,7 @@
     return defaultValue;
   }
 
-  public Type getType() {
+  public ProjectConfigEntryType getType() {
     return type;
   }
 
@@ -285,13 +285,13 @@
   public static class UpdateChecker implements GitReferenceUpdatedListener {
     private static final Logger log = LoggerFactory.getLogger(UpdateChecker.class);
 
-    private final MetaDataUpdate.Server metaDataUpdateFactory;
+    private final GitRepositoryManager repoManager;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
     @Inject
-    UpdateChecker(MetaDataUpdate.Server metaDataUpdateFactory,
+    UpdateChecker(GitRepositoryManager repoManager,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
-      this.metaDataUpdateFactory = metaDataUpdateFactory;
+      this.repoManager = repoManager;
       this.pluginConfigEntries = pluginConfigEntries;
     }
 
@@ -327,6 +327,7 @@
                 break;
               case LIST:
               case STRING:
+              case ARRAY:
               default:
                 configEntry.onUpdate(p, oldValue, newValue);
             }
@@ -345,7 +346,11 @@
       if (ObjectId.zeroId().equals(id)) {
         return null;
       }
-      return ProjectConfig.read(metaDataUpdateFactory.create(p), id);
+      try (Repository repo = repoManager.openRepository(p)) {
+        ProjectConfig pc = new ProjectConfig(p);
+        pc.load(repo, id);
+        return pc;
+      }
     }
 
     private static String getValue(ProjectConfig cfg, Entry<ProjectConfigEntry> e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 23615d6..34946ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -37,7 +37,7 @@
 public class ProjectOwnerGroupsProvider extends GroupSetProvider {
 
   public interface Factory {
-    public ProjectOwnerGroupsProvider create(Project.NameKey project);
+    ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
   @AssistedInject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
index c34b8a6..e250395 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
@@ -21,12 +22,18 @@
 
 import org.eclipse.jgit.lib.Config;
 
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+
 @Singleton
 public class RepositoryConfig {
 
   static final String SECTION_NAME = "repository";
   static final String OWNER_GROUP_NAME = "ownerGroup";
   static final String DEFAULT_SUBMIT_TYPE_NAME = "defaultSubmitType";
+  static final String BASE_PATH_NAME = "basePath";
 
   private final Config cfg;
 
@@ -40,9 +47,26 @@
         DEFAULT_SUBMIT_TYPE_NAME, SubmitType.MERGE_IF_NECESSARY);
   }
 
-  public String[] getOwnerGroups(Project.NameKey project) {
-    return cfg.getStringList(SECTION_NAME, findSubSection(project.get()),
-        OWNER_GROUP_NAME);
+  public List<String> getOwnerGroups(Project.NameKey project) {
+    return ImmutableList.copyOf(cfg.getStringList(SECTION_NAME,
+        findSubSection(project.get()), OWNER_GROUP_NAME));
+  }
+
+  public Path getBasePath(Project.NameKey project) {
+    String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()),
+        BASE_PATH_NAME);
+    return basePath != null ? Paths.get(basePath) : null;
+  }
+
+  public List<Path> getAllBasePaths() {
+    List<Path> basePaths = new ArrayList<>();
+    for (String subSection : cfg.getSubsections(SECTION_NAME)) {
+      String basePath = cfg.getString(SECTION_NAME, subSection, BASE_PATH_NAME);
+      if (basePath != null) {
+        basePaths.add(Paths.get(basePath));
+      }
+    }
+    return basePaths;
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
new file mode 100644
index 0000000..8ca072a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.config.GetDiffPreferences.readFromGit;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class SetDiffPreferences implements
+    RestModifyView<ConfigResource, DiffPreferencesInfo> {
+  private static final Logger log =
+      LoggerFactory.getLogger(SetDiffPreferences.class);
+
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  SetDiffPreferences(GitRepositoryManager gitManager,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName) {
+    this.gitManager = gitManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(ConfigResource configResource,
+      DiffPreferencesInfo in)
+          throws BadRequestException, IOException, ConfigInvalidException {
+    if (in == null) {
+      throw new BadRequestException("input must be provided");
+    }
+    if (!hasSetFields(in)) {
+      throw new BadRequestException("unsupported option");
+    }
+    return writeToGit(readFromGit(gitManager, allUsersName, in));
+  }
+
+  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    DiffPreferencesInfo out = new DiffPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      VersionedAccountPreferences prefs =
+          VersionedAccountPreferences.forDefault();
+      prefs.load(md);
+      DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in,
+          defaults);
+      prefs.commit(md);
+      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out,
+          DiffPreferencesInfo.defaults(), null);
+    }
+    return out;
+  }
+
+  private static boolean hasSetFields(DiffPreferencesInfo in) {
+    try {
+      for (Field field : in.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        if (field.get(in) != null) {
+          return true;
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Unable to verify input", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
index 519a4a4..c5c75ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
@@ -14,60 +14,104 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.config.GetPreferences.readFromGit;
+
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.GetPreferences.PreferenceInfo;
-import com.google.gerrit.server.account.SetPreferences.Input;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GeneralPreferencesLoader;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.lang.reflect.Field;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
-public class SetPreferences implements RestModifyView<ConfigResource, Input> {
+public class SetPreferences implements
+    RestModifyView<ConfigResource, GeneralPreferencesInfo> {
+  private static final Logger log =
+      LoggerFactory.getLogger(SetPreferences.class);
+
+  private final GeneralPreferencesLoader loader;
+  private final GitRepositoryManager gitManager;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
+  private final AccountCache accountCache;
 
   @Inject
-  SetPreferences(Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName) {
+  SetPreferences(GeneralPreferencesLoader loader,
+      GitRepositoryManager gitManager,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName,
+      AccountCache accountCache) {
+    this.loader = loader;
+    this.gitManager = gitManager;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
+    this.accountCache = accountCache;
   }
 
   @Override
-  public Object apply(ConfigResource rsrc, Input i) throws BadRequestException,
-      IOException, ConfigInvalidException {
-    if (i.changesPerPage != null || i.showSiteHeader != null
-        || i.useFlashClipboard != null || i.downloadScheme != null
-        || i.downloadCommand != null || i.copySelfOnEmail != null
-        || i.dateFormat != null || i.timeFormat != null
-        || i.relativeDateInChangeTable != null
-        || i.sizeBarInChangeTable != null
-        || i.legacycidInChangeTable != null
-        || i.muteCommonPathPrefixes != null
-        || i.reviewCategoryStrategy != null) {
+  public GeneralPreferencesInfo apply(ConfigResource rsrc,
+      GeneralPreferencesInfo i)
+          throws BadRequestException, IOException, ConfigInvalidException {
+    if (!hasSetFields(i)) {
       throw new BadRequestException("unsupported option");
     }
+    return writeToGit(readFromGit(gitManager, loader, allUsersName, i));
+  }
 
-    VersionedAccountPreferences p;
-    MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName);
-    try {
-      p = VersionedAccountPreferences.forDefault();
+  private GeneralPreferencesInfo writeToGit(GeneralPreferencesInfo i)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
       p.load(md);
+      storeSection(p.getConfig(), UserConfigSections.GENERAL, null, i,
+          GeneralPreferencesInfo.defaults());
       com.google.gerrit.server.account.SetPreferences.storeMyMenus(p, i.my);
+      com.google.gerrit.server.account.SetPreferences.storeUrlAliases(p, i.urlAliases);
       p.commit(md);
-      return new PreferenceInfo(null, p, md.getRepository());
-    } finally {
-      md.close();
+
+      accountCache.evictAll();
+
+      GeneralPreferencesInfo r = loadSection(p.getConfig(),
+          UserConfigSections.GENERAL, null, new GeneralPreferencesInfo(),
+          GeneralPreferencesInfo.defaults(), null);
+      return loader.loadMyMenusAndUrlAliases(r, p, null);
     }
   }
+
+
+  private static boolean hasSetFields(GeneralPreferencesInfo in) {
+    try {
+      for (Field field : in.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        if (field.get(in) != null) {
+          return true;
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Unable to verify input", e);
+    }
+    return false;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
index e41fb30..192ca49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
@@ -38,6 +38,7 @@
   public final Path tmp_dir;
   public final Path logs_dir;
   public final Path plugins_dir;
+  public final Path db_dir;
   public final Path data_dir;
   public final Path mail_dir;
   public final Path hooks_dir;
@@ -75,6 +76,7 @@
     lib_dir = p.resolve("lib");
     tmp_dir = p.resolve("tmp");
     plugins_dir = p.resolve("plugins");
+    db_dir = p.resolve("db");
     data_dir = p.resolve("data");
     logs_dir = p.resolve("logs");
     mail_dir = etc_dir.resolve("mail");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
new file mode 100644
index 0000000..c62583e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class ThreadSettingsConfig {
+  private final int sshdThreads;
+  private final int httpdMaxThreads;
+  private final int sshdBatchThreads;
+  private final int databasePoolLimit;
+
+  @Inject
+  ThreadSettingsConfig(@GerritServerConfig Config cfg) {
+    int cores = Runtime.getRuntime().availableProcessors();
+    sshdThreads = cfg.getInt("sshd", "threads", 2 * cores);
+    httpdMaxThreads = cfg.getInt("httpd", "maxThreads", 25);
+    int defaultDatabasePoolLimit = sshdThreads + httpdMaxThreads + 2;
+    databasePoolLimit =
+        cfg.getInt("database", "poolLimit", defaultDatabasePoolLimit);
+    sshdBatchThreads = cores == 1 ? 1 : 2;
+  }
+
+  public int getDatabasePoolLimit() {
+    return databasePoolLimit;
+  }
+
+  public int getHttpdMaxThreads() {
+    return httpdMaxThreads;
+  }
+
+  public int getSshdThreads() {
+    return sshdThreads;
+  }
+
+  public int getSshdBatchTreads() {
+    return sshdBatchThreads;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
new file mode 100644
index 0000000..f328b1ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+/**
+ * Verbosity level of the commit message for submodule subscriptions.
+ */
+public enum VerboseSuperprojectUpdate {
+  /** Do not include any commit messages for the gitlink update. */
+  FALSE,
+
+  /** Only include the commit subjects. */
+  SUBJECT_ONLY,
+
+  /** Include full commit messages. */
+  TRUE
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
index 3059be3..90b6fc4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
@@ -18,7 +18,7 @@
     public String type;
     public String description;
     public String value;
-
+    public String oldValue;
     public Long grantedOn;
     public AccountAttribute by;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
index c669e26..824d800 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.data;
 
-import com.google.gerrit.server.change.ChangeKind;
+import com.google.gerrit.extensions.client.ChangeKind;
 
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 446013e..9b15a42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.documentation;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -22,8 +23,7 @@
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.IndexReader;
-import org.apache.lucene.queryparser.classic.ParseException;
-import org.apache.lucene.queryparser.classic.QueryParser;
+import org.apache.lucene.queryparser.simple.SimpleQueryParser;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
@@ -37,6 +37,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
+import java.util.Map;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 
@@ -45,8 +46,12 @@
   private static final Logger log =
       LoggerFactory.getLogger(QueryDocumentationExecutor.class);
 
+  private static Map<String, Float> WEIGHTS = ImmutableMap.of(
+      Constants.TITLE_FIELD, 2.0f,
+      Constants.DOC_FIELD, 1.0f);
+
   private IndexSearcher searcher;
-  private QueryParser parser;
+  private SimpleQueryParser parser;
 
   public static class DocResult {
     public String title;
@@ -65,7 +70,7 @@
       }
       IndexReader reader = DirectoryReader.open(dir);
       searcher = new IndexSearcher(reader);
-      parser = new QueryParser(Constants.DOC_FIELD, new StandardAnalyzer());
+      parser = new SimpleQueryParser(new StandardAnalyzer(), WEIGHTS);
     } catch (IOException e) {
       log.error("Cannot initialize documentation full text index", e);
       searcher = null;
@@ -74,11 +79,11 @@
   }
 
   public List<DocResult> doQuery(String q) throws DocQueryException {
-    if (parser == null || searcher == null) {
+    if (!isAvailable()) {
       throw new DocQueryException("Documentation search not available");
     }
+    Query query = parser.parse(q);
     try {
-      Query query = parser.parse(q);
       // TODO(fishywang): Currently as we don't have much documentation, we just use MAX_VALUE here
       // and skipped paging. Maybe add paging later.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
@@ -94,7 +99,7 @@
         out.add(result);
       }
       return out;
-    } catch (IOException | ParseException e) {
+    } catch (IOException e) {
       throw new DocQueryException(e);
     }
   }
@@ -123,6 +128,10 @@
     return dir;
   }
 
+  public boolean isAvailable() {
+    return parser != null && searcher != null;
+  }
+
   @SuppressWarnings("serial")
   public static class DocQueryException extends Exception {
     DocQueryException() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 738d309..be9e1b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.edit;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FetchInfo;
@@ -31,6 +30,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 @Singleton
@@ -78,7 +78,7 @@
   }
 
   private Map<String, FetchInfo> fillFetchMap(ChangeEdit edit) {
-    Map<String, FetchInfo> r = Maps.newLinkedHashMap();
+    Map<String, FetchInfo> r = new LinkedHashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
       DownloadScheme scheme = e.getProvider().get();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index e44c810..45128bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -34,8 +34,11 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,6 +49,7 @@
 import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
 import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.FileMode;
@@ -67,8 +71,10 @@
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.sql.Timestamp;
 import java.util.Map;
 import java.util.TimeZone;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Utility functions to manipulate change edits.
@@ -81,7 +87,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private static enum TreeOperation {
+  private enum TreeOperation {
     CHANGE_ENTRY,
     DELETE_ENTRY,
     RENAME_ENTRY,
@@ -92,18 +98,21 @@
   private final ChangeIndexer indexer;
   private final Provider<ReviewDb> reviewDb;
   private final Provider<CurrentUser> currentUser;
+  private final ChangeControl.GenericFactory changeControlFactory;
 
   @Inject
   ChangeEditModifier(@GerritPersonIdent PersonIdent gerritIdent,
       GitRepositoryManager gitManager,
       ChangeIndexer indexer,
       Provider<ReviewDb> reviewDb,
-      Provider<CurrentUser> currentUser) {
+      Provider<CurrentUser> currentUser,
+      ChangeControl.GenericFactory changeControlFactory) {
     this.gitManager = gitManager;
     this.indexer = indexer;
     this.reviewDb = reviewDb;
     this.currentUser = currentUser;
     this.tz = gerritIdent.getTimeZone();
+    this.changeControlFactory = changeControlFactory;
   }
 
   /**
@@ -116,16 +125,26 @@
    * @throws IOException
    * @throws ResourceConflictException When change edit already
    * exists for the change
+   * @throws OrmException
    */
   public RefUpdate.Result createEdit(Change change, PatchSet ps)
-      throws AuthException, IOException, ResourceConflictException {
+      throws AuthException, IOException, ResourceConflictException, OrmException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-
     IdentifiedUser me = currentUser.get().asIdentifiedUser();
     String refPrefix = RefNames.refsEditPrefix(me.getAccountId(), change.getId());
 
+    try {
+      ChangeControl c =
+          changeControlFactory.controlFor(reviewDb.get(), change, me);
+      if (!c.canAddPatchSet(reviewDb.get())) {
+        return RefUpdate.Result.REJECTED;
+      }
+    } catch (NoSuchChangeException e) {
+      return RefUpdate.Result.NO_CHANGE;
+    }
+
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       Map<String, Ref> refs = repo.getRefDatabase().getRefs(refPrefix);
       if (!refs.isEmpty()) {
@@ -136,8 +155,8 @@
         ObjectId revision = ObjectId.fromString(ps.getRevision().get());
         String editRefName = RefNames.refsEdit(me.getAccountId(), change.getId(),
             ps.getId());
-        Result res =
-            update(repo, me, editRefName, rw, ObjectId.zeroId(), revision);
+        Result res = update(repo, me, editRefName, rw, ObjectId.zeroId(),
+            revision, TimeUtil.nowTs());
         indexer.index(reviewDb.get(), change);
         return res;
       }
@@ -244,11 +263,12 @@
         RevWalk rw = new RevWalk(repo);
         ObjectInserter inserter = repo.newObjectInserter()) {
       String refName = edit.getRefName();
+      Timestamp now = TimeUtil.nowTs();
       ObjectId commit = createCommit(me, inserter, prevEdit,
           prevEdit.getTree(),
-          msg);
+          msg, now);
       inserter.flush();
-      return update(repo, me, refName, rw, prevEdit, commit);
+      return update(repo, me, refName, rw, prevEdit, commit, now);
     }
   }
 
@@ -332,21 +352,23 @@
         ObjectReader reader = repo.newObjectReader()) {
       String refName = edit.getRefName();
       RevCommit prevEdit = edit.getEditCommit();
-      ObjectId newTree = writeNewTree(op,
+      ObjectId newTree = writeNewTree(
+          op,
           rw,
           inserter,
           prevEdit,
           reader,
           file,
           newFile,
-          toBlob(inserter, content));
+          content);
       if (ObjectId.equals(newTree, prevEdit.getTree())) {
         throw new InvalidChangeOperationException("no changes were made");
       }
 
-      ObjectId commit = createCommit(me, inserter, prevEdit, newTree);
+      Timestamp now = TimeUtil.nowTs();
+      ObjectId commit = createCommit(me, inserter, prevEdit, newTree, now);
       inserter.flush();
-      return update(repo, me, refName, rw, prevEdit, commit);
+      return update(repo, me, refName, rw, prevEdit, commit, now);
     }
   }
 
@@ -365,30 +387,30 @@
   }
 
   private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter,
-      RevCommit revision, ObjectId tree) throws IOException {
+      RevCommit revision, ObjectId tree, Timestamp when) throws IOException {
     return createCommit(me, inserter, revision, tree,
-        revision.getFullMessage());
+        revision.getFullMessage(), when);
   }
 
   private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter,
-      RevCommit revision, ObjectId tree, String msg)
+      RevCommit revision, ObjectId tree, String msg, Timestamp when)
       throws IOException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(tree);
     builder.setParentIds(revision.getParents());
     builder.setAuthor(revision.getAuthorIdent());
-    builder.setCommitter(getCommitterIdent(me));
+    builder.setCommitter(getCommitterIdent(me, when));
     builder.setMessage(msg);
     return inserter.insert(builder);
   }
 
   private RefUpdate.Result update(Repository repo, IdentifiedUser me,
-      String refName, RevWalk rw, ObjectId oldObjectId, ObjectId newEdit)
-      throws IOException {
+      String refName, RevWalk rw, ObjectId oldObjectId, ObjectId newEdit,
+      Timestamp when) throws IOException {
     RefUpdate ru = repo.updateRef(refName);
     ru.setExpectedOldObjectId(oldObjectId);
     ru.setNewObjectId(newEdit);
-    ru.setRefLogIdent(getRefLogIdent(me));
+    ru.setRefLogIdent(getRefLogIdent(me, when));
     ru.setRefLogMessage("inline edit (amend)", false);
     ru.setForceUpdate(true);
     RefUpdate.Result res = ru.update(rw);
@@ -399,10 +421,16 @@
     return res;
   }
 
-  private static ObjectId writeNewTree(TreeOperation op, RevWalk rw,
-      ObjectInserter ins, RevCommit prevEdit, ObjectReader reader,
-      String fileName, @Nullable String newFile,
-      @Nullable final ObjectId content) throws IOException {
+  private static ObjectId writeNewTree(
+      TreeOperation op,
+      RevWalk rw,
+      final ObjectInserter ins,
+      RevCommit prevEdit,
+      ObjectReader reader,
+      String fileName,
+      @Nullable String newFile,
+      @Nullable final RawInput content)
+      throws InvalidChangeOperationException, IOException {
     DirCache newTree = readTree(reader, prevEdit);
     DirCacheEditor dce = newTree.editor();
     switch (op) {
@@ -422,15 +450,41 @@
 
       case CHANGE_ENTRY:
         checkNotNull(content, "new content required");
+
+        final AtomicReference<IOException> ioe =
+            new AtomicReference<>(null);
+        final AtomicReference<InvalidChangeOperationException> icoe =
+            new AtomicReference<>(null);
         dce.add(new PathEdit(fileName) {
           @Override
           public void apply(DirCacheEntry ent) {
-            if (ent.getRawMode() == 0) {
-              ent.setFileMode(FileMode.REGULAR_FILE);
+            try {
+              if (ent.getFileMode() == FileMode.GITLINK) {
+                ent.setLength(0);
+                ent.setLastModified(0);
+                ent.setObjectId(ObjectId.fromString(
+                    ByteStreams.toByteArray(content.getInputStream()), 0));
+              } else {
+                if (ent.getRawMode() == 0) {
+                  ent.setFileMode(FileMode.REGULAR_FILE);
+                }
+                ent.setObjectId(toBlob(ins, content));
+              }
+            } catch (IOException e) {
+              ioe.set(e);
+            } catch (InvalidObjectIdException e) {
+              icoe.set(new InvalidChangeOperationException(
+                  "Invalid object id in submodule link: " + e.getMessage()));
+              icoe.get().initCause(e);
             }
-            ent.setObjectId(content);
           }
         });
+        if (ioe.get() != null) {
+          throw ioe.get();
+        }
+        if (icoe.get() != null) {
+          throw icoe.get();
+        }
         break;
 
       case RESTORE_ENTRY:
@@ -476,11 +530,11 @@
     return dc;
   }
 
-  private PersonIdent getCommitterIdent(IdentifiedUser user) {
-    return user.newCommitterIdent(TimeUtil.nowTs(), tz);
+  private PersonIdent getCommitterIdent(IdentifiedUser user, Timestamp when) {
+    return user.newCommitterIdent(when, tz);
   }
 
-  private PersonIdent getRefLogIdent(IdentifiedUser user) {
-    return user.newRefLogIdent(TimeUtil.nowTs(), tz);
+  private PersonIdent getRefLogIdent(IdentifiedUser user, Timestamp when) {
+    return user.newRefLogIdent(when, tz);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6a933e4..6811056 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,18 +30,17 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ChangeKind;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -67,69 +67,82 @@
 public class ChangeEditUtil {
   private final GitRepositoryManager gitManager;
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
   private final ProjectCache projectCache;
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeKindCache changeKindCache;
   private final BatchUpdate.Factory updateFactory;
+  private final PatchSetUtil psUtil;
 
   @Inject
   ChangeEditUtil(GitRepositoryManager gitManager,
       PatchSetInserter.Factory patchSetInserterFactory,
-      ProjectControl.GenericFactory projectControlFactory,
+      ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
       ProjectCache projectCache,
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeKindCache changeKindCache,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      PatchSetUtil psUtil) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
-    this.projectControlFactory = projectControlFactory;
+    this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
     this.projectCache = projectCache;
     this.db = db;
     this.user = user;
     this.changeKindCache = changeKindCache;
     this.updateFactory = updateFactory;
+    this.psUtil = psUtil;
   }
 
   /**
-   * Retrieve edits for a change and user. Max. one change edit can
-   * exist per user and change.
+   * Retrieve edit for a change and the user from the request scope.
+   * <p>
+   * At most one change edit can exist per user and change.
    *
    * @param change
    * @return edit for this change for this user, if present.
    * @throws AuthException
    * @throws IOException
+   * @throws OrmException
    */
   public Optional<ChangeEdit> byChange(Change change)
-      throws AuthException, IOException {
-    CurrentUser currentUser = user.get();
-    if (!currentUser.isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+      throws AuthException, IOException, OrmException {
+    try {
+      return byChange(
+          changeControlFactory.controlFor(db.get(), change, user.get()));
+    } catch (NoSuchChangeException e) {
+      throw new IOException(e);
     }
-    return byChange(change, currentUser.asIdentifiedUser());
   }
 
   /**
-   * Retrieve edits for a change and user. Max. one change edit can
-   * exist per user and change.
+   * Retrieve edit for a change and the given user.
+   * <p>
+   * At most one change edit can exist per user and change.
    *
-   * @param change
-   * @param user to retrieve change edits for
+   * @param ctl control with user to retrieve change edits for.
    * @return edit for this change for this user, if present.
-   * @throws IOException
+   * @throws AuthException if this is not a logged-in user.
+   * @throws IOException if an error occurs.
    */
-  public Optional<ChangeEdit> byChange(Change change, IdentifiedUser user)
-      throws IOException {
+  public Optional<ChangeEdit> byChange(ChangeControl ctl)
+      throws AuthException, IOException {
+    if (!ctl.getUser().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    IdentifiedUser u = ctl.getUser().asIdentifiedUser();
+    Change change = ctl.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       int n = change.currentPatchSetId().get();
       String[] refNames = new String[n];
       for (int i = n; i > 0; i--) {
-        refNames[i-1] = RefNames.refsEdit(user.getAccountId(), change.getId(),
+        refNames[i - 1] = RefNames.refsEdit(
+            u.getAccountId(), change.getId(),
             new PatchSet.Id(change.getId(), i));
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
@@ -138,8 +151,8 @@
       }
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
-        PatchSet basePs = getBasePatchSet(change, ref);
-        return Optional.of(new ChangeEdit(user, change, ref, commit, basePs));
+        PatchSet basePs = getBasePatchSet(ctl, ref);
+        return Optional.of(new ChangeEdit(u, change, ref, commit, basePs));
       }
     }
   }
@@ -149,13 +162,13 @@
    * its parent.
    *
    * @param edit change edit to publish
-   * @throws NoSuchProjectException
+   * @throws NoSuchChangeException
    * @throws IOException
    * @throws OrmException
    * @throws UpdateException
    * @throws RestApiException
    */
-  public void publish(ChangeEdit edit) throws NoSuchProjectException,
+  public void publish(ChangeEdit edit) throws NoSuchChangeException,
       IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
@@ -181,9 +194,10 @@
    *
    * @param edit change edit to delete
    * @throws IOException
+   * @throws OrmException
    */
   public void delete(ChangeEdit edit)
-      throws IOException {
+      throws IOException, OrmException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       deleteRef(repo, edit);
@@ -191,14 +205,14 @@
     indexer.index(db.get(), change);
   }
 
-  private PatchSet getBasePatchSet(Change change, Ref ref)
+  private PatchSet getBasePatchSet(ChangeControl ctl, Ref ref)
       throws IOException {
     try {
       int pos = ref.getName().lastIndexOf("/");
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
-      return db.get().patchSets().get(new PatchSet.Id(
-          change.getId(), Integer.parseInt(psId)));
+      return psUtil.get(db.get(), ctl.getNotes(),
+          new PatchSet.Id(ctl.getId(), Integer.parseInt(psId)));
     } catch (OrmException | NumberFormatException e) {
       throw new IOException(e);
     }
@@ -218,18 +232,15 @@
 
   private Change insertPatchSet(ChangeEdit edit, Change change,
       Repository repo, RevWalk rw, ObjectInserter oi, PatchSet basePatchSet,
-      RevCommit squashed) throws NoSuchProjectException, RestApiException,
-      UpdateException, IOException {
-    RefControl ctl = projectControlFactory
-        .controlFor(change.getProject(), edit.getUser())
-        .controlForRef(change.getDest());
+      RevCommit squashed) throws NoSuchChangeException, RestApiException,
+      UpdateException, OrmException, IOException {
+    ChangeControl ctl =
+        changeControlFactory.controlFor(db.get(), change, edit.getUser());
     PatchSet.Id psId =
         ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
     PatchSetInserter inserter =
         patchSetInserterFactory.create(ctl, psId, squashed);
 
-    inserter.setUploader(edit.getUser().getAccountId());
-
     StringBuilder message = new StringBuilder("Patch Set ")
       .append(inserter.getPatchSetId().get())
       .append(": ");
@@ -272,6 +283,13 @@
       case NEW:
       case NO_CHANGE:
         break;
+      case FAST_FORWARD:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
       default:
         throw new IOException(String.format("Failed to delete ref %s: %s",
             refName, result));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
index c285a82..32b5b02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeAbandonedEvent.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeAbandonedEvent extends PatchSetEvent {
-  public AccountAttribute abandoner;
+  static final String TYPE = "change-abandoned";
+  public Supplier<AccountAttribute> abandoner;
   public String reason;
 
-  public ChangeAbandonedEvent() {
-    super("change-abandoned");
+  public ChangeAbandonedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java
index f083b7a..6029ded 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeEvent.java
@@ -14,29 +14,36 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.data.ChangeAttribute;
 
 public abstract class ChangeEvent extends RefEvent {
-  public ChangeAttribute change;
+  public Supplier<ChangeAttribute> change;
+  public Project.NameKey project;
+  public String refName;
+  public Change.Key changeKey;
 
-  protected ChangeEvent(String type) {
+  protected ChangeEvent(String type, Change change) {
     super(type);
+    this.project = change.getProject();
+    this.refName = RefNames.fullName(change.getDest().get());
+    this.changeKey = change.getKey();
   }
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(change.project);
+    return project;
   }
 
   @Override
   public String getRefName() {
-    return RefNames.fullName(change.branch);
+    return refName;
   }
 
   public Change.Key getChangeKey() {
-    return new Change.Key(change.id);
+    return changeKey;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
index 41a95cb..3fb2ac8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeMergedEvent extends PatchSetEvent {
-  public AccountAttribute submitter;
+  public static final String TYPE = "change-merged";
+  public Supplier<AccountAttribute> submitter;
   public String newRev;
 
-  public ChangeMergedEvent() {
-    super("change-merged");
+  public ChangeMergedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
index a575a42..639ca55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeRestoredEvent extends PatchSetEvent {
-  public AccountAttribute restorer;
+  static final String TYPE = "change-restored";
+  public Supplier<AccountAttribute> restorer;
   public String reason;
 
-  public ChangeRestoredEvent () {
-    super("change-restored");
+  public ChangeRestoredEvent (Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java
index 4391dfb..bb1ac4d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommentAddedEvent.java
@@ -14,15 +14,18 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 
 public class CommentAddedEvent extends PatchSetEvent {
-  public AccountAttribute author;
-  public ApprovalAttribute[] approvals;
+  static final String TYPE = "comment-added";
+  public Supplier<AccountAttribute> author;
+  public Supplier<ApprovalAttribute[]> approvals;
   public String comment;
 
-  public CommentAddedEvent() {
-    super("comment-added");
+  public CommentAddedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 8843dbb..085abe3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -21,6 +21,7 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class CommitReceivedEvent extends RefEvent {
+  static final String TYPE = "commit-received";
   public ReceiveCommand command;
   public Project project;
   public String refName;
@@ -28,7 +29,7 @@
   public IdentifiedUser user;
 
   public CommitReceivedEvent() {
-    super("commit-received");
+    super(TYPE);
   }
 
   public CommitReceivedEvent(ReceiveCommand command, Project project,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java
index 5db628f..0724253 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class DraftPublishedEvent extends PatchSetEvent {
-  public AccountAttribute uploader;
+  static final String TYPE = "draft-published";
+  public Supplier<AccountAttribute> uploader;
 
-  public DraftPublishedEvent() {
-    super("draft-published");
+  public DraftPublishedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index d52f831..56daccc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -56,11 +57,10 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -73,11 +73,13 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 @Singleton
 public class EventFactory {
@@ -86,32 +88,35 @@
   private final AccountCache accountCache;
   private final Provider<String> urlProvider;
   private final PatchListCache patchListCache;
-  private final PatchSetInfoFactory psInfoFactory;
+  private final AccountByEmailCache byEmailCache;
   private final PersonIdent myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeKindCache changeKindCache;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final SchemaFactory<ReviewDb> schema;
 
   @Inject
   EventFactory(AccountCache accountCache,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      PatchSetInfoFactory psif,
+      AccountByEmailCache byEmailCache,
       PatchListCache patchListCache,
       @GerritPersonIdent PersonIdent myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
-      Provider<InternalChangeQuery> queryProvider) {
+      Provider<InternalChangeQuery> queryProvider,
+      SchemaFactory<ReviewDb> schema) {
     this.accountCache = accountCache;
     this.urlProvider = urlProvider;
     this.patchListCache = patchListCache;
-    this.psInfoFactory = psif;
+    this.byEmailCache = byEmailCache;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.changeKindCache = changeKindCache;
     this.queryProvider = queryProvider;
+    this.schema = schema;
   }
 
   /**
@@ -121,6 +126,23 @@
    * @param change
    * @return object suitable for serialization to JSON
    */
+  public ChangeAttribute asChangeAttribute(Change change) {
+    try (ReviewDb db = schema.open()) {
+      return asChangeAttribute(db, change);
+    } catch (OrmException e) {
+      log.error("Cannot open database connection", e);
+      return new ChangeAttribute();
+    }
+  }
+
+  /**
+   * Create a ChangeAttribute for the given change suitable for serialization to
+   * JSON.
+   *
+   * @param db Review database
+   * @param change
+   * @return object suitable for serialization to JSON
+   */
   public ChangeAttribute asChangeAttribute(ReviewDb db, Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
@@ -181,7 +203,7 @@
   public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
       throws OrmException {
     Collection<Account.Id> reviewers =
-        approvalsUtil.getReviewers(db, notes).values();
+        approvalsUtil.getReviewers(db, notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
@@ -222,7 +244,7 @@
         SubmitLabelAttribute la = new SubmitLabelAttribute();
         la.label = lbl.label;
         la.status = lbl.status.name();
-        if(lbl.appliedBy != null) {
+        if (lbl.appliedBy != null) {
           Account a = accountCache.get(lbl.appliedBy).getAccount();
           la.by = asAccountAttribute(a);
         }
@@ -292,7 +314,7 @@
 
   private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change,
       PatchSet currentPs) throws OrmException, IOException {
-    if (currentPs.getGroups() == null || currentPs.getGroups().isEmpty()) {
+    if (currentPs.getGroups().isEmpty()) {
       return;
     }
     String rev = currentPs.getRevision().get();
@@ -365,12 +387,12 @@
     if (!ps.isEmpty()) {
       ca.patchSets = new ArrayList<>(ps.size());
       for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(db, revWalk, p);
+        PatchSetAttribute psa = asPatchSetAttribute(db, revWalk, change, p);
         if (approvals != null) {
           addApprovals(psa, p.getId(), approvals, labelTypes);
         }
         ca.patchSets.add(psa);
-        if (includeFiles && change != null) {
+        if (includeFiles) {
           addPatchSetFileNames(psa, change, p);
         }
       }
@@ -426,11 +448,30 @@
    * Create a PatchSetAttribute for the given patchset suitable for
    * serialization to JSON.
    *
+   * @param revWalk
+   * @param patchSet
+   * @return object suitable for serialization to JSON
+   */
+  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk,
+      Change change, PatchSet patchSet) {
+    try (ReviewDb db = schema.open()) {
+      return asPatchSetAttribute(db, revWalk, change, patchSet);
+    } catch (OrmException e) {
+      log.error("Cannot open database connection", e);
+      return new PatchSetAttribute();
+    }
+  }
+
+  /**
+   * Create a PatchSetAttribute for the given patchset suitable for
+   * serialization to JSON.
+   *
+   * @param db Review database
    * @param patchSet
    * @return object suitable for serialization to JSON
    */
   public PatchSetAttribute asPatchSetAttribute(ReviewDb db, RevWalk revWalk,
-      PatchSet patchSet) {
+      Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.getRevision().get();
     p.number = Integer.toString(patchSet.getPatchSetId());
@@ -446,7 +487,7 @@
         p.parents.add(parent.name());
       }
 
-      UserIdentity author = psInfoFactory.get(db, pId).getAuthor();
+      UserIdentity author = toUserIdentity(c.getAuthorIdent());
       if (author.getAccount() == null) {
         p.author = new AccountAttribute();
         p.author.email = author.getEmail();
@@ -456,7 +497,6 @@
         p.author = asAccountAttribute(author.getAccount());
       }
 
-      Change change = db.changes().get(pId.getParentKey());
       List<Patch> list =
           patchListCache.get(change, patchSet).toPatchList(pId);
       for (Patch pe : list) {
@@ -466,16 +506,34 @@
         }
       }
       p.kind = changeKindCache.getChangeKind(db, change, patchSet);
-    } catch (OrmException | IOException e) {
+    } catch (IOException e) {
       log.error("Cannot load patch set data for " + patchSet.getId(), e);
-    } catch (PatchSetInfoNotAvailableException e) {
-      log.error(String.format("Cannot get authorEmail for %s.", pId), e);
     } catch (PatchListNotAvailableException e) {
       log.error(String.format("Cannot get size information for %s.", pId), e);
     }
     return p;
   }
 
+  // TODO: The same method exists in PatchSetInfoFactory, find a common place
+  // for it
+  private UserIdentity toUserIdentity(PersonIdent who) {
+    UserIdentity u = new UserIdentity();
+    u.setName(who.getName());
+    u.setEmail(who.getEmailAddress());
+    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setTimeZone(who.getTimeZoneOffset());
+
+    // If only one account has access to this email address, select it
+    // as the identity of the user.
+    //
+    Set<Account.Id> a = byEmailCache.get(u.getEmail());
+    if (a.size() == 1) {
+      u.setAccount(a.iterator().next());
+    }
+
+    return u;
+  }
+
   public void addApprovals(PatchSetAttribute p, PatchSet.Id id,
       Map<PatchSet.Id, Collection<PatchSetApproval>> all,
       LabelTypes labelTypes) {
@@ -562,6 +620,7 @@
     a.value = Short.toString(approval.getValue());
     a.by = asAccountAttribute(approval.getAccountId());
     a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.oldValue = null;
 
     LabelType lt = labelTypes.byLabel(approval.getLabelId());
     if (lt != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 9b37c38..447e8b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -22,30 +22,31 @@
   private static final Map<String, Class<?>> typesByString = new HashMap<>();
 
   static {
-    registerClass(new ChangeAbandonedEvent());
-    registerClass(new ChangeMergedEvent());
-    registerClass(new ChangeRestoredEvent());
-    registerClass(new CommentAddedEvent());
-    registerClass(new CommitReceivedEvent());
-    registerClass(new DraftPublishedEvent());
-    registerClass(new HashtagsChangedEvent());
-    registerClass(new MergeFailedEvent());
-    registerClass(new RefUpdatedEvent());
-    registerClass(new RefReceivedEvent());
-    registerClass(new ReviewerAddedEvent());
-    registerClass(new PatchSetCreatedEvent());
-    registerClass(new TopicChangedEvent());
-    registerClass(new ProjectCreatedEvent());
+    register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
+    register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
+    register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
+    register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
+    register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
+    register(DraftPublishedEvent.TYPE, DraftPublishedEvent.class);
+    register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
+    register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
+    register(RefReceivedEvent.TYPE, RefReceivedEvent.class);
+    register(ReviewerAddedEvent.TYPE, ReviewerAddedEvent.class);
+    register(ReviewerDeletedEvent.TYPE, ReviewerDeletedEvent.class);
+    register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
+    register(TopicChangedEvent.TYPE, TopicChangedEvent.class);
+    register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
   }
 
-  /** Register an event.
+  /**
+   * Register an event type and associated class.
    *
-   *  @param event The event to register.
-   *  registered.
+   * @param eventType The event type to register.
+   * @param eventClass The event class to register.
    **/
-  public static void registerClass(Event event) {
-    String type = event.getType();
-    typesByString.put(type, event.getClass());
+  public static void register(String eventType,
+      Class<? extends Event> eventClass) {
+    typesByString.put(eventType, eventClass);
   }
 
   /** Get the class for an event type.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
new file mode 100644
index 0000000..eaaf1a83
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class EventsMetrics implements EventListener {
+  private final Counter1<String> events;
+
+  @Inject
+  public EventsMetrics(MetricMaker metricMaker) {
+    events = metricMaker.newCounter(
+        "events",
+        new Description("Triggered events")
+          .setRate()
+          .setUnit("triggered events"),
+        Field.ofString("type"));
+  }
+
+  @Override
+  public void onEvent(com.google.gerrit.server.events.Event event) {
+    events.increment(event.getType());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
index c5919e5..4365c74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
@@ -14,15 +14,18 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class HashtagsChangedEvent extends ChangeEvent {
-  public AccountAttribute editor;
+  static final String TYPE = "hashtags-changed";
+  public Supplier<AccountAttribute> editor;
   public String[] added;
   public String[] removed;
   public String[] hashtags;
 
-  public HashtagsChangedEvent () {
-    super("hashtags-changed");
+  public HashtagsChangedEvent (Change change) {
+    super(TYPE, change);
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/MergeFailedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/MergeFailedEvent.java
deleted file mode 100644
index 75cbcb0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/MergeFailedEvent.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.gerrit.server.data.AccountAttribute;
-
-public class MergeFailedEvent extends PatchSetEvent {
-  public AccountAttribute submitter;
-  public String reason;
-
-  public MergeFailedEvent() {
-    super("merge-failed");
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
index e468593..8cea856 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetCreatedEvent.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class PatchSetCreatedEvent extends PatchSetEvent {
-  public AccountAttribute uploader;
+  static final String TYPE = "patchset-created";
+  public Supplier<AccountAttribute> uploader;
 
-  public PatchSetCreatedEvent() {
-    super("patchset-created");
+  public PatchSetCreatedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java
index cdaf601..f9dde66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/PatchSetEvent.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.PatchSetAttribute;
 
 public class PatchSetEvent extends ChangeEvent {
-  public PatchSetAttribute patchSet;
+  public Supplier<PatchSetAttribute> patchSet;
 
-  protected PatchSetEvent(String type) {
-    super(type);
+  protected PatchSetEvent(String type, Change change) {
+    super(type, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
index c1534df..dc979ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectCreatedEvent.java
@@ -17,11 +17,12 @@
 import com.google.gerrit.reviewdb.client.Project;
 
 public class ProjectCreatedEvent extends ProjectEvent {
+  static final String TYPE = "project-created";
   public String projectName;
   public String headName;
 
   public ProjectCreatedEvent() {
-    super("project-created");
+    super(TYPE);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
new file mode 100644
index 0000000..e2f51ac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+import java.lang.reflect.Type;
+
+public class ProjectNameKeySerializer
+    implements JsonSerializer<Project.NameKey> {
+  @Override
+  public JsonElement serialize(Project.NameKey project, Type typeOfSrc,
+      JsonSerializationContext context) {
+    return new JsonPrimitive(project.get());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java
index 646bb96..951940f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefEvent.java
@@ -14,10 +14,16 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.gerrit.reviewdb.client.Branch;
+
 public abstract class RefEvent extends ProjectEvent {
   protected RefEvent(String type) {
     super(type);
   }
 
+  public Branch.NameKey getBranchNameKey() {
+    return new Branch.NameKey(getProjectNameKey(), getRefName());
+  }
+
   public abstract String getRefName();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
index 38f4442..6cc1ae5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -19,12 +19,13 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class RefReceivedEvent extends RefEvent {
+  static final String TYPE = "ref-received";
   public ReceiveCommand command;
   public Project project;
   public IdentifiedUser user;
 
   public RefReceivedEvent() {
-    super("ref-received");
+    super(TYPE);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
index e5039ff..d740543 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
@@ -14,25 +14,27 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 
 public class RefUpdatedEvent extends RefEvent {
-  public AccountAttribute submitter;
-  public RefUpdateAttribute refUpdate;
+  public static final String TYPE = "ref-updated";
+  public Supplier<AccountAttribute> submitter;
+  public Supplier<RefUpdateAttribute> refUpdate;
 
   public RefUpdatedEvent() {
-    super("ref-updated");
+    super(TYPE);
   }
 
   @Override
   public Project.NameKey getProjectNameKey() {
-    return new Project.NameKey(refUpdate.project);
+    return new Project.NameKey(refUpdate.get().project);
   }
 
   @Override
   public String getRefName() {
-    return refUpdate.refName;
+    return refUpdate.get().refName;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
index b016bd9..9644456 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerAddedEvent.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ReviewerAddedEvent extends PatchSetEvent {
-  public AccountAttribute reviewer;
+  static final String TYPE = "reviewer-added";
+  public Supplier<AccountAttribute> reviewer;
 
-  public ReviewerAddedEvent() {
-    super("reviewer-added");
+  public ReviewerAddedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
new file mode 100644
index 0000000..f206cac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+
+public class ReviewerDeletedEvent extends PatchSetEvent {
+  public static final String TYPE = "reviewer-deleted";
+  public Supplier<AccountAttribute> reviewer;
+  public Supplier<ApprovalAttribute[]> approvals;
+  public String comment;
+
+  public ReviewerDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
new file mode 100644
index 0000000..5294391
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -0,0 +1,484 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.EventDispatcher;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.PatchSetAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+@Singleton
+public class StreamEventsApiListener implements
+    ChangeAbandonedListener,
+    ChangeMergedListener,
+    ChangeRestoredListener,
+    CommentAddedListener,
+    DraftPublishedListener,
+    GitReferenceUpdatedListener,
+    HashtagsEditedListener,
+    NewProjectCreatedListener,
+    ReviewerAddedListener,
+    ReviewerDeletedListener,
+    RevisionCreatedListener,
+    TopicEditedListener {
+  private static final Logger log =
+      LoggerFactory.getLogger(StreamEventsApiListener.class);
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), ChangeAbandonedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeMergedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeRestoredListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CommentAddedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), DraftPublishedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HashtagsEditedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), NewProjectCreatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerAddedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerDeletedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), RevisionCreatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), TopicEditedListener.class)
+        .to(StreamEventsApiListener.class);
+    }
+  }
+
+  private final DynamicItem<EventDispatcher> dispatcher;
+  private final Provider<ReviewDb> db;
+  private final EventFactory eventFactory;
+  private final ProjectCache projectCache;
+  private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  StreamEventsApiListener(DynamicItem<EventDispatcher> dispatcher,
+      Provider<ReviewDb> db,
+      EventFactory eventFactory,
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager,
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.dispatcher = dispatcher;
+    this.db = db;
+    this.eventFactory = eventFactory;
+    this.projectCache = projectCache;
+    this.repoManager = repoManager;
+    this.psUtil = psUtil;
+    this.changeNotesFactory = changeNotesFactory;
+  }
+
+  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
+    try {
+      return changeNotesFactory.createChecked(new Change.Id(info._number));
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Change getChange(ChangeInfo info) throws OrmException {
+    return getNotes(info).getChange();
+  }
+
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info)
+      throws OrmException {
+    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
+  }
+
+  private Supplier<ChangeAttribute> changeAttributeSupplier(
+      final Change change) {
+    return Suppliers.memoize(
+        new Supplier<ChangeAttribute>() {
+          @Override
+          public ChangeAttribute get() {
+            return eventFactory.asChangeAttribute(change);
+          }
+        });
+  }
+
+  private Supplier<AccountAttribute> accountAttributeSupplier(
+      final AccountInfo account) {
+    return Suppliers.memoize(
+        new Supplier<AccountAttribute>() {
+          @Override
+          public AccountAttribute get() {
+            return eventFactory.asAccountAttribute(
+                new Account.Id(account._accountId));
+          }
+        });
+  }
+
+  private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
+      final Change change, final PatchSet patchSet) {
+    return Suppliers.memoize(
+        new Supplier<PatchSetAttribute>() {
+          @Override
+          public PatchSetAttribute get() {
+            try (Repository repo =
+                  repoManager.openRepository(change.getProject());
+                RevWalk revWalk = new RevWalk(repo)) {
+              return eventFactory.asPatchSetAttribute(
+                  revWalk, change, patchSet);
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  private static Map<String, Short> convertApprovalsMap(
+      Map<String, ApprovalInfo> approvals) {
+    Map<String, Short> result = new HashMap<>();
+    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
+      Short value =
+          e.getValue().value == null ? null : e.getValue().value.shortValue();
+      result.put(e.getKey(), value);
+    }
+    return result;
+  }
+
+  private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
+      Entry<String, Short> approval,
+      Map<String, Short> oldApprovals) {
+  ApprovalAttribute a = new ApprovalAttribute();
+    a.type = approval.getKey();
+
+    if (oldApprovals != null && !oldApprovals.isEmpty()) {
+      if (oldApprovals.get(approval.getKey()) != null) {
+        a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
+      }
+    }
+    LabelType lt = labelTypes.byLabel(approval.getKey());
+    if (lt != null) {
+      a.description = lt.getName();
+    }
+    if (approval.getValue() != null) {
+      a.value = Short.toString(approval.getValue());
+    }
+    return a;
+  }
+
+  private Supplier<ApprovalAttribute[]> approvalsAttributeSupplier(
+      final Change change, Map<String, ApprovalInfo> newApprovals,
+      final Map<String, ApprovalInfo> oldApprovals) {
+    final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
+    return Suppliers.memoize(
+        new Supplier<ApprovalAttribute[]>() {
+          @Override
+          public ApprovalAttribute[] get() {
+            LabelTypes labelTypes = projectCache.get(
+                change.getProject()).getLabelTypes();
+            if (approvals.size() > 0) {
+              ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
+              int i = 0;
+              for (Map.Entry<String, Short> approval : approvals.entrySet()) {
+                r[i++] = getApprovalAttribute(labelTypes, approval,
+                    convertApprovalsMap(oldApprovals));
+              }
+              return r;
+            }
+            return null;
+          }
+        });
+  }
+
+  String[] hashtagArray(Collection<String> hashtags) {
+    if (hashtags != null && hashtags.size() > 0) {
+      return Sets.newHashSet(hashtags).toArray(
+          new String[hashtags.size()]);
+    }
+    return null;
+  }
+
+  @Override
+  public void onTopicEdited(TopicEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      TopicChangedEvent event = new TopicChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.oldTopic = ev.getOldTopic();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
+      PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.uploader = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onReviewerDeleted(final ReviewerDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.comment = ev.getComment();
+      event.approvals = approvalsAttributeSupplier(change,
+          ev.getNewApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+
+  }
+
+  @Override
+  public void onReviewerAdded(ReviewerAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerAddedEvent event = new ReviewerAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
+    ProjectCreatedEvent event = new ProjectCreatedEvent();
+    event.projectName = ev.getProjectName();
+    event.headName = ev.getHeadName();
+
+    dispatcher.get().postEvent(event.getProjectNameKey(), event);
+  }
+
+  @Override
+  public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      HashtagsChangedEvent event = new HashtagsChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.editor = accountAttributeSupplier(ev.getWho());
+      event.hashtags = hashtagArray(ev.getHashtags());
+      event.added = hashtagArray(ev.getAddedHashtags());
+      event.removed = hashtagArray(ev.getRemovedHashtags());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onGitReferenceUpdated(final GitReferenceUpdatedListener.Event ev) {
+    RefUpdatedEvent event = new RefUpdatedEvent();
+    if (ev.getUpdater() != null) {
+      event.submitter = accountAttributeSupplier(ev.getUpdater());
+    }
+    final Branch.NameKey refName =
+        new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    event.refUpdate = Suppliers.memoize(
+        new Supplier<RefUpdateAttribute>() {
+          @Override
+          public RefUpdateAttribute get() {
+            return eventFactory.asRefUpdateAttribute(
+                ObjectId.fromString(ev.getOldObjectId()),
+                ObjectId.fromString(ev.getNewObjectId()),
+                refName);
+          }
+        });
+    dispatcher.get().postEvent(refName, event);
+  }
+
+  @Override
+  public void onDraftPublished(DraftPublishedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet ps = getPatchSet(notes, ev.getRevision());
+      DraftPublishedEvent event = new DraftPublishedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.uploader = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onCommentAdded(CommentAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet ps = getPatchSet(notes, ev.getRevision());
+      CommentAddedEvent event = new CommentAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.author =  accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.comment = ev.getComment();
+      event.approvals = approvalsAttributeSupplier(
+          change, ev.getApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeRestored(ChangeRestoredListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeRestoredEvent event = new ChangeRestoredEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.restorer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeMerged(ChangeMergedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeMergedEvent event = new ChangeMergedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.submitter = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.newRev = ev.getNewRevisionId();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeAbandoned(ChangeAbandonedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.abandoner = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
new file mode 100644
index 0000000..fd7b350
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.events;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+public class SupplierDeserializer implements JsonDeserializer<Supplier<?>> {
+
+  @Override
+  public Supplier<?> deserialize(JsonElement json, Type typeOfT,
+      JsonDeserializationContext context) throws JsonParseException {
+    checkArgument(typeOfT instanceof ParameterizedType);
+    ParameterizedType parameterizedType = (ParameterizedType) typeOfT;
+    if (parameterizedType.getActualTypeArguments().length != 1) {
+      throw new JsonParseException(
+          "Expected one parameter type in Supplier interface.");
+    }
+    Type supplierOf = parameterizedType.getActualTypeArguments()[0];
+    return Suppliers.ofInstance(context.deserialize(json, supplierOf));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
new file mode 100644
index 0000000..76138b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+import java.lang.reflect.Type;
+
+public class SupplierSerializer implements JsonSerializer<Supplier<?>> {
+
+  @Override
+  public JsonElement serialize(Supplier<?> src, Type typeOfSrc,
+      JsonSerializationContext context) {
+    return context.serialize(src.get());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java
index 7bb334f..0b6ecc5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/TopicChangedEvent.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class TopicChangedEvent extends ChangeEvent {
-  public AccountAttribute changer;
+  static final String TYPE = "topic-changed";
+  public Supplier<AccountAttribute> changer;
   public String oldTopic;
 
-  public TopicChangedEvent() {
-    super("topic-changed");
+  public TopicChangedEvent(Change change) {
+    super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
new file mode 100644
index 0000000..be6f692
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeEvent;
+
+import java.sql.Timestamp;
+
+public abstract class AbstractChangeEvent implements ChangeEvent {
+  private final ChangeInfo changeInfo;
+  private final AccountInfo who;
+  private final Timestamp when;
+  private final NotifyHandling notify;
+
+  protected AbstractChangeEvent(ChangeInfo change, AccountInfo who,
+      Timestamp when, NotifyHandling notify) {
+    this.changeInfo = change;
+    this.who = who;
+    this.when = when;
+    this.notify = notify;
+  }
+
+  @Override
+  public ChangeInfo getChange() {
+    return changeInfo;
+  }
+
+  @Override
+  public AccountInfo getWho() {
+    return who;
+  }
+
+  @Override
+  public Timestamp getWhen() {
+    return when;
+  }
+
+  @Override
+  public NotifyHandling getNotify() {
+    return notify;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java
new file mode 100644
index 0000000..b48d532
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractNoNotifyEvent.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.events.GerritEvent;
+
+/** Intermediate class for events that do not support notification type. */
+public abstract class AbstractNoNotifyEvent implements GerritEvent {
+  @Override
+  public NotifyHandling getNotify() {
+    return NotifyHandling.NONE;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
new file mode 100644
index 0000000..d3d7e09
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionEvent;
+
+import java.sql.Timestamp;
+
+public abstract class AbstractRevisionEvent extends AbstractChangeEvent
+    implements RevisionEvent {
+
+  private final RevisionInfo revisionInfo;
+
+  protected AbstractRevisionEvent(ChangeInfo change, RevisionInfo revision,
+      AccountInfo who, Timestamp when, NotifyHandling notify) {
+    super(change, who, when, notify);
+    revisionInfo = revision;
+  }
+
+  @Override
+  public RevisionInfo getRevision() {
+    return revisionInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
new file mode 100644
index 0000000..c910a7a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+
+public class AgreementSignup {
+  private final DynamicSet<AgreementSignupListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  AgreementSignup(DynamicSet<AgreementSignupListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Account account, String agreementName) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event event = new Event(util.accountInfo(account), agreementName);
+    for (AgreementSignupListener l : listeners) {
+      try {
+        l.onAgreementSignup(event);
+      } catch (Exception e) {
+        util.logEventListenerError(this, l, e);
+      }
+    }
+  }
+
+  private static class Event extends AbstractNoNotifyEvent
+      implements AgreementSignupListener.Event {
+    private final AccountInfo account;
+    private final String agreementName;
+
+    Event(AccountInfo account, String agreementName) {
+      this.account = account;
+      this.agreementName = agreementName;
+    }
+
+    @Override
+    public AccountInfo getAccount() {
+      return account;
+    }
+
+    @Override
+    public String getAgreementName() {
+      return agreementName;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
new file mode 100644
index 0000000..e303d8b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class ChangeAbandoned {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeAbandoned.class);
+
+  private final DynamicSet<ChangeAbandonedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps, Account abandoner, String reason,
+      Timestamp when, NotifyHandling notifyHandling) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(abandoner),
+          reason, when, notifyHandling);
+      for (ChangeAbandonedListener l : listeners) {
+        try {
+          l.onChangeAbandoned(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeAbandonedListener.Event {
+    private final AccountInfo abandoner;
+    private final String reason;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo abandoner,
+        String reason, Timestamp when, NotifyHandling notifyHandling) {
+      super(change, revision, abandoner, when, notifyHandling);
+      this.abandoner = abandoner;
+      this.reason = reason;
+    }
+
+    @Override
+    public AccountInfo getAbandoner() {
+      return abandoner;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
new file mode 100644
index 0000000..00d276b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class ChangeMerged {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeMerged.class);
+
+  private final DynamicSet<ChangeMergedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeMerged(DynamicSet<ChangeMergedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps, Account merger,
+      String newRevisionId, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(merger),
+          newRevisionId, when);
+      for (ChangeMergedListener l : listeners) {
+        try {
+          l.onChangeMerged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeMergedListener.Event {
+    private final AccountInfo merger;
+    private final String newRevisionId;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo merger,
+        String newRevisionId, Timestamp when) {
+      super(change, revision, merger, when, NotifyHandling.ALL);
+      this.merger = merger;
+      this.newRevisionId = newRevisionId;
+    }
+
+    @Override
+    public AccountInfo getMerger() {
+      return merger;
+    }
+
+    @Override
+    public String getNewRevisionId() {
+      return newRevisionId;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
new file mode 100644
index 0000000..5dda4d1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class ChangeRestored {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeRestored.class);
+
+  private final DynamicSet<ChangeRestoredListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps, Account restorer, String reason,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(restorer),
+          reason, when);
+      for (ChangeRestoredListener l : listeners) {
+        try {
+          l.onChangeRestored(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeRestoredListener.Event {
+
+    private AccountInfo restorer;
+    private String reason;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo restorer,
+        String reason, Timestamp when) {
+      super(change, revision, restorer, when, NotifyHandling.ALL);
+      this.restorer = restorer;
+      this.reason = reason;
+    }
+
+    @Override
+    public AccountInfo getRestorer() {
+      return restorer;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
new file mode 100644
index 0000000..d963a47
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeRevertedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Timestamp;
+
+public class ChangeReverted {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeReverted.class);
+
+  private final DynamicSet<ChangeRevertedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeReverted(DynamicSet<ChangeRevertedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Change revertChange, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change), util.changeInfo(revertChange), when);
+      for (ChangeRevertedListener l : listeners) {
+        try {
+          l.onChangeReverted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements ChangeRevertedListener.Event {
+    private final ChangeInfo revertChange;
+
+    Event(ChangeInfo change, ChangeInfo revertChange, Timestamp when) {
+      super(change, revertChange.owner, when, NotifyHandling.ALL);
+      this.revertChange = revertChange;
+    }
+
+    @Override
+    public ChangeInfo getRevertChange() {
+      return revertChange;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
new file mode 100644
index 0000000..0c75e2e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Map;
+
+public class CommentAdded {
+  private static final Logger log =
+      LoggerFactory.getLogger(CommentAdded.class);
+
+  private final DynamicSet<CommentAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  CommentAdded(DynamicSet<CommentAddedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps, Account author,
+      String comment, Map<String, Short> approvals,
+      Map<String, Short> oldApprovals, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(author),
+          comment,
+          util.approvals(author, approvals, when),
+          util.approvals(author, oldApprovals, when),
+          when);
+      for (CommentAddedListener l : listeners) {
+        try {
+          l.onCommentAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements CommentAddedListener.Event {
+
+    private final AccountInfo author;
+    private final String comment;
+    private final Map<String, ApprovalInfo> approvals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo author,
+        String comment, Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
+      super(change, revision, author, when, NotifyHandling.ALL);
+      this.author = author;
+      this.comment = comment;
+      this.approvals = approvals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public AccountInfo getAuthor() {
+      return author;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getApprovals() {
+      return approvals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
new file mode 100644
index 0000000..9e3e5a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class DraftPublished {
+  private static final Logger log =
+      LoggerFactory.getLogger(DraftPublished.class);
+
+  private final DynamicSet<DraftPublishedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public DraftPublished(DynamicSet<DraftPublishedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account accountId,
+      Timestamp when) {
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(accountId),
+          when);
+      for (DraftPublishedListener l : listeners) {
+        try {
+          l.onDraftPublished(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements DraftPublishedListener.Event {
+    private final AccountInfo publisher;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher,
+        Timestamp when) {
+      super(change, revision, publisher, when, NotifyHandling.ALL);
+      this.publisher = publisher;
+    }
+
+    @Override
+    public AccountInfo getPublisher() {
+      return publisher;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
new file mode 100644
index 0000000..e519410
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+
+public class EventUtil {
+  private static final Logger log = LoggerFactory.getLogger(EventUtil.class);
+
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<ReviewDb> db;
+  private final ChangeJson changeJson;
+
+  @Inject
+  EventUtil(ChangeJson.Factory changeJsonFactory,
+      ChangeData.Factory changeDataFactory,
+      Provider<ReviewDb> db) {
+    this.changeDataFactory = changeDataFactory;
+    this.db = db;
+    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
+    opts.remove(ListChangesOption.CHECK);
+    this.changeJson = changeJsonFactory.create(opts);
+  }
+
+  public ChangeInfo changeInfo(Change change) throws OrmException {
+    return changeJson.format(change);
+  }
+
+  public RevisionInfo revisionInfo(Project project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException,
+             IOException {
+    return revisionInfo(project.getNameKey(), ps);
+  }
+
+  public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException,
+             IOException {
+    ChangeData cd = changeDataFactory.create(db.get(),
+        project, ps.getId().getParentKey());
+    ChangeControl ctl = cd.changeControl();
+    return changeJson.getRevisionInfo(ctl, ps);
+  }
+
+  public AccountInfo accountInfo(Account a) {
+    if (a == null || a.getId() == null) {
+      return null;
+    }
+    AccountInfo ai = new AccountInfo(a.getId().get());
+    ai.email = a.getPreferredEmail();
+    ai.name = a.getFullName();
+    ai.username = a.getUserName();
+    return ai;
+  }
+
+  public Map<String, ApprovalInfo> approvals(Account a,
+      Map<String, Short> approvals, Timestamp ts) {
+    Map<String, ApprovalInfo> result = new HashMap<>();
+    for (Map.Entry<String, Short> e : approvals.entrySet()) {
+      Integer value = e.getValue() != null ? new Integer(e.getValue()) : null;
+      result.put(e.getKey(),
+          ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
+    }
+    return result;
+  }
+
+  public void logEventListenerError(Object event, Object listener,
+      Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug(String.format(
+          "Error in event listener %s for event %s",
+          listener.getClass().getName(), event.getClass().getName()), error);
+    } else {
+      log.warn("Error in listener {} for event {}: {}",
+          listener.getClass().getName(), event.getClass().getName(),
+          error.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 7eef0ee..381dced 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 
@@ -23,67 +26,99 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Collections;
 
 public class GitReferenceUpdated {
-  private static final Logger log = LoggerFactory
-      .getLogger(GitReferenceUpdated.class);
+  public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated() {
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        ReceiveCommand.Type type, Account updater) {}
 
-  public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated(
-      Collections.<GitReferenceUpdatedListener> emptyList());
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        Account updater) {}
 
-  private final Iterable<GitReferenceUpdatedListener> listeners;
+    @Override
+    public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
+        ObjectId newObjectId, Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, ReceiveCommand cmd,
+        Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
+        Account updater) {}
+  };
+
+  private final DynamicSet<GitReferenceUpdatedListener> listeners;
+  private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners) {
+  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners,
+      EventUtil util) {
     this.listeners = listeners;
+    this.util = util;
   }
 
-  GitReferenceUpdated(Iterable<GitReferenceUpdatedListener> listeners) {
-    this.listeners = listeners;
+  private GitReferenceUpdated() {
+    this.listeners = null;
+    this.util = null;
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate,
-      ReceiveCommand.Type type) {
+      ReceiveCommand.Type type, Account updater) {
     fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), type);
+        refUpdate.getNewObjectId(), type, util.accountInfo(updater));
   }
 
-  public void fire(Project.NameKey project, RefUpdate refUpdate) {
+  public void fire(Project.NameKey project, RefUpdate refUpdate,
+      Account updater) {
     fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE);
+        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE,
+        util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId, ReceiveCommand.Type type) {
-    ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
-    ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
-    Event event = new Event(project, ref, o.name(), n.name(), type);
-    for (GitReferenceUpdatedListener l : listeners) {
-      try {
-        l.onGitReferenceUpdated(event);
-      } catch (RuntimeException e) {
-        log.warn("Failure in GitReferenceUpdatedListener", e);
+      ObjectId newObjectId, Account updater) {
+    fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE,
+        util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, ReceiveCommand cmd, Account updater) {
+    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType(),
+        util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
+      Account updater) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() == ReceiveCommand.Result.OK) {
+        fire(project,
+            cmd.getRefName(),
+            cmd.getOldId(),
+            cmd.getNewId(),
+            cmd.getType(),
+            util.accountInfo(updater));
       }
     }
   }
 
-  public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId) {
-    fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE);
-  }
-
-  public void fire(Project.NameKey project, ReceiveCommand cmd) {
-    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType());
-  }
-
-  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate) {
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(project, cmd);
+  private void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
+      ObjectId newObjectId, ReceiveCommand.Type type, AccountInfo updater) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
+    ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
+    Event event = new Event(project, ref, o.name(), n.name(), type, updater);
+    for (GitReferenceUpdatedListener l : listeners) {
+      try {
+        l.onGitReferenceUpdated(event);
+      } catch (Exception e) {
+        util.logEventListenerError(this, l, e);
       }
     }
   }
@@ -94,15 +129,18 @@
     private final String oldObjectId;
     private final String newObjectId;
     private final ReceiveCommand.Type type;
+    private final AccountInfo updater;
 
     Event(Project.NameKey project, String ref,
         String oldObjectId, String newObjectId,
-        ReceiveCommand.Type type) {
+        ReceiveCommand.Type type,
+        AccountInfo updater) {
       this.projectName = project.get();
       this.ref = ref;
       this.oldObjectId = oldObjectId;
       this.newObjectId = newObjectId;
       this.type = type;
+      this.updater = updater;
     }
 
     @Override
@@ -141,9 +179,19 @@
     }
 
     @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+
+    @Override
     public String toString() {
       return String.format("%s[%s,%s: %s -> %s]", getClass().getSimpleName(),
           projectName, ref, oldObjectId, newObjectId);
     }
+
+    @Override
+    public NotifyHandling getNotify() {
+      return NotifyHandling.ALL;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
new file mode 100644
index 0000000..27770fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Set;
+
+public class HashtagsEdited {
+  private static final Logger log =
+      LoggerFactory.getLogger(HashtagsEdited.class);
+
+  private final DynamicSet<HashtagsEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public HashtagsEdited(DynamicSet<HashtagsEditedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account editor,
+      ImmutableSortedSet<String> hashtags, Set<String> added,
+      Set<String> removed, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.accountInfo(editor),
+          hashtags, added, removed,
+          when);
+      for (HashtagsEditedListener l : listeners) {
+        try {
+          l.onHashtagsEdited(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements HashtagsEditedListener.Event {
+
+    private AccountInfo editor;
+    private Collection<String> updatedHashtags;
+    private Collection<String> addedHashtags;
+    private Collection<String> removedHashtags;
+
+    Event(ChangeInfo change, AccountInfo editor, Collection<String> updated,
+        Collection<String> added, Collection<String> removed, Timestamp when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.editor = editor;
+      this.updatedHashtags = updated;
+      this.addedHashtags = added;
+      this.removedHashtags = removed;
+    }
+
+    @Override
+    public AccountInfo getEditor() {
+      return editor;
+    }
+
+    @Override
+    public Collection<String> getHashtags() {
+      return updatedHashtags;
+    }
+
+    @Override
+    public Collection<String> getAddedHashtags() {
+      return addedHashtags;
+    }
+
+    @Override
+    public Collection<String> getRemovedHashtags() {
+      return removedHashtags;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
new file mode 100644
index 0000000..fbca02e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.events.PluginEventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+
+public class PluginEvent {
+  private final DynamicSet<PluginEventListener> listeners;
+
+  @Inject
+  PluginEvent(DynamicSet<PluginEventListener> listeners) {
+    this.listeners = listeners;
+  }
+
+  public void fire(String pluginName, String type, String data) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(pluginName, type, data);
+    for (PluginEventListener l : listeners) {
+      l.onPluginEvent(e);
+    }
+  }
+
+  private static class Event extends AbstractNoNotifyEvent
+      implements PluginEventListener.Event {
+    private final String pluginName;
+    private final String type;
+    private final String data;
+
+    Event(String pluginName, String type, String data) {
+      this.pluginName = pluginName;
+      this.type = type;
+      this.data = data;
+    }
+
+    @Override
+    public String pluginName() {
+      return pluginName;
+    }
+
+    @Override
+    public String getType() {
+      return type;
+    }
+
+    @Override
+    public String getData() {
+      return data;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
new file mode 100644
index 0000000..e9c44a5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class ReviewerAdded {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewerAdded.class);
+
+  private final DynamicSet<ReviewerAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account account,
+      Account adder, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(account),
+          util.accountInfo(adder),
+          when);
+      for (ReviewerAddedListener l : listeners) {
+        try {
+          l.onReviewerAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ReviewerAddedListener.Event {
+    private final AccountInfo reviewer;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
+        AccountInfo adder, Timestamp when) {
+      super(change, revision, adder, when, NotifyHandling.ALL);
+      this.reviewer = reviewer;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
new file mode 100644
index 0000000..42aa9a3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Map;
+
+public class ReviewerDeleted {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewerDeleted.class);
+
+  private final DynamicSet<ReviewerDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account reviewer,
+      Account remover, String message,
+      Map<String, Short> newApprovals,
+      Map<String, Short> oldApprovals, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(reviewer),
+          util.accountInfo(remover),
+          message,
+          util.approvals(reviewer, newApprovals, when),
+          util.approvals(reviewer, oldApprovals, when),
+          when);
+      for (ReviewerDeletedListener listener : listeners) {
+        try {
+          listener.onReviewerDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, listener, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ReviewerDeletedListener.Event {
+    private final AccountInfo reviewer;
+    private final String comment;
+    private final Map<String, ApprovalInfo> newApprovals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
+        AccountInfo remover, String comment,
+        Map<String, ApprovalInfo> newApprovals,
+        Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
+      super(change, revision, remover, when, NotifyHandling.ALL);
+      this.reviewer = reviewer;
+      this.comment = comment;
+      this.newApprovals = newApprovals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getNewApprovals() {
+      return newApprovals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
new file mode 100644
index 0000000..27f3be5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+public class RevisionCreated {
+  private static final Logger log =
+      LoggerFactory.getLogger(RevisionCreated.class);
+
+  private final DynamicSet<RevisionCreatedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account uploader,
+      Timestamp when, NotifyHandling notify) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(uploader),
+          when, notify);
+      for (RevisionCreatedListener l : listeners) {
+        try {
+          l.onRevisionCreated(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch ( PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements RevisionCreatedListener.Event {
+    private final AccountInfo uploader;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo uploader,
+        Timestamp when, NotifyHandling notify) {
+      super(change, revision, uploader, when, notify);
+      this.uploader = uploader;
+    }
+
+    @Override
+    public AccountInfo getUploader() {
+      return uploader;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
new file mode 100644
index 0000000..bf1b2ba
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Timestamp;
+
+public class TopicEdited {
+  private static final Logger log =
+      LoggerFactory.getLogger(TopicEdited.class);
+
+  private final DynamicSet<TopicEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  TopicEdited(DynamicSet<TopicEditedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account account, String oldTopicName,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.accountInfo(account),
+          oldTopicName,
+          when);
+      for (TopicEditedListener l : listeners) {
+        try {
+          l.onTopicEdited(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements TopicEditedListener.Event {
+    private final AccountInfo editor;
+    private final String oldTopic;
+
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic,
+        Timestamp when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.editor = editor;
+      this.oldTopic = oldTopic;
+    }
+
+    @Override
+    public AccountInfo getEditor() {
+      return editor;
+    }
+
+    @Override
+    public String getOldTopic() {
+      return oldTopic;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
new file mode 100644
index 0000000..e421ea6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Map;
+
+public class VoteDeleted {
+  private static final Logger log =
+      LoggerFactory.getLogger(VoteDeleted.class);
+
+  private final DynamicSet<VoteDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  VoteDeleted(DynamicSet<VoteDeletedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet ps,
+      Map<String, Short> approvals,
+      Map<String, Short> oldApprovals,
+      NotifyHandling notify, String message,
+      Account remover, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.approvals(remover, approvals, when),
+          util.approvals(remover, oldApprovals, when),
+          notify, message,
+          util.accountInfo(remover), when);
+      for (VoteDeletedListener l : listeners) {
+        try {
+          l.onVoteDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements VoteDeletedListener.Event {
+
+    private final Map<String, ApprovalInfo> approvals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+    private final String message;
+
+    Event(ChangeInfo change, RevisionInfo revision,
+        Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals,
+        NotifyHandling notify, String message,
+        AccountInfo remover, Timestamp when) {
+      super(change, revision, remover, when, notify);
+      this.approvals = approvals;
+      this.oldApprovals = oldApprovals;
+      this.message = message;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getApprovals() {
+      return approvals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getRemoved() {
+      return Maps.difference(oldApprovals, approvals).entriesOnlyOnLeft();
+    }
+
+    @Override
+    public String getMessage() {
+      return message;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index c54fe26..fdee6ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -17,41 +17,78 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Throwables;
-import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.util.concurrent.CheckedFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmConcurrencyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 /**
  * Context for a set of updates that should be applied for a site.
@@ -76,12 +113,47 @@
  * next phase.
  */
 public class BatchUpdate implements AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(BatchUpdate.class);
+
   public interface Factory {
-    public BatchUpdate create(ReviewDb db, Project.NameKey project,
+    BatchUpdate create(ReviewDb db, Project.NameKey project,
         CurrentUser user, Timestamp when);
   }
 
+  /** Order of execution of the various phases. */
+  public enum Order {
+    /**
+     * Update the repository and execute all ref updates before touching the
+     * database.
+     * <p>
+     * The default and most common, as Gerrit does not behave well when a patch
+     * set has no corresponding ref in the repo.
+     */
+    REPO_BEFORE_DB,
+
+    /**
+     * Update the database before touching the repository.
+     * <p>
+     * Generally only used when deleting patch sets, which should be deleted
+     * first from the database (for the same reason as above.)
+     */
+    DB_BEFORE_REPO;
+  }
+
   public class Context {
+    private Repository repoWrapper;
+
+    public Repository getRepository() throws IOException {
+      if (repoWrapper == null) {
+        repoWrapper = new ReadOnlyRepository(BatchUpdate.this.getRepository());
+      }
+      return repoWrapper;
+    }
+
+    public RevWalk getRevWalk() throws IOException {
+      return BatchUpdate.this.getRevWalk();
+    }
+
     public Project.NameKey getProject() {
       return project;
     }
@@ -97,34 +169,40 @@
     public CurrentUser getUser() {
       return user;
     }
+
+    public IdentifiedUser getIdentifiedUser() {
+      checkNotNull(user);
+      return user.asIdentifiedUser();
+    }
+
+    public Account getAccount() {
+      checkNotNull(user);
+      return user.asIdentifiedUser().getAccount();
+    }
+
+    public Account.Id getAccountId() {
+      checkNotNull(user);
+      return user.getAccountId();
+    }
+
+    public Order getOrder() {
+      return order;
+    }
   }
 
   public class RepoContext extends Context {
+    @Override
     public Repository getRepository() throws IOException {
-      initRepository();
-      return repo;
-    }
-
-    public RevWalk getRevWalk() throws IOException {
-      initRepository();
-      return revWalk;
+      return BatchUpdate.this.getRepository();
     }
 
     public ObjectInserter getInserter() throws IOException {
-      initRepository();
-      return inserter;
-    }
-
-    public BatchRefUpdate getBatchRefUpdate() throws IOException {
-      initRepository();
-      if (batchRefUpdate == null) {
-        batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
-      }
-      return batchRefUpdate;
+      return BatchUpdate.this.getObjectInserter();
     }
 
     public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      getBatchRefUpdate().addCommand(cmd);
+      initRepository();
+      commands.add(cmd);
     }
 
     public TimeZone getTimeZone() {
@@ -134,93 +212,342 @@
 
   public class ChangeContext extends Context {
     private final ChangeControl ctl;
-    private final ChangeUpdate update;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+    private final ReviewDbWrapper dbWrapper;
+    private final Repository threadLocalRepo;
+    private final RevWalk threadLocalRevWalk;
 
-    private ChangeContext(ChangeControl ctl) {
+    private boolean deleted;
+    private boolean bumpLastUpdatedOn = true;
+
+    protected ChangeContext(ChangeControl ctl, ReviewDbWrapper dbWrapper,
+        Repository repo, RevWalk rw) {
       this.ctl = ctl;
-      this.update = changeUpdateFactory.create(ctl, when);
+      this.dbWrapper = dbWrapper;
+      this.threadLocalRepo = repo;
+      this.threadLocalRevWalk = rw;
+      updates = new TreeMap<>(ReviewDbUtil.intKeyOrdering());
     }
 
-    public ChangeUpdate getChangeUpdate() {
-      return update;
+    @Override
+    public ReviewDb getDb() {
+      checkNotNull(dbWrapper);
+      return dbWrapper;
     }
 
-    public ChangeNotes getChangeNotes() {
-      return update.getChangeNotes();
+    @Override
+    public Repository getRepository() {
+      return threadLocalRepo;
     }
 
-    public ChangeControl getChangeControl() {
+    @Override
+    public RevWalk getRevWalk() {
+      return threadLocalRevWalk;
+    }
+
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    public ChangeNotes getNotes() {
+      ChangeNotes n = ctl.getNotes();
+      checkNotNull(n);
+      return n;
+    }
+
+    public ChangeControl getControl() {
+      checkNotNull(ctl);
       return ctl;
     }
 
     public Change getChange() {
-      return update.getChange();
+      Change c = ctl.getChange();
+      checkNotNull(c);
+      return c;
+    }
+
+    public void bumpLastUpdatedOn(boolean bump) {
+      bumpLastUpdatedOn = bump;
+    }
+
+    public void deleteChange() {
+      deleted = true;
     }
   }
 
-  public static class Op {
-    @SuppressWarnings("unused")
+  public static class RepoOnlyOp {
+    /**
+     * Override this method to update the repo.
+     *
+     * @param ctx context
+     */
     public void updateRepo(RepoContext ctx) throws Exception {
     }
 
-    @SuppressWarnings("unused")
-    public void updateChange(ChangeContext ctx) throws Exception {
-    }
-
-    // TODO(dborowitz): Support async operations?
-    @SuppressWarnings("unused")
+    /**
+     * Override this method to do something after the update
+     * e.g. send email or run hooks
+     *
+     * @param ctx context
+     */
+    //TODO(dborowitz): Support async operations?
     public void postUpdate(Context ctx) throws Exception {
     }
   }
 
-  public abstract static class InsertChangeOp extends Op {
-    public abstract Change getChange();
+  public static class Op extends RepoOnlyOp {
+    /**
+     * Override this method to modify a change.
+     *
+     * @param ctx context
+     * @return whether anything was changed that might require a write to
+     * the metadata storage.
+     */
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      return false;
+    }
   }
 
-  private final ReviewDb db;
-  private final GitRepositoryManager repoManager;
-  private final ChangeIndexer indexer;
+  public abstract static class InsertChangeOp extends Op {
+    public abstract Change createChange(Context ctx);
+  }
+
+  /**
+   * Interface for listening during batch update execution.
+   * <p>
+   * When used during execution of multiple batch updates, the {@code after*}
+   * methods are called after that phase has been completed for <em>all</em> updates.
+   */
+  public static class Listener {
+    public static final Listener NONE = new Listener();
+
+    /**
+     * Called after updating all repositories and flushing objects but before
+     * updating any refs.
+     */
+    public void afterUpdateRepos() throws Exception {
+    }
+
+    /** Called after updating all refs. */
+    public void afterRefUpdates() throws Exception {
+    }
+
+    /** Called after updating all changes. */
+    public void afterUpdateChanges() throws Exception {
+    }
+  }
+
+  private static Order getOrder(Collection<BatchUpdate> updates) {
+    Order o = null;
+    for (BatchUpdate u : updates) {
+      if (o == null) {
+        o = u.order;
+      } else if (u.order != o) {
+        throw new IllegalArgumentException("cannot mix execution orders");
+      }
+    }
+    return o;
+  }
+
+  private static boolean getUpdateChangesInParallel(
+      Collection<BatchUpdate> updates) {
+    checkArgument(!updates.isEmpty());
+    Boolean p = null;
+    for (BatchUpdate u : updates) {
+      if (p == null) {
+        p = u.updateChangesInParallel;
+      } else if (u.updateChangesInParallel != p) {
+        throw new IllegalArgumentException(
+            "cannot mix parallel and non-parallel operations");
+      }
+    }
+    // Properly implementing this would involve hoisting the parallel loop up
+    // even further. As of this writing, the only user is ReceiveCommits,
+    // which only executes a single BatchUpdate at a time. So bail for now.
+    checkArgument(!p || updates.size() <= 1,
+        "cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
+    return p;
+  }
+
+  static void execute(Collection<BatchUpdate> updates, Listener listener,
+      @Nullable RequestId requestId) throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId, requestId);
+        u.setRequestId(requestId);
+      }
+    }
+    try {
+      Order order = getOrder(updates);
+      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
+      switch (order) {
+        case REPO_BEFORE_DB:
+          for (BatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (BatchUpdate u : updates) {
+            u.executeRefUpdates();
+          }
+          listener.afterRefUpdates();
+          for (BatchUpdate u : updates) {
+            u.executeChangeOps(updateChangesInParallel);
+          }
+          listener.afterUpdateChanges();
+          break;
+        case DB_BEFORE_REPO:
+          for (BatchUpdate u : updates) {
+            u.executeChangeOps(updateChangesInParallel);
+          }
+          listener.afterUpdateChanges();
+          for (BatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (BatchUpdate u : updates) {
+            u.executeRefUpdates();
+          }
+          listener.afterRefUpdates();
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
+      for (BatchUpdate u : updates) {
+        indexFutures.addAll(u.indexFutures);
+      }
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      for (BatchUpdate u : updates) {
+        if (u.batchRefUpdate != null) {
+          // Fire ref update events only after all mutations are finished, since
+          // callers may assume a patch set ref being created means the change
+          // was created, or a branch advancing meaning some changes were
+          // closed.
+          u.gitRefUpdated.fire(
+              u.project,
+              u.batchRefUpdate,
+              u.getUser().isIdentifiedUser()
+                  ? u.getUser().asIdentifiedUser().getAccount()
+                  : null);
+        }
+      }
+
+      for (BatchUpdate u : updates) {
+        u.executePostOps();
+      }
+    } catch (UpdateException | RestApiException e) {
+      // Propagate REST API exceptions thrown by operations; they commonly throw
+      // exceptions like ResourceConflictException to indicate an atomic update
+      // failure.
+      throw e;
+
+    // Convert other common non-REST exception types with user-visible
+    // messages to corresponding REST exception types
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } catch (NoSuchChangeException | NoSuchRefException
+        | NoSuchProjectException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+
+    } catch (Exception e) {
+      Throwables.propagateIfPossible(e);
+      throw new UpdateException(e);
+    }
+  }
+
+  private final AllUsersName allUsers;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeIndexer indexer;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
   private final GitReferenceUpdated gitRefUpdated;
+  private final GitRepositoryManager repoManager;
+  private final ListeningExecutorService changeUpdateExector;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration notesMigration;
+  private final ReviewDb db;
+  private final SchemaFactory<ReviewDb> schemaFactory;
 
+  private final long logThresholdNanos;
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Timestamp when;
   private final TimeZone tz;
 
-  private final ListMultimap<Change.Id, Op> ops = ArrayListMultimap.create();
+  private final ListMultimap<Change.Id, Op> ops =
+      MultimapBuilder.linkedHashKeys().arrayListValues().build();
   private final Map<Change.Id, Change> newChanges = new HashMap<>();
   private final List<CheckedFuture<?, IOException>> indexFutures =
       new ArrayList<>();
+  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
   private Repository repo;
   private ObjectInserter inserter;
   private RevWalk revWalk;
+  private ChainedReceiveCommands commands;
   private BatchRefUpdate batchRefUpdate;
   private boolean closeRepo;
+  private Order order;
+  private boolean updateChangesInParallel;
+  private RequestId requestId;
 
   @AssistedInject
-  BatchUpdate(GitRepositoryManager repoManager,
-      ChangeIndexer indexer,
+  BatchUpdate(
+      @GerritServerConfig Config cfg,
+      AllUsersName allUsers,
       ChangeControl.GenericFactory changeControlFactory,
+      ChangeIndexer indexer,
+      ChangeNotes.Factory changeNotesFactory,
+      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
       ChangeUpdate.Factory changeUpdateFactory,
-      GitReferenceUpdated gitRefUpdated,
       @GerritPersonIdent PersonIdent serverIdent,
+      GitReferenceUpdated gitRefUpdated,
+      GitRepositoryManager repoManager,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration notesMigration,
+      SchemaFactory<ReviewDb> schemaFactory,
       @Assisted ReviewDb db,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.indexer = indexer;
+    this.allUsers = allUsers;
     this.changeControlFactory = changeControlFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateExector = changeUpdateExector;
     this.changeUpdateFactory = changeUpdateFactory;
     this.gitRefUpdated = gitRefUpdated;
+    this.indexer = indexer;
+    this.notesMigration = notesMigration;
+    this.repoManager = repoManager;
+    this.schemaFactory = schemaFactory;
+    this.updateManagerFactory = updateManagerFactory;
+
+    this.logThresholdNanos = MILLISECONDS.toNanos(
+        ConfigUtil.getTimeUnit(
+            cfg, "change", null, "updateDebugLogThreshold",
+            SECONDS.toMillis(2), MILLISECONDS));
+    this.db = db;
     this.project = project;
     this.user = user;
     this.when = when;
     tz = serverIdent.getTimeZone();
+    order = Order.REPO_BEFORE_DB;
   }
 
   @Override
@@ -232,6 +559,11 @@
     }
   }
 
+  public BatchUpdate setRequestId(RequestId requestId) {
+    this.requestId = requestId;
+    return this;
+  }
+
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk,
       ObjectInserter inserter) {
     checkState(this.repo == null, "repo already set");
@@ -239,6 +571,20 @@
     this.repo = checkNotNull(repo, "repo");
     this.revWalk = checkNotNull(revWalk, "revWalk");
     this.inserter = checkNotNull(inserter, "inserter");
+    commands = new ChainedReceiveCommands(repo);
+    return this;
+  }
+
+  public BatchUpdate setOrder(Order order) {
+    this.order = order;
+    return this;
+  }
+
+  /**
+   * Execute {@link Op#updateChange(ChangeContext)} in parallel for each change.
+   */
+  public BatchUpdate updateChangesInParallel() {
+    this.updateChangesInParallel = true;
     return this;
   }
 
@@ -248,6 +594,7 @@
       closeRepo = true;
       inserter = repo.newObjectInserter();
       revWalk = new RevWalk(inserter.newReader());
+      commands = new ChainedReceiveCommands(repo);
     }
   }
 
@@ -272,12 +619,20 @@
 
   public BatchUpdate addOp(Change.Id id, Op op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+    checkNotNull(op);
     ops.put(id, op);
     return this;
   }
 
+  public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
+    checkArgument(!(op instanceof Op), "use addOp()");
+    repoOnlyOps.add(op);
+    return this;
+  }
+
   public BatchUpdate insertChange(InsertChangeOp op) {
-    Change c = op.getChange();
+    Context ctx = new Context();
+    Change c = op.createChange(ctx);
     checkArgument(!newChanges.containsKey(c.getId()),
         "only one op allowed to create change %s", c.getId());
     newChanges.put(c.getId(), c);
@@ -286,47 +641,50 @@
   }
 
   public void execute() throws UpdateException, RestApiException {
-    try {
-      executeRefUpdates();
-      executeChangeOps();
-      reindexChanges();
-
-      if (batchRefUpdate != null) {
-        // Fire ref update events only after all mutations are finished, since
-        // callers may assume a patch set ref being created means the change was
-        // created, or a branch advancing meaning some changes were closed.
-        gitRefUpdated.fire(project, batchRefUpdate);
-      }
-
-      executePostOps();
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-    } catch (Exception e) {
-      Throwables.propagateIfPossible(e);
-      throw new UpdateException(e);
-    }
+    execute(Listener.NONE);
   }
 
-  private void executeRefUpdates()
-      throws IOException, UpdateException, RestApiException {
+  public void execute(Listener listener)
+      throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId);
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
       RepoContext ctx = new RepoContext();
       for (Op op : ops.values()) {
         op.updateRepo(ctx);
       }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (inserter != null) {
+        logDebug("Flushing inserter");
+        inserter.flush();
+      } else {
+        logDebug("No objects to flush");
+      }
     } catch (Exception e) {
       Throwables.propagateIfPossible(e, RestApiException.class);
       throw new UpdateException(e);
     }
+  }
 
-    if (repo == null || batchRefUpdate == null
-        || batchRefUpdate.getCommands().isEmpty()) {
+  private void executeRefUpdates() throws IOException, RestApiException {
+    if (commands == null || commands.isEmpty()) {
+      logDebug("No ref updates to execute");
       return;
     }
-    inserter.flush();
+    // May not be opened if the caller added ref updates but no new objects.
+    initRepository();
+    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
+    commands.addTo(batchRefUpdate);
+    logDebug("Executing batch of {} ref updates",
+        batchRefUpdate.getCommands().size());
     batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
     boolean ok = true;
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
@@ -336,48 +694,358 @@
       }
     }
     if (!ok) {
-      throw new UpdateException("BatchRefUpdate failed: " + batchRefUpdate);
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
     }
   }
 
-  private void executeChangeOps() throws UpdateException, RestApiException {
+  private void executeChangeOps(boolean parallel)
+      throws UpdateException, RestApiException {
+    logDebug("Executing change ops (parallel? {})", parallel);
+    ListeningExecutorService executor = parallel
+        ? changeUpdateExector
+        : MoreExecutors.newDirectExecutorService();
+
+    List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
     try {
+      if (notesMigration.commitChangeWrites() && repo != null) {
+        // A NoteDb change may have been rebuilt since the repo was originally
+        // opened, so make sure we see that.
+        logDebug("Preemptively scanning for repo changes");
+        repo.scanForRepoChanges();
+      }
+      if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
+        // Fail fast before attempting any writes if changes are read-only, as
+        // this is a programmer error.
+        logDebug("Failing early due to read-only Changes table");
+        throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+      }
+      List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
       for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
-        Change.Id id = e.getKey();
-        db.changes().beginTransaction(id);
+        ChangeTask task =
+            new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread());
+        tasks.add(task);
+        if (!parallel) {
+          logDebug("Direct execution of task for ops: {}", ops);
+        }
+        futures.add(executor.submit(task));
+      }
+      if (parallel) {
+        logDebug("Waiting on futures for {} ops spanning {} changes",
+            ops.size(), ops.keySet().size());
+      }
+      // TODO(dborowitz): Timing is wrong for non-parallel updates.
+      long startNanos = System.nanoTime();
+      Futures.allAsList(futures).get();
+      maybeLogSlowUpdate(startNanos, "change");
+
+      if (notesMigration.commitChangeWrites()) {
+        startNanos = System.nanoTime();
+        executeNoteDbUpdates(tasks);
+        maybeLogSlowUpdate(startNanos, "NoteDb");
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
+      Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
+      throw new UpdateException(e);
+    } catch (OrmException | IOException e) {
+      throw new UpdateException(e);
+    }
+
+    // Reindex changes.
+    for (ChangeTask task : tasks) {
+      if (task.deleted) {
+        indexFutures.add(indexer.deleteAsync(task.id));
+      } else if (task.dirty) {
+        indexFutures.add(indexer.indexAsync(project, task.id));
+      }
+    }
+  }
+
+  private static class SlowUpdateException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    private SlowUpdateException(String fmt, Object... args) {
+      super(String.format(fmt, args));
+    }
+  }
+
+  private void maybeLogSlowUpdate(long startNanos, String desc) {
+    long elapsedNanos = System.nanoTime() - startNanos;
+    if (!log.isDebugEnabled() || elapsedNanos <= logThresholdNanos) {
+      return;
+    }
+    // Always log even without RequestId.
+    log.debug("Slow " + desc + " update",
+        new SlowUpdateException(
+            "Slow %s update (%d ms) to %s for %s",
+            desc, NANOSECONDS.toMillis(elapsedNanos), project, ops.keySet()));
+  }
+
+  private void executeNoteDbUpdates(List<ChangeTask> tasks) {
+    // Aggregate together all NoteDb ref updates from the ops we executed,
+    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
+    // with its own thread-local copy of the repo(s), but each of those was just
+    // used for staging updates and was never executed.
+    //
+    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
+    // for use only by the updateRepo phase.
+    //
+    // See the comments in NoteDbUpdateManager#execute() for why we execute the
+    // updates on the change repo first.
+    logDebug("Executing NoteDb updates for {} changes", tasks.size());
+    try {
+      BatchRefUpdate changeRefUpdate =
+          getRepository().getRefDatabase().newBatchUpdate();
+      boolean hasAllUsersCommands = false;
+      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+        int objs = 0;
+        for (ChangeTask task : tasks) {
+          if (task.noteDbResult == null) {
+            logDebug("No-op update to {}", task.id);
+            continue;
+          }
+          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
+            changeRefUpdate.addCommand(cmd);
+          }
+          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
+            objs++;
+            ins.insert(obj.type(), obj.data().toByteArray());
+          }
+          hasAllUsersCommands |=
+              !task.noteDbResult.allUsersCommands().isEmpty();
+        }
+        logDebug("Collected {} objects and {} ref updates to change repo",
+            objs, changeRefUpdate.getCommands().size());
+        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
+      }
+
+      if (hasAllUsersCommands) {
+        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+            RevWalk allUsersRw = new RevWalk(allUsersRepo);
+            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
+          int objs = 0;
+          BatchRefUpdate allUsersRefUpdate =
+              allUsersRepo.getRefDatabase().newBatchUpdate();
+          for (ChangeTask task : tasks) {
+            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
+              allUsersRefUpdate.addCommand(cmd);
+            }
+            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
+              allUsersIns.insert(obj.type(), obj.data().toByteArray());
+            }
+          }
+          logDebug("Collected {} objects and {} ref updates to All-Users",
+              objs, allUsersRefUpdate.getCommands().size());
+          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
+        }
+      } else {
+        logDebug("No All-Users updates");
+      }
+    } catch (IOException e) {
+      // Ignore all errors trying to update NoteDb at this point. We've
+      // already written the NoteDbChangeState to ReviewDb, which means
+      // if the state is out of date it will be rebuilt the next time it
+      // is needed.
+      // Always log even without RequestId.
+      log.debug(
+          "Ignoring NoteDb update error after ReviewDb write", e);
+    }
+  }
+
+  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins,
+      BatchRefUpdate bru) throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      logDebug("No commands, skipping flush and ref update");
+      return;
+    }
+    ins.flush();
+    bru.setAllowNonFastForwards(true);
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Update failed: " + bru);
+      }
+    }
+  }
+
+  private class ChangeTask implements Callable<Void> {
+    final Change.Id id;
+    private final Collection<Op> changeOps;
+    private final Thread mainThread;
+
+    NoteDbUpdateManager.StagedResult noteDbResult;
+    boolean dirty;
+    boolean deleted;
+    private String taskId;
+
+    private ChangeTask(Change.Id id, Collection<Op> changeOps,
+        Thread mainThread) {
+      this.id = id;
+      this.changeOps = changeOps;
+      this.mainThread = mainThread;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      taskId = id.toString() + "-" + Thread.currentThread().getId();
+      if (Thread.currentThread() == mainThread) {
+        Repository repo = getRepository();
+        try (ObjectReader reader = repo.newObjectReader();
+            RevWalk rw = new RevWalk(repo)) {
+          call(BatchUpdate.this.db, repo, rw);
+        }
+      } else {
+        // Possible optimization: allow Ops to declare whether they need to
+        // access the repo from updateChange, and don't open in this thread
+        // unless we need it. However, as of this writing the only operations
+        // that are executed in parallel are during ReceiveCommits, and they
+        // all need the repo open anyway. (The non-parallel case above does not
+        // reopen the repo.)
+        try (ReviewDb threadLocalDb = schemaFactory.open();
+            Repository repo = repoManager.openRepository(project);
+            RevWalk rw = new RevWalk(repo)) {
+          call(threadLocalDb, repo, rw);
+        }
+      }
+      return null;
+    }
+
+    private void call(ReviewDb db, Repository repo, RevWalk rw)
+        throws Exception {
+      @SuppressWarnings("resource") // Not always opened.
+      NoteDbUpdateManager updateManager = null;
+      try {
         ChangeContext ctx;
+        db.changes().beginTransaction(id);
         try {
-          ctx = newChangeContext(id);
-          for (Op op : e.getValue()) {
-            op.updateChange(ctx);
+          ctx = newChangeContext(db, repo, rw, id);
+          // Call updateChange on each op.
+          logDebug("Calling updateChange on {} ops", changeOps.size());
+          for (Op op : changeOps) {
+            dirty |= op.updateChange(ctx);
+          }
+          if (!dirty) {
+            logDebug("No ops reported dirty, short-circuiting");
+            return;
+          }
+          deleted = ctx.deleted;
+          if (deleted) {
+            logDebug("Change was deleted");
+          }
+
+          // Stage the NoteDb update and store its state in the Change.
+          if (notesMigration.commitChangeWrites()) {
+            updateManager = stageNoteDbUpdate(ctx, deleted);
+          }
+
+          // Bump lastUpdatedOn or rowVersion and commit.
+          Iterable<Change> cs = changesToUpdate(ctx);
+          if (newChanges.containsKey(id)) {
+            // Insert rather than upsert in case of a race on change IDs.
+            logDebug("Inserting change");
+            db.changes().insert(cs);
+          } else if (deleted) {
+            logDebug("Deleting change");
+            db.changes().delete(cs);
+          } else {
+            logDebug("Updating change");
+            db.changes().update(cs);
           }
           db.commit();
         } finally {
           db.rollback();
         }
-        ctx.getChangeUpdate().commit();
-        indexFutures.add(indexer.indexAsync(id));
+
+        if (notesMigration.commitChangeWrites()) {
+          try {
+            // Do not execute the NoteDbUpdateManager, as we don't want too much
+            // contention on the underlying repo, and we would rather use a
+            // single ObjectInserter/BatchRefUpdate later.
+            //
+            // TODO(dborowitz): May or may not be worth trying to batch
+            // together flushed inserters as well.
+            noteDbResult = updateManager.stage().get(id);
+          } catch (IOException ex) {
+            // Ignore all errors trying to update NoteDb at this point. We've
+            // already written the NoteDbChangeState to ReviewDb, which means
+            // if the state is out of date it will be rebuilt the next time it
+            // is needed.
+            log.debug(
+                "Ignoring NoteDb update error after ReviewDb write", ex);
+          }
+        }
+      } catch (Exception e) {
+        logDebug("Error updating change (should be rethrown)", e);
+        Throwables.propagateIfPossible(e, RestApiException.class);
+        throw new UpdateException(e);
+      } finally {
+        if (updateManager != null) {
+          updateManager.close();
+        }
       }
-    } catch (Exception e) {
-      Throwables.propagateIfPossible(e, RestApiException.class);
-      throw new UpdateException(e);
+    }
+
+    private ChangeContext newChangeContext(ReviewDb db, Repository repo,
+        RevWalk rw, Change.Id id) throws OrmException, NoSuchChangeException {
+      Change c = newChanges.get(id);
+      if (c == null) {
+        c = ReviewDbUtil.unwrapDb(db).changes().get(id);
+        if (c == null) {
+          logDebug("Failed to get change {} from unwrapped db", id);
+          throw new NoSuchChangeException(id);
+        }
+      }
+      // Pass in preloaded change to controlFor, to avoid:
+      //  - reading from a db that does not belong to this update
+      //  - attempting to read a change that doesn't exist yet
+      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c);
+      ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+      return new ChangeContext(ctl, new BatchUpdateReviewDb(db), repo, rw);
+    }
+
+    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContext ctx,
+        boolean deleted) throws OrmException, IOException {
+      logDebug("Staging NoteDb update");
+      NoteDbUpdateManager updateManager = updateManagerFactory
+          .create(ctx.getProject())
+          .setChangeRepo(ctx.getRepository(), ctx.getRevWalk(), null,
+              new ChainedReceiveCommands(repo));
+      for (ChangeUpdate u : ctx.updates.values()) {
+        updateManager.add(u);
+      }
+      if (deleted) {
+        updateManager.deleteChange(ctx.getChange().getId());
+      }
+      try {
+        updateManager.stageAndApplyDelta(ctx.getChange());
+      } catch (OrmConcurrencyException ex) {
+        // Refused to apply update because NoteDb was out of sync. Go ahead with
+        // this ReviewDb update; it's still out of sync, but this is no worse
+        // than before, and it will eventually get rebuilt.
+        logDebug("Ignoring OrmConcurrencyException while staging");
+      }
+      return updateManager;
+    }
+
+    private void logDebug(String msg, Throwable t) {
+      if (log.isDebugEnabled()) {
+        BatchUpdate.this.logDebug("[" + taskId + "]" + msg, t);
+      }
+    }
+
+    private void logDebug(String msg, Object... args) {
+      if (log.isDebugEnabled()) {
+        BatchUpdate.this.logDebug("[" + taskId + "]" + msg, args);
+      }
     }
   }
 
-  private ChangeContext newChangeContext(Change.Id id) throws Exception {
-    Change c = newChanges.get(id);
-    if (c == null) {
-      c = db.changes().get(id);
+  private static Iterable<Change> changesToUpdate(ChangeContext ctx) {
+    Change c = ctx.getChange();
+    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
+      c.setLastUpdatedOn(ctx.getWhen());
     }
-    // Pass in preloaded change to controlFor, to avoid:
-    //  - reading from a db that does not belong to this update
-    //  - attempting to read a change that doesn't exist yet
-    return new ChangeContext(
-      changeControlFactory.controlFor(c, user));
-  }
-
-  private void reindexChanges() throws IOException {
-    ChangeIndexer.allAsList(indexFutures).checkedGet();
+    return Collections.singleton(c);
   }
 
   private void executePostOps() throws Exception {
@@ -385,5 +1053,24 @@
     for (Op op : ops.values()) {
       op.postUpdate(ctx);
     }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+
+  private void logDebug(String msg, Throwable t) {
+    if (requestId != null && log.isDebugEnabled()) {
+      log.debug(requestId + msg, t);
+    }
+  }
+
+  private void logDebug(String msg, Object... args) {
+    // Only log if there is a requestId assigned, since those are the
+    // expensive/complicated requests like MergeOp. Doing it every time would be
+    // noisy.
+    if (requestId != null && log.isDebugEnabled()) {
+      log.debug(requestId + msg, args);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java
new file mode 100644
index 0000000..1de98d3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.AtomicUpdate;
+
+public class BatchUpdateReviewDb extends ReviewDbWrapper {
+  private final ChangeAccess changesWrapper;
+
+  BatchUpdateReviewDb(ReviewDb delegate) {
+    super(delegate);
+    changesWrapper = new BatchUpdateChanges(delegate.changes());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changesWrapper;
+  }
+
+  @Override
+  public void commit() {
+    throw new UnsupportedOperationException(
+        "do not call commit; BatchUpdate always manages transactions");
+  }
+
+  @Override
+  public void rollback() {
+    throw new UnsupportedOperationException(
+        "do not call rollback; BatchUpdate always manages transactions");
+  }
+
+  private static class BatchUpdateChanges extends ChangeAccessWrapper {
+    private BatchUpdateChanges(ChangeAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public void insert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call insert; change is automatically inserted");
+    }
+
+    @Override
+    public void upsert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call upsert; existing changes are updated automatically,"
+          + " or use InsertChangeOp for insertion");
+    }
+
+    @Override
+    public void update(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call update; change is updated automatically");
+    }
+
+    @Override
+    public void beginTransaction(Change.Id key) {
+      throw new UnsupportedOperationException(
+          "updateChange is always called within a transaction");
+    }
+
+    @Override
+    public void deleteKeys(Iterable<Change.Id> keys) {
+      throw new UnsupportedOperationException(
+          "do not call deleteKeys; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public void delete(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call delete; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public Change atomicUpdate(Change.Id key,
+        AtomicUpdate<Change> update) {
+      throw new UnsupportedOperationException(
+          "do not call atomicUpdate; updateChange is always called within a"
+          + " transaction");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
new file mode 100644
index 0000000..cfbaa41
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Optional;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Collection of {@link ReceiveCommand}s that supports multiple updates per ref.
+ * <p>
+ * The underlying behavior of {@link BatchRefUpdate} is undefined (an
+ * implementations vary) when more than one command per ref is added. This class
+ * works around that limitation by allowing multiple updates per ref, as long as
+ * the previous new SHA-1 matches the next old SHA-1.
+ */
+public class ChainedReceiveCommands implements RefCache {
+  private final Map<String, ReceiveCommand> commands = new LinkedHashMap<>();
+  private final RepoRefCache refCache;
+
+  public ChainedReceiveCommands(Repository repo) {
+    this(new RepoRefCache(repo));
+  }
+
+  public ChainedReceiveCommands(RepoRefCache refCache) {
+    this.refCache = checkNotNull(refCache);
+  }
+
+  public RepoRefCache getRepoRefCache() {
+    return refCache;
+  }
+
+  public boolean isEmpty() {
+    return commands.isEmpty();
+  }
+
+  /**
+   * Add a command.
+   *
+   * @param cmd command to add. If a command has been previously added for the
+   *     same ref, the new SHA-1 of the most recent previous command must match
+   *     the old SHA-1 of this command.
+   */
+  public void add(ReceiveCommand cmd) {
+    checkArgument(!cmd.getOldId().equals(cmd.getNewId()),
+        "ref update is a no-op: %s", cmd);
+    ReceiveCommand old = commands.get(cmd.getRefName());
+    if (old == null) {
+      commands.put(cmd.getRefName(), cmd);
+      return;
+    }
+    checkArgument(old.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED,
+        "cannot chain ref update %s after update %s with result %s",
+        cmd, old, old.getResult());
+    checkArgument(cmd.getOldId().equals(old.getNewId()),
+        "cannot chain ref update %s after update %s with different new ID",
+        cmd, old);
+    commands.put(cmd.getRefName(), new ReceiveCommand(
+        old.getOldId(), cmd.getNewId(), cmd.getRefName()));
+  }
+
+  /**
+   * Get the latest value of a ref according to this sequence of commands.
+   * <p>
+   * After the value for a ref is read from the repo once, it is cached as in
+   * {@link RepoRefCache}.
+   *
+   * @see RefCache#get(String)
+   */
+  @Override
+  public Optional<ObjectId> get(String refName) throws IOException {
+    ReceiveCommand cmd = commands.get(refName);
+    if (cmd != null) {
+      return !cmd.getNewId().equals(ObjectId.zeroId())
+          ? Optional.of(cmd.getNewId())
+          : Optional.<ObjectId>absent();
+    }
+    return refCache.get(refName);
+  }
+
+  /**
+   * Add commands from this instance to a native JGit batch update.
+   * <p>
+   * Exactly one command per ref will be added to the update. The old SHA-1 will
+   * be the old SHA-1 of the first command added to this instance for that ref;
+   * the new SHA-1 will be the new SHA-1 of the last command.
+   *
+   * @param bru batch update
+   */
+  public void addTo(BatchRefUpdate bru) {
+    for (ReceiveCommand cmd : commands.values()) {
+      bru.addCommand(cmd);
+    }
+  }
+
+  /** @return an unmodifiable view of commands. */
+  public Map<String, ReceiveCommand> getCommands() {
+    return Collections.unmodifiableMap(commands);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
new file mode 100644
index 0000000..f2e7f78
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+/**
+ * Indicates that the change or commit is already in the source tree.
+ */
+public class ChangeAlreadyMergedException extends MergeIdenticalTreeException {
+  private static final long serialVersionUID = 1L;
+
+  /** @param msg message to return to the client describing the error. */
+  public ChangeAlreadyMergedException(String msg) {
+    super(msg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
deleted file mode 100644
index 391ccd0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-
-import java.util.List;
-
-public interface ChangeCache {
-  public List<Change> get(Project.NameKey name);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java
deleted file mode 100644
index 90109a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.inject.AbstractModule;
-
-public class ChangeCacheImplModule extends AbstractModule {
-  private final boolean slave;
-
-  public ChangeCacheImplModule(boolean slave) {
-    this.slave = slave;
-  }
-
-  @Override
-  protected void configure() {
-    if (slave) {
-      install(ScanningChangeCacheImpl.module());
-    } else {
-      install(SearchingChangeCacheImpl.module());
-      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-          .to(SearchingChangeCacheImpl.class);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
new file mode 100644
index 0000000..370bc2d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+
+import org.eclipse.jgit.lib.ProgressMonitor;
+
+/** Trivial op to update a counter during {@code updateChange} */
+class ChangeProgressOp extends BatchUpdate.Op {
+  private final ProgressMonitor progress;
+
+  ChangeProgressOp(ProgressMonitor progress) {
+    this.progress = progress;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) {
+    synchronized (progress) {
+      progress.update(1);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index fdf9b34..16e4bd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -15,102 +15,105 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.SetMultimap;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
-import java.util.HashSet;
-import java.util.Set;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
 /**
  * A set of changes grouped together to be submitted atomically.
  * <p>
+ * MergeSuperSet constructs ChangeSets to accumulate intermediate
+ * results toward the ChangeSet it returns when done.
+ * <p>
  * This class is not thread safe.
  */
 public class ChangeSet {
-  private final ImmutableCollection<ChangeData> changeData;
+  private final ImmutableMap<Change.Id, ChangeData> changeData;
 
-  public ChangeSet(Iterable<ChangeData> changes) {
-    Set<Change.Id> ids = new HashSet<>();
-    ImmutableSet.Builder<ChangeData> cdb = ImmutableSet.builder();
+  /**
+   * Additional changes not included in changeData because their
+   * connection to the original change is not visible to the
+   * current user.  That is, this map includes both
+   * - changes that are not visible to the current user, and
+   * - changes whose only relationship to the set is via a change
+   *   that is not visible to the current user
+   */
+  private final ImmutableMap<Change.Id, ChangeData> nonVisibleChanges;
+
+  private static ImmutableMap<Change.Id, ChangeData> index(
+      Iterable<ChangeData> changes, Collection<Change.Id> exclude) {
+    Map<Change.Id, ChangeData> ret = new LinkedHashMap<>();
     for (ChangeData cd : changes) {
-      if (ids.add(cd.getId())) {
-        cdb.add(cd);
+      Change.Id id = cd.getId();
+      if (!ret.containsKey(id) && !exclude.contains(id)) {
+        ret.put(id, cd);
       }
     }
-    changeData = cdb.build();
+    return ImmutableMap.copyOf(ret);
   }
 
-  public ChangeSet(ChangeData change) {
-    this(ImmutableList.of(change));
+  public ChangeSet(
+      Iterable<ChangeData> changes, Iterable<ChangeData> hiddenChanges) {
+    changeData = index(changes, ImmutableList.<Change.Id>of());
+    nonVisibleChanges = index(hiddenChanges, changeData.keySet());
+  }
+
+  public ChangeSet(ChangeData change, boolean visible) {
+    this(visible ? ImmutableList.of(change) : ImmutableList.<ChangeData>of(),
+        ImmutableList.of(change));
   }
 
   public ImmutableSet<Change.Id> ids() {
-    ImmutableSet.Builder<Change.Id> ret = ImmutableSet.builder();
-    for (ChangeData cd : changeData) {
-      ret.add(cd.getId());
-    }
-    return ret.build();
+    return changeData.keySet();
   }
 
-  public Set<PatchSet.Id> patchIds() throws OrmException {
-    Set<PatchSet.Id> ret = new HashSet<>();
-    for (ChangeData cd : changeData) {
-      ret.add(cd.change().currentPatchSetId());
-    }
-    return ret;
-  }
-
-  public SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject()
-      throws OrmException {
-    SetMultimap<Project.NameKey, Branch.NameKey> ret =
-        HashMultimap.create();
-    for (ChangeData cd : changeData) {
-      ret.put(cd.change().getProject(), cd.change().getDest());
-    }
-    return ret;
-  }
-
-  public Multimap<Project.NameKey, Change.Id> changesByProject()
-      throws OrmException {
-    ListMultimap<Project.NameKey, Change.Id> ret =
-        ArrayListMultimap.create();
-    for (ChangeData cd : changeData) {
-      ret.put(cd.change().getProject(), cd.getId());
-    }
-    return ret;
+  public ImmutableMap<Change.Id, ChangeData> changesById() {
+    return changeData;
   }
 
   public Multimap<Branch.NameKey, ChangeData> changesByBranch()
       throws OrmException {
     ListMultimap<Branch.NameKey, ChangeData> ret =
         ArrayListMultimap.create();
-    for (ChangeData cd : changeData) {
+    for (ChangeData cd : changeData.values()) {
       ret.put(cd.change().getDest(), cd);
     }
     return ret;
   }
 
   public ImmutableCollection<ChangeData> changes() {
-    return changeData;
+    return changeData.values();
+  }
+
+  public ImmutableSet<Change.Id> nonVisibleIds() {
+    return nonVisibleChanges.keySet();
+  }
+
+  public ImmutableList<ChangeData> nonVisibleChanges() {
+    return nonVisibleChanges.values().asList();
+  }
+
+  public boolean furtherHiddenChanges() {
+    return !nonVisibleChanges.isEmpty();
   }
 
   public int size() {
-    return changeData.size();
+    return changeData.size() + nonVisibleChanges.size();
   }
 
   @Override
   public String toString() {
-    return getClass().getSimpleName() + ids();
+    return getClass().getSimpleName() + ids() + nonVisibleIds();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
index 37a0886..f07b922 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -20,13 +20,13 @@
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -63,33 +63,6 @@
     return new CodeReviewRevWalk(reader);
   }
 
-  static CodeReviewCommit revisionGone(ChangeControl ctl) {
-    return error(ctl, CommitMergeStatus.REVISION_GONE);
-  }
-
-  static CodeReviewCommit noPatchSet(ChangeControl ctl) {
-    return error(ctl, CommitMergeStatus.NO_PATCH_SET);
-  }
-
-  /**
-   * Create an error commit.
-   * <p>
-   * Should only be used for error statuses such that there is no possible
-   * non-zero commit on which we could call {@link
-   * #setStatusCode(CommitMergeStatus)}, enumerated in the methods above.
-   *
-   * @param ctl control for change that caused this error
-   * @param s status
-   * @return new commit instance
-   */
-  private static CodeReviewCommit error(ChangeControl ctl,
-      CommitMergeStatus s) {
-    CodeReviewCommit r = new CodeReviewCommit(ObjectId.zeroId());
-    r.setControl(ctl);
-    r.statusCode = s;
-    return r;
-  }
-
   public static class CodeReviewRevWalk extends RevWalk {
     private CodeReviewRevWalk(Repository repo) {
       super(repo);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CommitMergeStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/CommitMergeStatus.java
deleted file mode 100644
index cc9e977..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/CommitMergeStatus.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-public enum CommitMergeStatus {
-  /** */
-  CLEAN_MERGE("Change has been successfully merged"),
-
-  /** */
-  CLEAN_PICK("Change has been successfully cherry-picked"),
-
-  /** */
-  CLEAN_REBASE("Change has been successfully rebased"),
-
-  /** */
-  ALREADY_MERGED(""),
-
-  /** */
-  PATH_CONFLICT("Change could not be merged due to a path conflict.\n"
-                  + "\n"
-                  + "Please rebase the change locally and upload the rebased commit for review."),
-
-  /** */
-  REBASE_MERGE_CONFLICT(
-      "Change could not be merged due to a conflict.\n"
-          + "\n"
-          + "Please rebase the change locally and upload the rebased commit for review."),
-
-  /** */
-  MISSING_DEPENDENCY("Missing dependency"),
-
-  /** */
-  NO_PATCH_SET(""),
-
-  /** */
-  REVISION_GONE(""),
-
-  /** */
-  NO_SUBMIT_TYPE(""),
-
-  /** */
-  MANUAL_RECURSIVE_MERGE("The change requires a local merge to resolve.\n"
-                       + "\n"
-                       + "Please merge (or rebase) the change locally and upload the resolution for review."),
-
-  /** */
-  CANNOT_CHERRY_PICK_ROOT("Cannot cherry-pick an initial commit onto an existing branch.\n"
-                  + "\n"
-                  + "Please merge the change locally and upload the merge commit for review."),
-
-  /** */
-  CANNOT_REBASE_ROOT("Cannot rebase an initial commit onto an existing branch.\n"
-                   + "\n"
-                   + "Please merge the change locally and upload the merge commit for review."),
-
-  /** */
-  NOT_FAST_FORWARD("Project policy requires all submissions to be a fast-forward.\n"
-                  + "\n"
-                  + "Please rebase the change locally and upload again for review."),
-
-  /** */
-  INVALID_PROJECT_CONFIGURATION("Change contains an invalid project configuration."),
-
-  /** */
-  INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_PERMITTED(
-      "Change contains an invalid project configuration:\n"
-          + "One of the plugin configuration parameters has a value that is not permitted."),
-
-  /** */
-  INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_EDITABLE(
-      "Change contains an invalid project configuration:\n"
-          + "One of the plugin configuration parameters is not editable."),
-
-  /** */
-  INVALID_PROJECT_CONFIGURATION_PARENT_PROJECT_NOT_FOUND(
-      "Change contains an invalid project configuration:\n"
-          + "Parent project does not exist."),
-
-  /** */
-  INVALID_PROJECT_CONFIGURATION_ROOT_PROJECT_CANNOT_HAVE_PARENT(
-      "Change contains an invalid project configuration:\n"
-          + "The root project cannot have a parent."),
-
-  /** */
-  SETTING_PARENT_PROJECT_ONLY_ALLOWED_BY_ADMIN(
-      "Change contains a project configuration that changes the parent project.\n"
-          + "The change must be submitted by a Gerrit administrator.");
-
-
-  private String message;
-
-  CommitMergeStatus(String message){
-    this.message = message;
-  }
-
-  public String getMessage(){
-    return message;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
index ca1f705c..7c02e5b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
@@ -53,7 +53,7 @@
 
   protected static Set<Branch.NameKey> toSet(List<Row> destRows) {
     Set<Branch.NameKey> dests = Sets.newHashSetWithExpectedSize(destRows.size());
-    for(Row row : destRows) {
+    for (Row row : destRows) {
       dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left));
     }
     return dests;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
index 66742bc..66e0704 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,7 +42,8 @@
   private static final Logger log = LoggerFactory.getLogger(EmailMerge.class);
 
   public interface Factory {
-    EmailMerge create(Change.Id changeId, Account.Id submitter);
+    EmailMerge create(Project.NameKey project, Change.Id changeId,
+        Account.Id submitter, NotifyHandling notifyHandling);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -49,8 +52,10 @@
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final Account.Id submitter;
+  private final NotifyHandling notifyHandling;
   private ReviewDb db;
 
   @Inject
@@ -59,18 +64,22 @@
       SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Assisted Project.NameKey project,
       @Assisted Change.Id changeId,
-      @Assisted @Nullable Account.Id submitter) {
+      @Assisted @Nullable Account.Id submitter,
+      @Assisted NotifyHandling notifyHandling) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
+    this.project = project;
     this.changeId = changeId;
     this.submitter = submitter;
+    this.notifyHandling = notifyHandling;
   }
 
-  void sendAsync() {
+  public void sendAsync() {
     sendEmailsExecutor.submit(this);
   }
 
@@ -78,10 +87,11 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender cm = mergedSenderFactory.create(changeId);
+      MergedSender cm = mergedSenderFactory.create(project, changeId);
       if (submitter != null) {
         cm.setFrom(submitter);
       }
+      cm.setNotify(notifyHandling);
       cm.send();
     } catch (Exception e) {
       log.error("Cannot email merged notification for " + changeId, e);
@@ -102,8 +112,7 @@
   @Override
   public CurrentUser getUser() {
     if (submitter != null) {
-      return identifiedUserFactory.create(
-          getReviewDbProvider(), submitter).getRealUser();
+      return identifiedUserFactory.create(submitter).getRealUser();
     }
     throw new OutOfScopeException("No user on email thread");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
index cfdedd0..5bb4dfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -17,10 +17,10 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
-import com.google.gerrit.extensions.events.GarbageCollectorListener.Event;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GcConfig;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.api.GarbageCollectCommand;
@@ -115,17 +115,10 @@
   }
 
   private void fire(final Project.NameKey p, final Properties statistics) {
-    Event event = new GarbageCollectorListener.Event() {
-      @Override
-      public String getProjectName() {
-        return p.get();
-      }
-
-      @Override
-      public Properties getStatistics() {
-        return statistics;
-      }
-    };
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event event = new Event(p, statistics);
     for (GarbageCollectorListener l : listeners) {
       try {
         l.onGarbageCollected(event);
@@ -204,4 +197,25 @@
       writer.print(message);
     }
   }
+
+  private static class Event extends AbstractNoNotifyEvent
+      implements GarbageCollectorListener.Event {
+    private final Project.NameKey p;
+    private final Properties statistics;
+
+    Event(Project.NameKey p, Properties statistics) {
+      this.p = p;
+      this.statistics = statistics;
+    }
+
+    @Override
+    public String getProjectName() {
+      return p.get();
+    }
+
+    @Override
+    public Properties getStatistics() {
+      return statistics;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
index 42dc505..90e2aac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
@@ -19,11 +19,12 @@
 import com.google.inject.Singleton;
 
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.Set;
 
 @Singleton
 public class GarbageCollectionQueue {
-  private final Set<Project.NameKey> projectsScheduledForGc = Sets.newHashSet();
+  private final Set<Project.NameKey> projectsScheduledForGc = new HashSet<>();
 
   public synchronized Set<Project.NameKey> addAll(Collection<Project.NameKey> projects) {
     Set<Project.NameKey> added =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
index aa0fc55..e609d68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+
+import org.eclipse.jgit.transport.PostUploadHook;
 
 /** Configures the Git support. */
 public class GitModule extends FactoryModule {
@@ -24,5 +27,7 @@
     factory(MetaDataUpdate.InternalFactory.class);
     bind(MetaDataUpdate.Server.class);
     bind(ReceiveConfig.class);
+    DynamicSet.bind(binder(), PostUploadHook.class)
+        .to(UploadPackMetricsHook.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
new file mode 100644
index 0000000..26c59c2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Loads the .gitmodules file of the specified project/branch.
+ * It can be queried which submodules this branch is subscribed to.
+ */
+public class GitModules {
+  private static final Logger log = LoggerFactory.getLogger(GitModules.class);
+
+  public interface Factory {
+    GitModules create(Branch.NameKey project, MergeOpRepoManager m);
+  }
+
+  private static final String GIT_MODULES = ".gitmodules";
+
+  private final RequestId submissionId;
+  Set<SubmoduleSubscription> subscriptions;
+
+  @AssistedInject
+  GitModules(
+      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      @Assisted Branch.NameKey branch,
+      @Assisted MergeOpRepoManager orm) throws IOException {
+    this.submissionId = orm.getSubmissionId();
+    Project.NameKey project = branch.getParentKey();
+    logDebug("Loading .gitmodules of {} for project {}", branch, project);
+    OpenRepo or;
+    try {
+      or = orm.openRepo(project);
+    } catch (NoSuchProjectException e) {
+      throw new IOException(e);
+    }
+
+    ObjectId id = or.repo.resolve(branch.get());
+    if (id == null) {
+      throw new IOException("Cannot open branch " + branch.get());
+    }
+    RevCommit commit = or.rw.parseCommit(id);
+
+    TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree());
+    if (tw == null
+        || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
+      subscriptions = Collections.emptySet();
+      logDebug("The .gitmodules file doesn't exist in " + branch);
+      return;
+    }
+    BlobBasedConfig bbc;
+    try {
+      bbc = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Could not read .gitmodules of super project: " +
+              branch.getParentKey(), e);
+    }
+    subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl,
+          branch).parseAllSections();
+  }
+
+  public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
+    logDebug("Checking for a subscription of " + src);
+    Collection<SubmoduleSubscription> ret = new ArrayList<>();
+    for (SubmoduleSubscription s : subscriptions) {
+      if (s.getSubmodule().equals(src)) {
+        logDebug("Found " + s);
+        ret.add(s);
+      }
+    }
+    return ret;
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(submissionId + msg, args);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
index dee2df0..29e14ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -41,14 +41,11 @@
    *         repository.
    * @throws IOException the name cannot be read as a repository.
    */
-  public abstract Repository openRepository(Project.NameKey name)
+  Repository openRepository(Project.NameKey name)
       throws RepositoryNotFoundException, IOException;
 
   /**
    * Create (and open) a repository by name.
-   * <p>
-   * If the implementation supports separate metadata repositories, this method
-   * must also create the metadata repository, but does not open it.
    *
    * @param name the repository name, relative to the base directory.
    * @return the cached Repository instance. Caller must call {@code close()}
@@ -58,29 +55,12 @@
    * @throws RepositoryNotFoundException the name is invalid.
    * @throws IOException the repository cannot be created.
    */
-  public abstract Repository createRepository(Project.NameKey name)
+  Repository createRepository(Project.NameKey name)
       throws RepositoryCaseMismatchException, RepositoryNotFoundException,
       IOException;
 
-  /**
-   * Open the repository storing metadata for the given project.
-   * <p>
-   * This includes any project-specific metadata <em>except</em> what is stored
-   * in {@code refs/meta/config}. Implementations may choose to store all
-   * metadata in the original project.
-   *
-   * @param name the base project name name.
-   * @return the cached metadata Repository instance. Caller must call
-   *         {@code close()} when done to decrement the resource handle.
-   * @throws RepositoryNotFoundException the name does not denote an existing
-   *         repository.
-   * @throws IOException the name cannot be read as a repository.
-   */
-  public abstract Repository openMetadataRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, IOException;
-
   /** @return set of all known projects, sorted by natural NameKey order. */
-  public abstract SortedSet<Project.NameKey> list();
+  SortedSet<Project.NameKey> list();
 
   /**
    * Read the {@code GIT_DIR/description} file for gitweb.
@@ -94,7 +74,7 @@
    * @throws IOException the description file exists, but is not readable by
    *         this process.
    */
-  public abstract String getProjectDescription(Project.NameKey name)
+  String getProjectDescription(Project.NameKey name)
       throws RepositoryNotFoundException, IOException;
 
   /**
@@ -106,6 +86,6 @@
    * @param name the repository name, relative to the base directory.
    * @param description new description text for the repository.
    */
-  public abstract void setProjectDescription(Project.NameKey name,
+  void setProjectDescription(Project.NameKey name,
       final String description);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
new file mode 100644
index 0000000..6e8bab1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.inject.Inject;
+
+public class GitRepositoryManagerModule extends LifecycleModule {
+
+  private final RepositoryConfig repoConfig;
+
+  @Inject
+  public GitRepositoryManagerModule(RepositoryConfig repoConfig) {
+    this.repoConfig = repoConfig;
+  }
+
+  @Override
+  protected void configure() {
+    if (repoConfig.getAllBasePaths().isEmpty()) {
+      install(new LocalDiskRepositoryManager.Module());
+    } else {
+      install(new MultiBaseLocalDiskRepositoryManager.Module());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
index 51125b4..d832260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
@@ -30,10 +30,14 @@
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -82,16 +86,14 @@
   private static final Logger log =
       LoggerFactory.getLogger(GroupCollector.class);
 
-  public static List<String> getCurrentGroups(ReviewDb db, Change c)
-      throws OrmException {
-    PatchSet ps = db.patchSets().get(c.currentPatchSetId());
-    return ps != null ? ps.getGroups() : null;
-  }
-
   public static List<String> getDefaultGroups(PatchSet ps) {
     return ImmutableList.of(ps.getRevision().get());
   }
 
+  public static List<String> getDefaultGroups(ObjectId commit) {
+    return ImmutableList.of(commit.name());
+  }
+
   public static List<String> getGroups(RevisionResource rsrc) {
     if (rsrc.getEdit().isPresent()) {
       // Groups for an edit are just the base revision's groups, since they have
@@ -101,8 +103,9 @@
     return rsrc.getPatchSet().getGroups();
   }
 
-  private static interface Lookup {
-    List<String> lookup(PatchSet.Id psId) throws OrmException;
+  private interface Lookup {
+    List<String> lookup(PatchSet.Id psId)
+        throws OrmException, NoSuchChangeException;
   }
 
   private final Multimap<ObjectId, PatchSet.Id> patchSetsBySha;
@@ -112,6 +115,37 @@
 
   private boolean done;
 
+  public static GroupCollector create(Multimap<ObjectId, Ref> changeRefsById,
+      final ReviewDb db, final PatchSetUtil psUtil,
+      final ChangeNotes.Factory notesFactory, final Project.NameKey project) {
+    return new GroupCollector(
+        transformRefs(changeRefsById),
+        new Lookup() {
+          @Override
+          public List<String> lookup(PatchSet.Id psId)
+              throws OrmException, NoSuchChangeException {
+            // TODO(dborowitz): Reuse open repository from caller.
+            ChangeNotes notes =
+                notesFactory.createChecked(db, project, psId.getParentKey());
+            PatchSet ps = psUtil.get(db, notes, psId);
+            return ps != null ? ps.getGroups() : null;
+          }
+        });
+  }
+
+  public static GroupCollector createForSchemaUpgradeOnly(
+      Multimap<ObjectId, Ref> changeRefsById, final ReviewDb db) {
+    return new GroupCollector(
+        transformRefs(changeRefsById),
+        new Lookup() {
+          @Override
+          public List<String> lookup(PatchSet.Id psId) throws OrmException {
+            PatchSet ps = db.patchSets().get(psId);
+            return ps != null ? ps.getGroups() : null;
+          }
+        });
+  }
+
   private GroupCollector(
       Multimap<ObjectId, PatchSet.Id> patchSetsBySha,
       Lookup groupLookup) {
@@ -121,23 +155,14 @@
     groupAliases = HashMultimap.create();
   }
 
-  public GroupCollector(
-      Multimap<ObjectId, Ref> changeRefsById,
-      final ReviewDb db) {
-    this(
-        Multimaps.transformValues(
-            changeRefsById,
-            new Function<Ref, PatchSet.Id>() {
-              @Override
-              public PatchSet.Id apply(Ref in) {
-                return PatchSet.Id.fromRef(in.getName());
-              }
-            }),
-        new Lookup() {
+  private static Multimap<ObjectId, PatchSet.Id> transformRefs(
+      Multimap<ObjectId, Ref> refs) {
+    return Multimaps.transformValues(
+        refs,
+        new Function<Ref, PatchSet.Id>() {
           @Override
-          public List<String> lookup(PatchSet.Id psId) throws OrmException {
-            PatchSet ps = db.patchSets().get(psId);
-            return ps != null ? ps.getGroups() : null;
+          public PatchSet.Id apply(Ref in) {
+            return PatchSet.Id.fromRef(in.getName());
           }
         });
   }
@@ -217,9 +242,10 @@
     }
   }
 
-  public SetMultimap<ObjectId, String> getGroups() throws OrmException {
+  public SortedSetMultimap<ObjectId, String> getGroups()
+      throws OrmException, NoSuchChangeException {
     done = true;
-    SetMultimap<ObjectId, String> result = MultimapBuilder
+    SortedSetMultimap<ObjectId, String> result = MultimapBuilder
         .hashKeys(groups.keySet().size())
         .treeSetValues()
         .build();
@@ -248,7 +274,8 @@
   }
 
   private Set<String> resolveGroups(ObjectId forCommit,
-      Collection<String> candidates) throws OrmException {
+      Collection<String> candidates)
+          throws OrmException, NoSuchChangeException {
     Set<String> actual = Sets.newTreeSet();
     Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
     Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
@@ -285,7 +312,7 @@
   }
 
   private Iterable<String> resolveGroup(ObjectId forCommit, String group)
-      throws OrmException {
+      throws OrmException, NoSuchChangeException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
       PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
index 1477f6a..bd76ad4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -28,10 +27,11 @@
 
 public class GroupList extends TabFile {
   public static final String FILE_NAME = "groups";
+
   private final Map<AccountGroup.UUID, GroupReference> byUUID;
 
   private GroupList(Map<AccountGroup.UUID, GroupReference> byUUID) {
-        this.byUUID = byUUID;
+    this.byUUID = byUUID;
   }
 
   public static GroupList parse(String text, ValidationError.Sink errors)
@@ -39,7 +39,7 @@
     List<Row> rows = parse(text, FILE_NAME, TRIM, TRIM, errors);
     Map<AccountGroup.UUID, GroupReference> groupsByUUID =
         new HashMap<>(rows.size());
-    for(Row row : rows) {
+    for (Row row : rows) {
       AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
@@ -56,6 +56,13 @@
 
   public GroupReference resolve(GroupReference group) {
     if (group != null) {
+      if (group.getUUID() == null || group.getUUID().get() == null) {
+        // A GroupReference from ProjectConfig that refers to a group not found
+        // in this file will have a null UUID. Since there may be multiple
+        // different missing references, it's not appropriate to cache the
+        // results, nor return null the set from #uuids.
+        return group;
+      }
       GroupReference ref = byUUID.get(group.getUUID());
       if (ref != null) {
         return ref;
@@ -73,7 +80,10 @@
     return byUUID.keySet();
   }
 
-  public void put(UUID uuid, GroupReference reference) {
+  public void put(AccountGroup.UUID uuid, GroupReference reference) {
+    if (uuid == null || uuid.get() == null) {
+      return; // See note in #resolve above.
+    }
     byUUID.put(uuid, reference);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
index 5cd7dc3..8080419 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
@@ -113,6 +113,7 @@
 
     // Scan history until the advertisement is full.
     RevWalk rw = rp.getRevWalk();
+    rw.reset();
     try {
       for (Ref ref : refs) {
         try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
index 4c7f637..a70c235 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
@@ -17,13 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.ImmutableList;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
+
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -34,6 +28,14 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PackParser;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
 public class InMemoryInserter extends ObjectInserter {
   private final ObjectReader reader;
   private final Map<ObjectId, InsertedObject> inserted = new LinkedHashMap<>();
@@ -50,7 +52,8 @@
   }
 
   @Override
-  public ObjectId insert(int type, long length, InputStream in) throws IOException {
+  public ObjectId insert(int type, long length, InputStream in)
+      throws IOException {
     return insert(InsertedObject.create(type, in));
   }
 
@@ -106,7 +109,8 @@
     }
 
     @Override
-    public Collection<ObjectId> resolve(AbbreviatedObjectId id) throws IOException {
+    public Collection<ObjectId> resolve(AbbreviatedObjectId id)
+        throws IOException {
       Set<ObjectId> result = new HashSet<>();
       for (ObjectId insId : inserted.keySet()) {
         if (id.prefixCompare(insId) == 0) {
@@ -118,7 +122,8 @@
     }
 
     @Override
-    public ObjectLoader open(AnyObjectId objectId, int typeHint) throws IOException {
+    public ObjectLoader open(AnyObjectId objectId, int typeHint)
+        throws IOException {
       InsertedObject obj = inserted.get(objectId);
       if (obj == null) {
         return reader.open(objectId, typeHint);
@@ -138,5 +143,10 @@
     public void close() {
       // Do nothing; this class owns no open resources.
     }
+
+    @Override
+    public ObjectInserter getCreatedFromInserter() {
+      return InMemoryInserter.this;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
index 8a766af..1aee14f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
@@ -16,12 +16,14 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.protobuf.ByteString;
-import java.io.IOException;
-import java.io.InputStream;
+
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectLoader;
 
+import java.io.IOException;
+import java.io.InputStream;
+
 @AutoValue
 public abstract class InsertedObject {
   static InsertedObject create(int type, InputStream in) throws IOException {
@@ -43,9 +45,7 @@
   }
 
   public abstract ObjectId id();
-
   public abstract int type();
-
   public abstract ByteString data();
 
   ObjectLoader newLoader() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index 0dc7aa9..f3b2ac9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -29,10 +29,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.util.Collection;
@@ -71,12 +74,16 @@
     }
   }
 
+  private final Provider<ReviewDb> db;
   private final ChangeControl.GenericFactory changeFactory;
   private final IdentifiedUser.GenericFactory userFactory;
 
   @Inject
-  LabelNormalizer(ChangeControl.GenericFactory changeFactory,
+  LabelNormalizer(
+      Provider<ReviewDb> db,
+      ChangeControl.GenericFactory changeFactory,
       IdentifiedUser.GenericFactory userFactory) {
+    this.db = db;
     this.changeFactory = changeFactory;
     this.userFactory = userFactory;
   }
@@ -89,12 +96,13 @@
    *     included in the output, nor are approvals where the user has no
    *     permissions for that label.
    * @throws NoSuchChangeException
+   * @throws OrmException
    */
-  public Result normalize(Change change,
-      Collection<PatchSetApproval> approvals) throws NoSuchChangeException {
+  public Result normalize(Change change, Collection<PatchSetApproval> approvals)
+      throws NoSuchChangeException, OrmException {
+    IdentifiedUser user = userFactory.create(change.getOwner());
     return normalize(
-        changeFactory.controlFor(change, userFactory.create(change.getOwner())),
-        approvals);
+        changeFactory.controlFor(db.get(), change, user), approvals);
   }
 
   /**
@@ -116,10 +124,10 @@
     LabelTypes labelTypes = ctl.getLabelTypes();
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.getKey().getParentKey().getParentKey();
-      checkArgument(changeId.equals(ctl.getChange().getId()),
+      checkArgument(changeId.equals(ctl.getId()),
           "Approval %s does not match change %s",
           psa.getKey(), ctl.getChange().getKey());
-      if (psa.isSubmit()) {
+      if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
new file mode 100644
index 0000000..ebfaae7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceivePack;
+
+import java.util.Collection;
+
+class LazyPostReceiveHookChain implements PostReceiveHook {
+  private final DynamicSet<PostReceiveHook> hooks;
+
+  @Inject
+  LazyPostReceiveHookChain(DynamicSet<PostReceiveHook> hooks) {
+    this.hooks = hooks;
+  }
+
+  @Override
+  public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    for (PostReceiveHook h : hooks) {
+      h.onPostReceive(rp, commands);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 60cd8ab..d532078 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.git;
 
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.MoreObjects;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -35,6 +31,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.lib.RepositoryCacheConfig;
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.storage.file.WindowCacheConfig;
 import org.eclipse.jgit.util.FS;
@@ -61,7 +58,8 @@
 
 /** Manages Git repositories stored on the local filesystem. */
 @Singleton
-public class LocalDiskRepositoryManager implements GitRepositoryManager {
+public class LocalDiskRepositoryManager implements GitRepositoryManager,
+    LifecycleListener {
   private static final Logger log =
       LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
 
@@ -72,6 +70,7 @@
     @Override
     protected void configure() {
       bind(GitRepositoryManager.class).to(LocalDiskRepositoryManager.class);
+      listener().to(LocalDiskRepositoryManager.class);
       listener().to(LocalDiskRepositoryManager.Lifecycle.class);
     }
   }
@@ -86,6 +85,10 @@
 
     @Override
     public void start() {
+      RepositoryCacheConfig repoCacheCfg = new RepositoryCacheConfig();
+      repoCacheCfg.fromConfig(serverConfig);
+      repoCacheCfg.install();
+
       WindowCacheConfig cfg = new WindowCacheConfig();
       cfg.fromConfig(serverConfig);
       if (serverConfig.getString("core", null, "streamFileThreshold") == null) {
@@ -122,38 +125,43 @@
   }
 
   private final Path basePath;
-  private final Path noteDbPath;
   private final Lock namesUpdateLock;
-  private volatile SortedSet<Project.NameKey> names;
+  private volatile SortedSet<Project.NameKey> names = new TreeSet<>();
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site,
-      @GerritServerConfig Config cfg,
-      NotesMigration notesMigration) {
+      @GerritServerConfig Config cfg) {
     basePath = site.resolve(cfg.getString("gerrit", null, "basePath"));
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
 
-    if (notesMigration.enabled()) {
-      noteDbPath = site.resolve(MoreObjects.firstNonNull(
-          cfg.getString("gerrit", null, "noteDbPath"), "notedb"));
-    } else {
-      noteDbPath = null;
-    }
     namesUpdateLock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void start() {
     names = list();
   }
 
-  /** @return base directory under which all projects are stored. */
-  public Path getBasePath() {
+  @Override
+  public void stop() {
+  }
+
+  /**
+   * Return the basePath under which the specified project is stored.
+   *
+   * @param name the name of the project
+   * @return base directory
+   */
+  public Path getBasePath(Project.NameKey name) {
     return basePath;
   }
 
   @Override
   public Repository openRepository(Project.NameKey name)
       throws RepositoryNotFoundException {
-    return openRepository(basePath, name);
+    return openRepository(getBasePath(name), name);
   }
 
   private Repository openRepository(Path path, Project.NameKey name)
@@ -200,15 +208,7 @@
   @Override
   public Repository createRepository(Project.NameKey name)
       throws RepositoryNotFoundException, RepositoryCaseMismatchException {
-    Repository repo = createRepository(basePath, name);
-    if (noteDbPath != null && !noteDbPath.equals(basePath)) {
-      createRepository(noteDbPath, name);
-    }
-    return repo;
-  }
-
-  private Repository createRepository(Path path, Project.NameKey name)
-      throws RepositoryNotFoundException, RepositoryCaseMismatchException {
+    Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
@@ -263,17 +263,6 @@
     }
   }
 
-  @Override
-  public Repository openMetadataRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, IOException {
-    checkState(noteDbPath != null, "notedb disabled");
-    try {
-      return openRepository(noteDbPath, name);
-    } catch (RepositoryNotFoundException e) {
-      return createRepository(noteDbPath, name);
-    }
-  }
-
   private void onCreateProject(final Project.NameKey newProjectName) {
     namesUpdateLock.lock();
     try {
@@ -316,18 +305,17 @@
   }
 
   @Override
-  public void setProjectDescription(final Project.NameKey name,
-      final String description) {
+  public void setProjectDescription(Project.NameKey name, String description) {
     // Update git's description file, in case gitweb is being used
     //
     try (Repository e = openRepository(name)) {
-      final String old = getProjectDescription(e);
+      String old = getProjectDescription(e);
       if ((old == null && description == null)
           || (old != null && old.equals(description))) {
         return;
       }
 
-      final LockFile f = new LockFile(new File(e.getDirectory(), "description"), FS.DETECTED);
+      LockFile f = new LockFile(new File(e.getDirectory(), "description"));
       if (f.lock()) {
         String d = description;
         if (d != null) {
@@ -350,7 +338,7 @@
     final String name = nameKey.get();
 
     return name.length() == 0  // no empty paths
-      || name.charAt(name.length() -1) == '/' // no suffix
+      || name.charAt(name.length() - 1) == '/' // no suffix
       || name.indexOf('\\') >= 0 // no windows/dos style paths
       || name.charAt(0) == '/' // no absolute paths
       || new File(name).isAbsolute() // no absolute paths
@@ -377,33 +365,52 @@
     // scanning the filesystem. Don't rely on the cached names collection.
     namesUpdateLock.lock();
     try {
-      ProjectVisitor visitor = new ProjectVisitor();
-      try {
-        Files.walkFileTree(basePath, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
-            Integer.MAX_VALUE, visitor);
-      } catch (IOException e) {
-        log.error("Error walking repository tree " + basePath.toAbsolutePath(),
-            e);
-      }
+      ProjectVisitor visitor = new ProjectVisitor(basePath);
+      scanProjects(visitor);
       return Collections.unmodifiableSortedSet(visitor.found);
     } finally {
       namesUpdateLock.unlock();
     }
   }
 
-  private class ProjectVisitor extends SimpleFileVisitor<Path> {
+  protected void scanProjects(ProjectVisitor visitor) {
+    try {
+      Files.walkFileTree(visitor.startFolder,
+          EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, visitor);
+    } catch (IOException e) {
+      log.error("Error walking repository tree "
+          + visitor.startFolder.toAbsolutePath(), e);
+    }
+  }
+
+  protected class ProjectVisitor extends SimpleFileVisitor<Path> {
     private final SortedSet<Project.NameKey> found = new TreeSet<>();
+    private Path startFolder;
+
+    public ProjectVisitor(Path startFolder) {
+      setStartFolder(startFolder);
+    }
+
+    public void setStartFolder(Path startFolder) {
+      this.startFolder = startFolder;
+    }
 
     @Override
     public FileVisitResult preVisitDirectory(Path dir,
         BasicFileAttributes attrs) throws IOException {
-      if (!dir.equals(basePath) && isRepo(dir)) {
+      if (!dir.equals(startFolder) && isRepo(dir)) {
         addProject(dir);
         return FileVisitResult.SKIP_SUBTREE;
       }
       return FileVisitResult.CONTINUE;
     }
 
+    @Override
+    public FileVisitResult visitFileFailed(Path file, IOException e) {
+      log.warn(e.getMessage());
+      return FileVisitResult.CONTINUE;
+    }
+
     private boolean isRepo(Path p) {
       String name = p.getFileName().toString();
       return !name.equals(Constants.DOT_GIT)
@@ -413,16 +420,18 @@
 
     private void addProject(Path p) {
       Project.NameKey nameKey = getProjectName(p);
-      if (isUnreasonableName(nameKey)) {
-        log.warn(
-            "Ignoring unreasonably named repository " + p.toAbsolutePath());
-      } else {
-        found.add(nameKey);
+      if (getBasePath(nameKey).equals(startFolder)) {
+        if (isUnreasonableName(nameKey)) {
+          log.warn(
+              "Ignoring unreasonably named repository " + p.toAbsolutePath());
+        } else {
+          found.add(nameKey);
+        }
       }
     }
 
     private Project.NameKey getProjectName(Path p) {
-      String projectName = basePath.relativize(p).toString();
+      String projectName = startFolder.relativize(p).toString();
       if (File.separatorChar != '/') {
         projectName = projectName.replace(File.separatorChar, '/');
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
index 109fa76..dd6b717 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
@@ -14,10 +14,17 @@
 
 package com.google.gerrit.server.git;
 
-/** Indicates that the commit is already contained in destination banch. */
-public class MergeIdenticalTreeException extends Exception {
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/**
+ * Indicates that the commit is already contained in destination branch.
+ * Either the commit itself is in the source tree, or the content is merged
+ */
+public class MergeIdenticalTreeException extends RestApiException {
   private static final long serialVersionUID = 1L;
+
+  /** @param msg message to return to the client describing the error. */
   public MergeIdenticalTreeException(String msg) {
-    super(msg, null);
+    super(msg);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 496b386..f16c997 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -14,96 +14,78 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
 import com.google.common.base.Predicate;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Table;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenBranch;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
 import com.google.gerrit.server.git.strategy.SubmitStrategyFactory;
+import com.google.gerrit.server.git.strategy.SubmitStrategyListener;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevSort;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 
 /**
@@ -120,123 +102,166 @@
  * marking it as conflicting, even if an earlier commit along that same line can
  * be merged cleanly.
  */
-public class MergeOp {
+public class MergeOp implements AutoCloseable {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
-  private final AccountCache accountCache;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeHooks hooks;
-  private final ChangeIndexer indexer;
+  public static class CommitStatus {
+    private final ImmutableMap<Change.Id, ChangeData> changes;
+    private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
+    private final Map<Change.Id, CodeReviewCommit> commits;
+    private final Multimap<Change.Id, String> problems;
+
+    private CommitStatus(ChangeSet cs) throws OrmException {
+      checkArgument(!cs.furtherHiddenChanges(),
+          "CommitStatus must not be called with hidden changes");
+      changes = cs.changesById();
+      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb =
+          ImmutableSetMultimap.builder();
+      for (ChangeData cd : cs.changes()) {
+        bb.put(cd.change().getDest(), cd.getId());
+      }
+      byBranch = bb.build();
+      commits = new HashMap<>();
+      problems = MultimapBuilder.treeKeys(
+          Ordering.natural().onResultOf(new Function<Change.Id, Integer>() {
+            @Override
+            public Integer apply(Change.Id in) {
+              return in.get();
+            }
+          })).arrayListValues(1).build();
+    }
+
+    public ImmutableSet<Change.Id> getChangeIds() {
+      return changes.keySet();
+    }
+
+    public ImmutableSet<Change.Id> getChangeIds(Branch.NameKey branch) {
+      return byBranch.get(branch);
+    }
+
+    public CodeReviewCommit get(Change.Id changeId) {
+      return commits.get(changeId);
+    }
+
+    public void put(CodeReviewCommit c) {
+      commits.put(c.change().getId(), c);
+    }
+
+    public void problem(Change.Id id, String problem) {
+      problems.put(id, problem);
+    }
+
+    public void logProblem(Change.Id id, Throwable t) {
+      String msg = "Error reading change";
+      log.error(msg + " " + id, t);
+      problems.put(id, msg);
+    }
+
+    public void logProblem(Change.Id id, String msg) {
+      log.error(msg + " " + id);
+      problems.put(id, msg);
+    }
+
+    public boolean isOk() {
+      return problems.isEmpty();
+    }
+
+    public ImmutableMultimap<Change.Id, String> getProblems() {
+      return ImmutableMultimap.copyOf(problems);
+    }
+
+    public List<SubmitRecord> getSubmitRecords(Change.Id id) {
+      // Use the cached submit records from the original ChangeData in the input
+      // ChangeSet, which were checked earlier in the integrate process. Even in
+      // the case of a race where the submit records may have changed, it makes
+      // more sense to store the original results of the submit rule evaluator
+      // than to fail at this point.
+      //
+      // However, do NOT expose that ChangeData directly, as it is way out of
+      // date by this point.
+      ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
+      return checkNotNull(cd.getSubmitRecords(),
+          "getSubmitRecord only valid after submit rules are evalutated");
+    }
+
+    public void maybeFailVerbose() throws ResourceConflictException {
+      if (isOk()) {
+        return;
+      }
+      String msg = "Failed to submit " + changes.size() + " change"
+          + (changes.size() > 1 ? "s" : "")
+          + " due to the following problems:\n";
+      List<String> ps = new ArrayList<>(problems.keySet().size());
+      for (Change.Id id : problems.keySet()) {
+        ps.add("Change " + id + ": " + Joiner.on("; ").join(problems.get(id)));
+      }
+      throw new ResourceConflictException(msg + Joiner.on('\n').join(ps));
+    }
+
+    public void maybeFail(String msgPrefix) throws ResourceConflictException {
+      if (isOk()) {
+        return;
+      }
+      StringBuilder msg = new StringBuilder(msgPrefix).append(" of change");
+      Set<Change.Id> ids = problems.keySet();
+      if (ids.size() == 1) {
+        msg.append(" ").append(ids.iterator().next());
+      } else {
+        msg.append("s ").append(Joiner.on(", ").join(ids));
+      }
+      throw new ResourceConflictException(msg.toString());
+    }
+  }
+
   private final ChangeMessagesUtil cmUtil;
-  private final ChangeUpdate.Factory updateFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final GitRepositoryManager repoManager;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final LabelNormalizer labelNormalizer;
-  private final EmailMerge.Factory mergedSenderFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final InternalUser.Factory internalUserFactory;
   private final MergeSuperSet mergeSuperSet;
   private final MergeValidators.Factory mergeValidatorsFactory;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ProjectCache projectCache;
   private final InternalChangeQuery internalChangeQuery;
-  private final PersonIdent serverIdent;
   private final SubmitStrategyFactory submitStrategyFactory;
-  private final Provider<SubmoduleOp> subOpProvider;
-  private final TagCache tagCache;
+  private final SubmoduleOp.Factory subOpFactory;
+  private final MergeOpRepoManager orm;
 
-  private final Map<Change.Id, List<SubmitRecord>> records;
-  private final Map<Change.Id, CodeReviewCommit> commits;
+  private Timestamp ts;
+  private RequestId submissionId;
+  private IdentifiedUser caller;
 
-  private static final String MACHINE_ID;
-  static {
-    String id;
-    try {
-      id = InetAddress.getLocalHost().getHostAddress();
-    } catch (UnknownHostException e) {
-      id = "unknown";
-    }
-    MACHINE_ID = id;
-  }
-  private String staticSubmissionId;
-  private String submissionId;
-
-  private ProjectState destProject;
+  private CommitStatus commits;
   private ReviewDb db;
-  private Repository repo;
-  private CodeReviewRevWalk rw;
-  private RevFlag canMergeFlag;
-  private ObjectInserter inserter;
-  private PersonIdent refLogIdent;
-  private Map<Branch.NameKey, RefUpdate> pendingRefUpdates;
-  private Map<Branch.NameKey, CodeReviewCommit> openBranches;
-  private Map<Branch.NameKey, MergeTip> mergeTips;
+  private SubmitInput submitInput;
 
   @Inject
-  MergeOp(AccountCache accountCache,
-      ApprovalsUtil approvalsUtil,
-      ChangeControl.GenericFactory changeControlFactory,
-      ChangeData.Factory changeDataFactory,
-      ChangeHooks hooks,
-      ChangeIndexer indexer,
-      ChangeMessagesUtil cmUtil,
-      ChangeUpdate.Factory updateFactory,
-      GitReferenceUpdated gitRefUpdated,
-      GitRepositoryManager repoManager,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      LabelNormalizer labelNormalizer,
-      EmailMerge.Factory mergedSenderFactory,
+  MergeOp(ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
       MergeValidators.Factory mergeValidatorsFactory,
-      PatchSetInfoFactory patchSetInfoFactory,
-      ProjectCache projectCache,
       InternalChangeQuery internalChangeQuery,
-      @GerritPersonIdent PersonIdent serverIdent,
       SubmitStrategyFactory submitStrategyFactory,
-      Provider<SubmoduleOp> subOpProvider,
-      TagCache tagCache) {
-    this.accountCache = accountCache;
-    this.approvalsUtil = approvalsUtil;
-    this.changeControlFactory = changeControlFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.hooks = hooks;
-    this.indexer = indexer;
+      SubmoduleOp.Factory subOpFactory,
+      MergeOpRepoManager orm) {
     this.cmUtil = cmUtil;
-    this.updateFactory = updateFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.repoManager = repoManager;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.labelNormalizer = labelNormalizer;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.internalUserFactory = internalUserFactory;
     this.mergeSuperSet = mergeSuperSet;
     this.mergeValidatorsFactory = mergeValidatorsFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.projectCache = projectCache;
     this.internalChangeQuery = internalChangeQuery;
-    this.serverIdent = serverIdent;
     this.submitStrategyFactory = submitStrategyFactory;
-    this.subOpProvider = subOpProvider;
-    this.tagCache = tagCache;
-
-    commits = new HashMap<>();
-    pendingRefUpdates = new HashMap<>();
-    openBranches = new HashMap<>();
-    pendingRefUpdates = new HashMap<>();
-    records = new HashMap<>();
-    mergeTips = new HashMap<>();
+    this.subOpFactory = subOpFactory;
+    this.orm = orm;
   }
 
-  private void setDestProject(Branch.NameKey destBranch)
-      throws IntegrationException {
-    destProject = projectCache.get(destBranch.getParentKey());
-    if (destProject == null) {
-      throw new IntegrationException(
-          "No such project: " + destBranch.getParentKey());
+  @Override
+  public void close() {
+    orm.close();
+  }
+
+  private static Optional<SubmitRecord> findOkRecord(
+      Collection<SubmitRecord> in) {
+    if (in == null) {
+      return Optional.absent();
     }
-  }
-
-  private static Optional<SubmitRecord> findOkRecord(Collection<SubmitRecord> in) {
     return Iterables.tryFind(in, new Predicate<SubmitRecord>() {
       @Override
       public boolean apply(SubmitRecord input) {
@@ -245,20 +270,17 @@
     });
   }
 
-  public static List<SubmitRecord> checkSubmitRule(ChangeData cd)
+  public static void checkSubmitRule(ChangeData cd)
       throws ResourceConflictException, OrmException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException(
           "missing current patch set for change " + cd.getId());
     }
-    List<SubmitRecord> results = new SubmitRuleEvaluator(cd)
-        .setPatchSet(patchSet)
-        .evaluate();
-    Optional<SubmitRecord> ok = findOkRecord(results);
-    if (ok.isPresent()) {
+    List<SubmitRecord> results = getSubmitRecords(cd);
+    if (findOkRecord(results).isPresent()) {
       // Rules supplied a valid solution.
-      return ImmutableList.of(ok.get());
+      return;
     } else if (results.isEmpty()) {
       throw new IllegalStateException(String.format(
           "SubmitRuleEvaluator.evaluate for change %s " +
@@ -271,54 +293,22 @@
     for (SubmitRecord record : results) {
       switch (record.status) {
         case CLOSED:
-          throw new ResourceConflictException(String.format(
-              "change %s is closed", cd.getId()));
+          throw new ResourceConflictException("change is closed");
 
         case RULE_ERROR:
-          throw new ResourceConflictException(String.format(
-              "rule error for change %s: %s",
-              cd.getId(), record.errorMessage));
+          throw new ResourceConflictException(
+              "submit rule error: " + record.errorMessage);
 
         case NOT_READY:
-          StringBuilder msg = new StringBuilder();
-          msg.append(cd.getId() + ":");
-          for (SubmitRecord.Label lbl : record.labels) {
-            switch (lbl.status) {
-              case OK:
-              case MAY:
-                continue;
+          throw new ResourceConflictException(
+              describeLabels(cd, record.labels));
 
-              case REJECT:
-                msg.append(" blocked by ").append(lbl.label);
-                msg.append(";");
-                continue;
-
-              case NEED:
-                msg.append(" needs ").append(lbl.label);
-                msg.append(";");
-                continue;
-
-              case IMPOSSIBLE:
-                msg.append(" needs ").append(lbl.label)
-                .append(" (check project access)");
-                msg.append(";");
-                continue;
-
-              default:
-                throw new IllegalStateException(String.format(
-                    "Unsupported SubmitRecord.Label %s for %s in %s in %s",
-                    lbl.toString(),
-                    patchSet.getId(),
-                    cd.getId(),
-                    cd.change().getProject().get()));
-            }
-          }
-          throw new ResourceConflictException(msg.toString());
-
+        case FORCED:
+        case OK:
         default:
           throw new IllegalStateException(String.format(
-              "Unsupported SubmitRecord %s for %s in %s",
-              record,
+              "Unexpected SubmitRecord status %s for %s in %s",
+              record.status,
               patchSet.getId().getId(),
               cd.change().getProject().get()));
       }
@@ -326,62 +316,124 @@
     throw new IllegalStateException();
   }
 
-  private void checkSubmitRulesAndState(ChangeSet cs)
-      throws ResourceConflictException, OrmException {
-
-    StringBuilder msgbuf = new StringBuilder();
-    List<Change.Id> problemChanges = new ArrayList<>();
-    for (Change.Id id : cs.ids()) {
-      try {
-        ChangeData cd = changeDataFactory.create(db, id);
-        if (cd.change().getStatus() != Change.Status.NEW){
-          throw new ResourceConflictException("Change " +
-              cd.change().getChangeId() + " is in state " +
-              cd.change().getStatus());
-        } else {
-          records.put(cd.change().getId(), checkSubmitRule(cd));
-        }
-      } catch (ResourceConflictException e) {
-        msgbuf.append(e.getMessage() + "\n");
-        problemChanges.add(id);
-      }
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd)
+      throws OrmException {
+    List<SubmitRecord> results = cd.getSubmitRecords();
+    if (results == null) {
+      results = new SubmitRuleEvaluator(cd).evaluate();
+      cd.setSubmitRecords(results);
     }
-    String reason = msgbuf.toString();
-    if (!reason.isEmpty()) {
-        throw new ResourceConflictException("The change could not be " +
-            "submitted because it depends on change(s) " +
-            problemChanges.toString() + ", which could not be submitted " +
-            "because:\n" + reason);
-    }
+    return results;
   }
 
-  private void updateSubmissionId(Change change) {
-    Hasher h = Hashing.sha1().newHasher();
-    h.putLong(Thread.currentThread().getId())
-        .putUnencodedChars(MACHINE_ID);
-    staticSubmissionId = h.hash().toString().substring(0, 8);
-    submissionId = change.getId().get() + "-" + TimeUtil.nowMs() +
-        "-" + staticSubmissionId;
+  private static String describeLabels(ChangeData cd,
+      List<SubmitRecord.Label> labels) throws OrmException {
+    List<String> labelResults = new ArrayList<>();
+    for (SubmitRecord.Label lbl : labels) {
+      switch (lbl.status) {
+        case OK:
+        case MAY:
+          break;
+
+        case REJECT:
+          labelResults.add("blocked by " + lbl.label);
+          break;
+
+        case NEED:
+          labelResults.add("needs " + lbl.label);
+          break;
+
+        case IMPOSSIBLE:
+          labelResults.add(
+              "needs " + lbl.label + " (check project access)");
+          break;
+
+        default:
+          throw new IllegalStateException(String.format(
+              "Unsupported SubmitRecord.Label %s for %s in %s",
+              lbl,
+              cd.change().currentPatchSetId(),
+              cd.change().getProject()));
+      }
+    }
+    return Joiner.on("; ").join(labelResults);
+  }
+
+  private void checkSubmitRulesAndState(ChangeSet cs)
+      throws ResourceConflictException {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "checkSubmitRulesAndState called for topic with hidden change");
+    for (ChangeData cd : cs.changes()) {
+      try {
+        if (cd.change().getStatus() != Change.Status.NEW) {
+          commits.problem(cd.getId(), "Change " + cd.getId() + " is "
+              + cd.change().getStatus().toString().toLowerCase());
+        } else {
+          checkSubmitRule(cd);
+        }
+      } catch (ResourceConflictException e) {
+        commits.problem(cd.getId(), e.getMessage());
+      } catch (OrmException e) {
+        String msg = "Error checking submit rules for change";
+        log.warn(msg + " " + cd.getId(), e);
+        commits.problem(cd.getId(), msg);
+      }
+    }
+    commits.maybeFailVerbose();
+  }
+
+  private void bypassSubmitRules(ChangeSet cs) {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "cannot bypass submit rules for topic with hidden change");
+    for (ChangeData cd : cs.changes()) {
+      List<SubmitRecord> records;
+      try {
+        records = new ArrayList<>(getSubmitRecords(cd));
+      } catch (OrmException e) {
+        log.warn("Error checking submit rules for change " + cd.getId(), e);
+        records = new ArrayList<>(1);
+      }
+      SubmitRecord forced = new SubmitRecord();
+      forced.status = SubmitRecord.Status.FORCED;
+      records.add(forced);
+      cd.setSubmitRecords(records);
+    }
   }
 
   public void merge(ReviewDb db, Change change, IdentifiedUser caller,
-      boolean checkSubmitRules) throws NoSuchChangeException,
-      OrmException, ResourceConflictException {
-    updateSubmissionId(change);
+      boolean checkSubmitRules, SubmitInput submitInput)
+      throws OrmException, RestApiException {
+    this.submitInput = submitInput;
+    this.caller = caller;
+    this.ts = TimeUtil.nowTs();
+    submissionId = RequestId.forChange(change);
     this.db = db;
+    orm.setContext(db, ts, caller, submissionId);
+
     logDebug("Beginning integration of {}", change);
     try {
-      ChangeSet cs = mergeSuperSet.completeChangeSet(db, change);
+      ChangeSet cs = mergeSuperSet.completeChangeSet(db, change, caller);
+      checkState(cs.ids().contains(change.getId()),
+          "change %s missing from %s", change.getId(), cs);
+      if (cs.furtherHiddenChanges()) {
+        throw new AuthException("A change to be submitted with "
+            + change.getId() + " is not visible");
+      }
+      this.commits = new CommitStatus(cs);
+      MergeSuperSet.reloadChanges(cs);
       logDebug("Calculated to merge {}", cs);
       if (checkSubmitRules) {
         logDebug("Checking submit rules and state");
         checkSubmitRulesAndState(cs);
+      } else {
+        logDebug("Bypassing submit rules");
+        bypassSubmitRules(cs);
       }
       try {
-        integrateIntoHistory(cs, caller);
+        integrateIntoHistory(cs);
       } catch (IntegrationException e) {
-        logError("Merge Conflict", e);
-        throw new ResourceConflictException(e.getMessage());
+        logError("Error from integrateIntoHistory", e);
+        throw new ResourceConflictException(e.getMessage(), e);
       }
     } catch (IOException e) {
       // Anything before the merge attempt is an error
@@ -389,183 +441,109 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs, IdentifiedUser caller)
-      throws IntegrationException, NoSuchChangeException,
-      ResourceConflictException {
+  private void integrateIntoHistory(ChangeSet cs)
+      throws IntegrationException, RestApiException {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "cannot integrate hidden changes into history");
     logDebug("Beginning merge attempt on {}", cs);
-    Map<Branch.NameKey, ListMultimap<SubmitType, ChangeData>> toSubmit =
-        new HashMap<>();
-    logDebug("Perform the merges");
+    Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
+
+    Multimap<Branch.NameKey, ChangeData> cbb;
     try {
-      Multimap<Project.NameKey, Branch.NameKey> br = cs.branchesByProject();
-      Multimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
-      for (Project.NameKey project : br.keySet()) {
-        openRepository(project);
-        for (Branch.NameKey branch : br.get(project)) {
-          setDestProject(branch);
-
-          ListMultimap<SubmitType, ChangeData> submitting =
-              validateChangeList(cbb.get(branch), caller);
-          toSubmit.put(branch, submitting);
-
-          Set<SubmitType> submitTypes = new HashSet<>(submitting.keySet());
-          for (SubmitType submitType : submitTypes) {
-            SubmitStrategy strategy = createStrategy(branch, submitType,
-                getBranchTip(branch), caller);
-
-            MergeTip mergeTip = preMerge(strategy, submitting.get(submitType),
-                getBranchTip(branch));
-            mergeTips.put(branch, mergeTip);
-            updateChangeStatus(submitting.get(submitType), branch,
-                true, caller);
-          }
-          inserter.flush();
-        }
-        closeRepository();
-      }
-      logDebug("Write out the new branch tips");
-      SubmoduleOp subOp = subOpProvider.get();
-      for (Project.NameKey project : br.keySet()) {
-        openRepository(project);
-        for (Branch.NameKey branch : br.get(project)) {
-
-          RefUpdate update = updateBranch(branch);
-          pendingRefUpdates.remove(branch);
-
-          setDestProject(branch);
-          ListMultimap<SubmitType, ChangeData> submitting = toSubmit.get(branch);
-          for (SubmitType submitType : submitting.keySet()) {
-            updateChangeStatus(submitting.get(submitType), branch,
-                false, caller);
-            updateSubmoduleSubscriptions(subOp, branch, getBranchTip(branch));
-          }
-          if (update != null) {
-            fireRefUpdated(branch, update);
-          }
-        }
-        closeRepository();
-      }
-
-      updateSuperProjects(subOp, br.values());
-      checkState(pendingRefUpdates.isEmpty(), "programmer error: "
-          + "pending ref update list not emptied");
-    } catch (NoSuchProjectException noProject) {
-      logWarn("Project " + noProject.project() + " no longer exists, "
-          + "abandoning open changes");
-      abandonAllOpenChanges(noProject.project());
+      cbb = cs.changesByBranch();
     } catch (OrmException e) {
-      throw new IntegrationException("Cannot query the database", e);
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot query the database", e);
-    } finally {
-      closeRepository();
+      throw new IntegrationException("Error reading changes to submit", e);
     }
-  }
-
-  private MergeTip preMerge(SubmitStrategy strategy,
-      List<ChangeData> submitted, CodeReviewCommit branchTip)
-      throws IntegrationException, OrmException {
-    logDebug("Running submit strategy {} for {} commits {}",
-        strategy.getClass().getSimpleName(), submitted.size(), submitted);
-    List<CodeReviewCommit> toMerge = new ArrayList<>(submitted.size());
-    for (ChangeData cd : submitted) {
-      CodeReviewCommit commit = commits.get(cd.change().getId());
-      checkState(commit != null,
-          "commit for %s not found by validateChangeList", cd.change().getId());
-      toMerge.add(commit);
-    }
-    MergeTip mergeTip = strategy.run(branchTip, toMerge);
-    refLogIdent = strategy.getRefLogIdent();
-    logDebug("Produced {} new commits", strategy.getNewCommits().size());
-    commits.putAll(strategy.getNewCommits());
-    return mergeTip;
-  }
-
-  private SubmitStrategy createStrategy(Branch.NameKey destBranch,
-      SubmitType submitType, CodeReviewCommit branchTip, IdentifiedUser caller)
-      throws IntegrationException, NoSuchProjectException {
-    return submitStrategyFactory.create(submitType, db, repo, rw, inserter,
-        canMergeFlag, getAlreadyAccepted(branchTip), destBranch, caller);
-  }
-
-  private void openRepository(Project.NameKey name)
-      throws IntegrationException, NoSuchProjectException {
-    try {
-      repo = repoManager.openRepository(name);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new NoSuchProjectException(name, notFound);
-    } catch (IOException err) {
-      String m = "Error opening repository \"" + name.get() + '"';
-      throw new IntegrationException(m, err);
-    }
-
-    rw = CodeReviewCommit.newRevWalk(repo);
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.COMMIT_TIME_DESC, true);
-    rw.setRetainBody(false);
-    canMergeFlag = rw.newFlag("CAN_MERGE");
-
-    inserter = repo.newObjectInserter();
-  }
-
-  private void closeRepository() {
-    if (inserter != null) {
-      inserter.close();
-      inserter = null;
-    }
-    if (rw != null) {
-      rw.close();
-      rw = null;
-    }
-    if (repo != null) {
-      repo.close();
-      repo = null;
-    }
-  }
-
-  private RefUpdate getPendingRefUpdate(Branch.NameKey destBranch)
-      throws IntegrationException {
-
-    if (pendingRefUpdates.containsKey(destBranch)) {
-      logDebug("Access cached open branch {}: {}", destBranch.get(),
-          openBranches.get(destBranch));
-      return pendingRefUpdates.get(destBranch);
-    }
-
-    try {
-      RefUpdate branchUpdate = repo.updateRef(destBranch.get());
-      CodeReviewCommit branchTip;
-      if (branchUpdate.getOldObjectId() != null) {
-        branchTip = rw.parseCommit(branchUpdate.getOldObjectId());
-      } else if (Objects.equals(repo.getFullBranch(), destBranch.get())) {
-        branchTip = null;
-        branchUpdate.setExpectedOldObjectId(ObjectId.zeroId());
-      } else {
-        throw new IntegrationException("The destination branch "
-            + destBranch.get() + " does not exist anymore.");
+    Set<Branch.NameKey> branches = cbb.keySet();
+    for (Branch.NameKey branch : branches) {
+      OpenRepo or = openRepo(branch.getParentKey());
+      if (or != null) {
+        toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
       }
-
-      logDebug("Opened branch {}: {}", destBranch.get(), branchTip);
-      pendingRefUpdates.put(destBranch, branchUpdate);
-      openBranches.put(destBranch, branchTip);
-      return branchUpdate;
-    } catch (IOException e) {
-      throw new IntegrationException("Cannot open branch", e);
+    }
+    // Done checks that don't involve running submit strategies.
+    commits.maybeFailVerbose();
+    SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
+    try {
+      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp);
+      Set<Project.NameKey> allProjects = submoduleOp.getProjectsInOrder();
+      BatchUpdate.execute(orm.batchUpdates(allProjects),
+          new SubmitStrategyListener(submitInput, strategies, commits),
+          submissionId);
+    } catch (SubmoduleException e) {
+      throw new IntegrationException(e);
+    } catch (UpdateException e) {
+      // BatchUpdate may have inadvertently wrapped an IntegrationException
+      // thrown by some legacy SubmitStrategyOp code that intended the error
+      // message to be user-visible. Copy the message from the wrapped
+      // exception.
+      //
+      // If you happen across one of these, the correct fix is to convert the
+      // inner IntegrationException to a ResourceConflictException.
+      String msg;
+      if (e.getCause() instanceof IntegrationException) {
+        msg = e.getCause().getMessage();
+      } else {
+        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "");
+      }
+      throw new IntegrationException(msg, e);
     }
   }
 
-  private CodeReviewCommit getBranchTip(Branch.NameKey destBranch)
+  private List<SubmitStrategy> getSubmitStrategies(
+      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp)
       throws IntegrationException {
-    if (openBranches.containsKey(destBranch)) {
-      return openBranches.get(destBranch);
-    } else {
-      getPendingRefUpdate(destBranch);
-      return openBranches.get(destBranch);
+    List<SubmitStrategy> strategies = new ArrayList<>();
+    Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    for (Branch.NameKey branch : allBranches) {
+      OpenRepo or = orm.getRepo(branch.getParentKey());
+      if (toSubmit.containsKey(branch)) {
+        BranchBatch submitting = toSubmit.get(branch);
+        OpenBranch ob = or.getBranch(branch);
+        checkNotNull(submitting.submitType(),
+            "null submit type for %s; expected to previously fail fast",
+            submitting);
+        Set<CodeReviewCommit> commitsToSubmit = commits(submitting.changes());
+        ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
+        SubmitStrategy strategy = createStrategy(or, ob.mergeTip, branch,
+            submitting.submitType(), ob.oldTip, submoduleOp);
+        strategies.add(strategy);
+        strategy.addOps(or.getUpdate(), commitsToSubmit);
+        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY) &&
+            submoduleOp.hasSubscription(branch)) {
+          submoduleOp.addOp(or.getUpdate(), branch);
+        }
+      } else {
+        // no open change for this branch
+        // add submodule triggered op into BatchUpdate
+        submoduleOp.addOp(or.getUpdate(), branch);
+      }
     }
+    return strategies;
   }
 
-  private Set<RevCommit> getAlreadyAccepted(CodeReviewCommit branchTip)
-      throws IntegrationException {
+  private Set<CodeReviewCommit> commits(List<ChangeData> cds) {
+    LinkedHashSet<CodeReviewCommit> result =
+        Sets.newLinkedHashSetWithExpectedSize(cds.size());
+    for (ChangeData cd : cds) {
+      CodeReviewCommit commit = commits.get(cd.getId());
+      checkState(commit != null,
+          "commit for %s not found by validateChangeList", cd.getId());
+      result.add(commit);
+    }
+    return result;
+  }
+
+  private SubmitStrategy createStrategy(OpenRepo or,
+      MergeTip mergeTip, Branch.NameKey destBranch, SubmitType submitType,
+      CodeReviewCommit branchTip, SubmoduleOp submoduleOp) throws IntegrationException {
+    return submitStrategyFactory.create(submitType, db, or.repo, or.rw, or.ins,
+        or.canMergeFlag, getAlreadyAccepted(or, branchTip), destBranch, caller,
+        mergeTip, commits, submissionId, submitInput.notify, submoduleOp);
+  }
+
+  private Set<RevCommit> getAlreadyAccepted(OpenRepo or,
+      CodeReviewCommit branchTip) throws IntegrationException {
     Set<RevCommit> alreadyAccepted = new HashSet<>();
 
     if (branchTip != null) {
@@ -573,10 +551,11 @@
     }
 
     try {
-      for (Ref r : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS)
+          .values()) {
         try {
-          CodeReviewCommit aac = rw.parseCommit(r.getObjectId());
-          if (!commits.values().contains(aac)) {
+          CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
+          if (!commits.commits.values().contains(aac)) {
             alreadyAccepted.add(aac);
           }
         } catch (IncorrectObjectTypeException iote) {
@@ -592,43 +571,51 @@
     return alreadyAccepted;
   }
 
-  private ListMultimap<SubmitType, ChangeData> validateChangeList(
-      Collection<ChangeData> submitted, IdentifiedUser caller)
-      throws IntegrationException, ResourceConflictException,
-      NoSuchChangeException, OrmException {
+  @AutoValue
+  abstract static class BranchBatch {
+    @Nullable abstract SubmitType submitType();
+    abstract List<ChangeData> changes();
+  }
+
+  private BranchBatch validateChangeList(OpenRepo or,
+      Collection<ChangeData> submitted) throws IntegrationException {
     logDebug("Validating {} changes", submitted.size());
-    ListMultimap<SubmitType, ChangeData> toSubmit = ArrayListMultimap.create();
+    List<ChangeData> toSubmit = new ArrayList<>(submitted.size());
+    Multimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
 
-    Map<String, Ref> allRefs;
-    try {
-      allRefs = repo.getRefDatabase().getRefs(ALL);
-    } catch (IOException e) {
-      throw new IntegrationException(e.getMessage(), e);
-    }
-
-    Set<ObjectId> tips = new HashSet<>();
-    for (Ref r : allRefs.values()) {
-      tips.add(r.getObjectId());
-    }
-
+    SubmitType submitType = null;
+    ChangeData choseSubmitTypeFrom = null;
     for (ChangeData cd : submitted) {
+      Change.Id changeId = cd.getId();
       ChangeControl ctl;
       Change chg;
       try {
         ctl = cd.changeControl();
-        // Reload change in case index was stale.
-        chg = cd.reloadChange();
+        chg = cd.change();
       } catch (OrmException e) {
-        throw new IntegrationException("Failed to validate changes", e);
+        commits.logProblem(changeId, e);
+        continue;
       }
-      Change.Id changeId = cd.getId();
-      if (chg.getStatus() != Change.Status.NEW) {
-        logDebug("Change {} is not new: {}", changeId, chg.getStatus());
+
+      SubmitType st = getSubmitType(cd);
+      if (st == null) {
+        commits.logProblem(changeId, "No submit type for change");
+        continue;
+      }
+      if (submitType == null) {
+        submitType = st;
+        choseSubmitTypeFrom = cd;
+      } else if (st != submitType) {
+        commits.problem(changeId, String.format(
+            "Change has submit type %s, but previously chose submit type %s "
+            + "from change %s in the same batch",
+            st, submitType, choseSubmitTypeFrom.getId()));
         continue;
       }
       if (chg.currentPatchSetId() == null) {
-        logError("Missing current patch set on change " + changeId);
-        commits.put(changeId, CodeReviewCommit.noPatchSet(ctl));
+        String msg = "Missing current patch set on change";
+        logError(msg + " " + changeId);
+        commits.problem(changeId, msg);
         continue;
       }
 
@@ -637,12 +624,12 @@
       try {
         ps = cd.currentPatchSet();
       } catch (OrmException e) {
-        throw new IntegrationException("Cannot query the database", e);
+        commits.logProblem(changeId, e);
+        continue;
       }
       if (ps == null || ps.getRevision() == null
           || ps.getRevision().get() == null) {
-        logError("Missing patch set or revision on change " + changeId);
-        commits.put(changeId, CodeReviewCommit.noPatchSet(ctl));
+        commits.logProblem(changeId, "Missing patch set or revision on change");
         continue;
       }
 
@@ -650,686 +637,162 @@
       ObjectId id;
       try {
         id = ObjectId.fromString(idstr);
-      } catch (IllegalArgumentException iae) {
-        logError("Invalid revision on patch set " + ps.getId());
-        commits.put(changeId, CodeReviewCommit.noPatchSet(ctl));
+      } catch (IllegalArgumentException e) {
+        commits.logProblem(changeId, e);
         continue;
       }
 
-      if (!tips.contains(id)) {
-        // TODO Technically the proper way to do this test is to use a
-        // RevWalk on "$id --not --all" and test for an empty set. But
-        // that is way slower than looking for a ref directly pointing
-        // at the desired tip. We should always have a ref available.
-        //
+      if (!revisions.containsEntry(id, ps.getId())) {
         // TODO this is actually an error, the branch is gone but we
         // want to merge the issue. We can't safely do that if the
         // tip is not reachable.
         //
-        logError("Revision " + idstr + " of patch set " + ps.getId()
-            + " is not contained in any ref");
-        commits.put(changeId, CodeReviewCommit.revisionGone(ctl));
+        commits.logProblem(changeId, "Revision " + idstr + " of patch set "
+            + ps.getPatchSetId() + " does not match " + ps.getId().toRefName()
+            + " for change");
         continue;
       }
 
       CodeReviewCommit commit;
       try {
-        commit = rw.parseCommit(id);
+        commit = or.rw.parseCommit(id);
       } catch (IOException e) {
-        logError("Invalid commit " + idstr + " on patch set " + ps.getId(), e);
-        commits.put(changeId, CodeReviewCommit.revisionGone(ctl));
+        commits.logProblem(changeId, e);
         continue;
       }
 
       // TODO(dborowitz): Consider putting ChangeData in CodeReviewCommit.
       commit.setControl(ctl);
       commit.setPatchsetId(ps.getId());
-      commits.put(changeId, commit);
+      commits.put(commit);
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
         mergeValidators.validatePreMerge(
-            repo, commit, destProject, destBranch, ps.getId(), caller);
+            or.repo, commit, or.project, destBranch, ps.getId(), caller);
       } catch (MergeValidationException mve) {
-        logDebug("Revision {} of patch set {} failed validation: {}",
-            idstr, ps.getId(), mve.getStatus());
-        commit.setStatusCode(mve.getStatus());
+        commits.problem(changeId, mve.getMessage());
         continue;
       }
-
-      SubmitType submitType;
-      submitType = getSubmitType(commit.getControl(), ps);
-      if (submitType == null) {
-        logError("No submit type for revision " + idstr + " of patch set "
-            + ps.getId());
-        commit.setStatusCode(CommitMergeStatus.NO_SUBMIT_TYPE);
-        continue;
-      }
-
-      commit.add(canMergeFlag);
-      toSubmit.put(submitType, cd);
+      commit.add(or.canMergeFlag);
+      toSubmit.add(cd);
     }
-
-    List<ChangeData> notSubmittable = new ArrayList<>(submitted);
-    notSubmittable.removeAll(toSubmit.values());
-    updateChangeStatus(notSubmittable, null, false, caller);
-
     logDebug("Submitting on this run: {}", toSubmit);
-    return toSubmit;
+    return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
-  private SubmitType getSubmitType(ChangeControl ctl, PatchSet ps) {
+  private Multimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or,
+      Collection<ChangeData> cds) throws IntegrationException {
     try {
-      ChangeData cd = changeDataFactory.create(db, ctl);
-      SubmitTypeRecord r = new SubmitRuleEvaluator(cd).setPatchSet(ps)
-          .getSubmitType();
-      if (r.status != SubmitTypeRecord.Status.OK) {
-        logError("Failed to get submit type for " + ctl.getChange().getKey());
-        return null;
+      List<String> refNames = new ArrayList<>(cds.size());
+      for (ChangeData cd : cds) {
+        Change c = cd.change();
+        if (c != null) {
+          refNames.add(c.currentPatchSetId().toRefName());
+        }
       }
-      return r.type;
+      Multimap<ObjectId, PatchSet.Id> revisions =
+          HashMultimap.create(cds.size(), 1);
+      for (Map.Entry<String, Ref> e : or.repo.getRefDatabase().exactRef(
+          refNames.toArray(new String[refNames.size()])).entrySet()) {
+        revisions.put(
+            e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
+      }
+      return revisions;
+    } catch (IOException | OrmException e) {
+      throw new IntegrationException("Failed to validate changes", e);
+    }
+  }
+
+  private SubmitType getSubmitType(ChangeData cd) {
+    try {
+      SubmitTypeRecord str = cd.submitTypeRecord();
+      return str.isOk() ? str.type : null;
     } catch (OrmException e) {
-      logError("Failed to get submit type for " + ctl.getChange().getKey(), e);
+      logError("Failed to get submit type for " + cd.getId(), e);
       return null;
     }
   }
 
-  private RefUpdate updateBranch(Branch.NameKey destBranch)
+  private OpenRepo openRepo(Project.NameKey project)
       throws IntegrationException {
-    RefUpdate branchUpdate = getPendingRefUpdate(destBranch);
-    CodeReviewCommit branchTip = getBranchTip(destBranch);
-
-    MergeTip mergeTip = mergeTips.get(destBranch);
-
-    CodeReviewCommit currentTip =
-        mergeTip != null ? mergeTip.getCurrentTip() : null;
-    if (Objects.equals(branchTip, currentTip)) {
-      if (currentTip != null) {
-        logDebug("Branch already at merge tip {}, no update to perform",
-            currentTip.name());
-      } else {
-        logDebug("Both branch and merge tip are nonexistent, no update");
-      }
-      return null;
-    } else if (currentTip == null) {
-      logDebug("No merge tip, no update to perform");
-      return null;
-    }
-
-    if (RefNames.REFS_CONFIG.equals(branchUpdate.getName())) {
-      logDebug("Loading new configuration from {}", RefNames.REFS_CONFIG);
-      try {
-        ProjectConfig cfg =
-            new ProjectConfig(destProject.getProject().getNameKey());
-        cfg.load(repo, currentTip);
-      } catch (Exception e) {
-        throw new IntegrationException("Submit would store invalid"
-            + " project configuration " + currentTip.name() + " for "
-            + destProject.getProject().getName(), e);
-      }
-    }
-
-    branchUpdate.setRefLogIdent(refLogIdent);
-    branchUpdate.setForceUpdate(false);
-    branchUpdate.setNewObjectId(currentTip);
-    branchUpdate.setRefLogMessage("merged", true);
     try {
-      RefUpdate.Result result = branchUpdate.update(rw);
-      logDebug("Update of {}: {}..{} returned status {}",
-          branchUpdate.getName(), branchUpdate.getOldObjectId(),
-          branchUpdate.getNewObjectId(), result);
-      switch (result) {
-        case NEW:
-        case FAST_FORWARD:
-          if (branchUpdate.getResult() == RefUpdate.Result.FAST_FORWARD) {
-            tagCache.updateFastForward(destBranch.getParentKey(),
-                branchUpdate.getName(),
-                branchUpdate.getOldObjectId(),
-                currentTip);
-          }
-
-          if (RefNames.REFS_CONFIG.equals(branchUpdate.getName())) {
-            Project p = destProject.getProject();
-            projectCache.evict(p);
-            destProject = projectCache.get(p.getNameKey());
-            repoManager.setProjectDescription(
-                p.getNameKey(), p.getDescription());
-          }
-
-          return branchUpdate;
-
-        case LOCK_FAILURE:
-          throw new IntegrationException("Failed to lock " + branchUpdate.getName());
-        default:
-          throw new IOException(branchUpdate.getResult().name()
-              + '\n' + branchUpdate);
-      }
+      return orm.openRepo(project);
+    } catch (NoSuchProjectException noProject) {
+      logWarn("Project " + noProject.project() + " no longer exists, "
+          + "abandoning open changes");
+      abandonAllOpenChangeForDeletedProject(noProject.project());
     } catch (IOException e) {
-      throw new IntegrationException("Cannot update " + branchUpdate.getName(), e);
+      throw new IntegrationException("Error opening project " + project, e);
     }
+    return null;
   }
 
-  private void fireRefUpdated(Branch.NameKey destBranch,
-      RefUpdate branchUpdate) {
-    logDebug("Firing ref updated hooks for {}", branchUpdate.getName());
-    gitRefUpdated.fire(destBranch.getParentKey(), branchUpdate);
-    hooks.doRefUpdatedHook(destBranch, branchUpdate,
-        getAccount(mergeTips.get(destBranch).getCurrentTip()));
-  }
-
-  private Account getAccount(CodeReviewCommit codeReviewCommit) {
-    Account account = null;
-    PatchSetApproval submitter = approvalsUtil.getSubmitter(
-        db, codeReviewCommit.notes(), codeReviewCommit.getPatchsetId());
-    if (submitter != null) {
-      account = accountCache.get(submitter.getAccountId()).getAccount();
-    }
-    return account;
-  }
-
-  private String getByAccountName(CodeReviewCommit codeReviewCommit) {
-    Account account = getAccount(codeReviewCommit);
-    if (account != null && account.getFullName() != null) {
-      return " by " + account.getFullName();
-    }
-    return "";
-  }
-
-  private void updateChangeStatus(List<ChangeData> changes,
-      Branch.NameKey destBranch, boolean dryRun, IdentifiedUser caller)
-      throws NoSuchChangeException, IntegrationException, ResourceConflictException,
-      OrmException {
-    if (!dryRun) {
-      logDebug("Updating change status for {} changes", changes.size());
-    } else {
-      logDebug("Checking change state for {} changes in a dry run",
-          changes.size());
-    }
-    MergeTip mergeTip = destBranch != null ? mergeTips.get(destBranch) : null;
-    for (ChangeData cd : changes) {
-      Change c = cd.change();
-      CodeReviewCommit commit = commits.get(c.getId());
-      CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
-      if (s == null) {
-        // Shouldn't ever happen, but leave the change alone. We'll pick
-        // it up on the next pass.
-        //
-        logDebug("Submitted change {} did not appear in set of new commits"
-            + " produced by merge strategy", c.getId());
-        continue;
-      }
-
-      if (!dryRun) {
-        try {
-          setApproval(cd, caller);
-        } catch (IOException e) {
-          throw new OrmException(e);
-        }
-      }
-
-      String txt = s.getMessage();
-      logDebug("Status of change {} ({}) on {}: {}", c.getId(), commit.name(),
-          c.getDest(), s);
-      // If mergeTip is null merge failed and mergeResultRev will not be read.
-      ObjectId mergeResultRev =
-          mergeTip != null ? mergeTip.getMergeResults().get(commit) : null;
-      try {
-        ChangeMessage msg;
-        switch (s) {
-          case CLEAN_MERGE:
-            if (!dryRun) {
-              setMerged(c, message(c, txt + getByAccountName(commit)),
-                  mergeResultRev);
-            }
-            break;
-
-          case CLEAN_REBASE:
-          case CLEAN_PICK:
-            if (!dryRun) {
-              setMerged(c, message(c, txt + " as " + commit.name()
-                  + getByAccountName(commit)), mergeResultRev);
-            }
-            break;
-
-          case ALREADY_MERGED:
-            if (!dryRun) {
-              setMerged(c, null, mergeResultRev);
-            }
-            break;
-
-          case PATH_CONFLICT:
-          case REBASE_MERGE_CONFLICT:
-          case MANUAL_RECURSIVE_MERGE:
-          case CANNOT_CHERRY_PICK_ROOT:
-          case NOT_FAST_FORWARD:
-          case INVALID_PROJECT_CONFIGURATION:
-          case INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_PERMITTED:
-          case INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_EDITABLE:
-          case INVALID_PROJECT_CONFIGURATION_PARENT_PROJECT_NOT_FOUND:
-          case INVALID_PROJECT_CONFIGURATION_ROOT_PROJECT_CANNOT_HAVE_PARENT:
-          case SETTING_PARENT_PROJECT_ONLY_ALLOWED_BY_ADMIN:
-            setNew(commit.notes(), message(c, txt));
-            throw new ResourceConflictException("Cannot merge " + commit.name()
-                + "\n" + s.getMessage());
-
-          case MISSING_DEPENDENCY:
-            logDebug("Change {} is missing dependency", c.getId());
-            throw new IntegrationException(
-                "Cannot merge " + commit.name() + "\n" + s.getMessage());
-
-          case REVISION_GONE:
-            logDebug("Commit not found for change {}", c.getId());
-            msg = new ChangeMessage(
-                new ChangeMessage.Key(
-                    c.getId(),
-                    ChangeUtil.messageUUID(db)),
-                null,
-                TimeUtil.nowTs(),
-                c.currentPatchSetId());
-            msg.setMessage("Failed to read commit for this patch set");
-            setNew(commit.notes(), msg);
-            throw new IntegrationException(msg.getMessage());
-
-          default:
-            msg = message(c, "Unspecified merge failure: " + s.name());
-            setNew(commit.notes(), msg);
-            throw new IntegrationException(msg.getMessage());
-        }
-      } catch (OrmException | IOException err) {
-        logWarn("Error updating change status for " + c.getId(), err);
-      }
-    }
-  }
-
-  private void updateSubmoduleSubscriptions(SubmoduleOp subOp,
-      Branch.NameKey destBranch, CodeReviewCommit branchTip) {
-    MergeTip mergeTip = mergeTips.get(destBranch);
-    if (mergeTip != null
-        && (branchTip == null || branchTip != mergeTip.getCurrentTip())) {
-      logDebug("Updating submodule subscriptions for branch {}", destBranch);
-      try {
-        subOp.updateSubmoduleSubscriptions(db, destBranch);
-      } catch (SubmoduleException e) {
-        logError("The submodule subscriptions were not updated according"
-            + "to the .gitmodules files", e);
-      }
-    }
-  }
-
-  private void updateSuperProjects(SubmoduleOp subOp,
-      Collection<Branch.NameKey> branches) {
-    logDebug("Updating superprojects");
-    try {
-      subOp.updateSuperProjects(db, branches);
-    } catch (SubmoduleException e) {
-      logError("The gitlinks were not updated according to the "
-          + "subscriptions", e);
-    }
-  }
-
-  private ChangeMessage message(Change c, String body) {
-    String uuid;
-    try {
-      uuid = ChangeUtil.messageUUID(db);
-    } catch (OrmException e) {
-      return null;
-    }
-    ChangeMessage m = new ChangeMessage(new ChangeMessage.Key(c.getId(), uuid),
-        null, TimeUtil.nowTs(), c.currentPatchSetId());
-    m.setMessage(body);
-    return m;
-  }
-
-  private void setMerged(Change c, ChangeMessage msg, ObjectId mergeResultRev)
-      throws OrmException, IOException {
-    logDebug("Setting change {} merged", c.getId());
-    ChangeUpdate update = null;
-    final PatchSetApproval submitter;
-    PatchSet merged;
-    try {
-      db.changes().beginTransaction(c.getId());
-
-      // We must pull the patchset out of commits, because the patchset ID is
-      // modified when using the cherry-pick merge strategy.
-      CodeReviewCommit commit = commits.get(c.getId());
-      PatchSet.Id mergedId = commit.change().currentPatchSetId();
-      merged = db.patchSets().get(mergedId);
-      c = setMergedPatchSet(c.getId(), mergedId);
-      submitter = approvalsUtil.getSubmitter(db, commit.notes(), mergedId);
-      ChangeControl control = commit.getControl();
-      update = updateFactory.create(control, c.getLastUpdatedOn());
-
-      // TODO(yyonas): we need to be able to change the author of the message
-      // is not the person for whom the change was made. addMergedMessage
-      // did this in the past.
-      if (msg != null) {
-        cmUtil.addChangeMessage(db, update, msg);
-      }
-      db.commit();
-
-    } finally {
-      db.rollback();
-    }
-    update.commit();
-    indexer.index(db, c);
-
-    try {
-      mergedSenderFactory.create(
-          c.getId(),
-          submitter != null ? submitter.getAccountId() : null).sendAsync();
-    } catch (Exception e) {
-      log.error("Cannot email merged notification for " + c.getId(), e);
-    }
-    if (submitter != null && mergeResultRev != null) {
-      try {
-        hooks.doChangeMergedHook(c,
-            accountCache.get(submitter.getAccountId()).getAccount(),
-            merged, db, mergeResultRev.name());
-      } catch (OrmException ex) {
-        logError("Cannot run hook for submitted patch set " + c.getId(), ex);
-      }
-    }
-  }
-
-  private Change setMergedPatchSet(Change.Id changeId, final PatchSet.Id merged)
-      throws OrmException {
-    return db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
-      @Override
-      public Change update(Change c) {
-        c.setStatus(Change.Status.MERGED);
-        c.setSubmissionId(submissionId);
-        if (!merged.equals(c.currentPatchSetId())) {
-          // Uncool; the patch set changed after we merged it.
-          // Go back to the patch set that was actually merged.
-          //
-          try {
-            c.setCurrentPatchSet(patchSetInfoFactory.get(db, merged));
-          } catch (PatchSetInfoNotAvailableException e1) {
-            logError("Cannot read merged patch set " + merged, e1);
-          }
-        }
-        ChangeUtil.updated(c);
-        return c;
-      }
-    });
-  }
-
-  private void setApproval(ChangeData cd, IdentifiedUser user)
-      throws OrmException, IOException {
-    Timestamp timestamp = TimeUtil.nowTs();
-    ChangeControl control = cd.changeControl();
-    PatchSet.Id psId = cd.currentPatchSet().getId();
-    PatchSet.Id psIdNewRev = commits.get(cd.change().getId())
-        .change().currentPatchSetId();
-
-    logDebug("Add approval for " + cd + " from user " + user);
-    ChangeUpdate update = updateFactory.create(control, timestamp);
-    List<SubmitRecord> record = records.get(cd.change().getId());
-    if (record != null) {
-      update.merge(record);
-    }
-    db.changes().beginTransaction(cd.change().getId());
-    try {
-      BatchMetaDataUpdate batch = approve(control, psId, user,
-          update, timestamp);
-      batch.write(update, new CommitBuilder());
-
-      // If the submit strategy created a new revision (rebase, cherry-pick)
-      // approve that as well
-      if (!psIdNewRev.equals(psId)) {
-        batch = approve(control, psIdNewRev, user,
-            update, timestamp);
-        // Write update commit after all normalized label commits.
-        batch.write(update, new CommitBuilder());
-      }
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-    indexer.index(db, cd.change());
-  }
-
-  private BatchMetaDataUpdate approve(ChangeControl control, PatchSet.Id psId,
-      IdentifiedUser user, ChangeUpdate update, Timestamp timestamp)
-          throws OrmException {
-    Map<PatchSetApproval.Key, PatchSetApproval> byKey = Maps.newHashMap();
-    for (PatchSetApproval psa :
-      approvalsUtil.byPatchSet(db, control, psId)) {
-      if (!byKey.containsKey(psa.getKey())) {
-        byKey.put(psa.getKey(), psa);
-      }
-    }
-
-    PatchSetApproval submit = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              user.getAccountId(),
-              LabelId.SUBMIT),
-              (short) 1, TimeUtil.nowTs());
-    byKey.put(submit.getKey(), submit);
-    submit.setValue((short) 1);
-    submit.setGranted(timestamp);
-
-    // Flatten out existing approvals for this patch set based upon the current
-    // permissions. Once the change is closed the approvals are not updated at
-    // presentation view time, except for zero votes used to indicate a reviewer
-    // was added. So we need to make sure votes are accurate now. This way if
-    // permissions get modified in the future, historical records stay accurate.
-    LabelNormalizer.Result normalized =
-        labelNormalizer.normalize(control, byKey.values());
-
-    // TODO(dborowitz): Don't use a label in notedb; just check when status
-    // change happened.
-    update.putApproval(submit.getLabel(), submit.getValue());
-    logDebug("Adding submit label " + submit);
-
-    db.patchSetApprovals().upsert(normalized.getNormalized());
-    db.patchSetApprovals().update(zero(normalized.deleted()));
-
-    try {
-      return saveToBatch(control, update, normalized, timestamp);
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private static Iterable<PatchSetApproval> zero(
-      Iterable<PatchSetApproval> approvals) {
-    return Iterables.transform(approvals,
-        new Function<PatchSetApproval, PatchSetApproval>() {
-          @Override
-          public PatchSetApproval apply(PatchSetApproval in) {
-            PatchSetApproval copy = new PatchSetApproval(in.getPatchSetId(), in);
-            copy.setValue((short) 0);
-            return copy;
-          }
-        });
-  }
-
-
-  private BatchMetaDataUpdate saveToBatch(ChangeControl ctl,
-      ChangeUpdate callerUpdate, LabelNormalizer.Result normalized,
-      Timestamp timestamp) throws IOException {
-    Table<Account.Id, String, Optional<Short>> byUser = HashBasedTable.create();
-    for (PatchSetApproval psa : normalized.updated()) {
-      byUser.put(psa.getAccountId(), psa.getLabel(),
-          Optional.of(psa.getValue()));
-    }
-    for (PatchSetApproval psa : normalized.deleted()) {
-      byUser.put(psa.getAccountId(), psa.getLabel(), Optional.<Short> absent());
-    }
-
-    BatchMetaDataUpdate batch = callerUpdate.openUpdate();
-    for (Account.Id accountId : byUser.rowKeySet()) {
-      if (!accountId.equals(callerUpdate.getUser().getAccountId())) {
-        ChangeUpdate update = updateFactory.create(
-            ctl.forUser(identifiedUserFactory.create(accountId)), timestamp);
-        update.setSubject("Finalize approvals at submit");
-        putApprovals(update, byUser.row(accountId));
-
-        CommitBuilder commit = new CommitBuilder();
-        commit.setCommitter(new PersonIdent(serverIdent, timestamp));
-        batch.write(update, commit);
-      }
-    }
-
-    putApprovals(callerUpdate,
-        byUser.row(callerUpdate.getUser().getAccountId()));
-    return batch;
-  }
-
-  private static void putApprovals(ChangeUpdate update,
-      Map<String, Optional<Short>> approvals) {
-    for (Map.Entry<String, Optional<Short>> e : approvals.entrySet()) {
-      if (e.getValue().isPresent()) {
-        update.putApproval(e.getKey(), e.getValue().get());
-      } else {
-        update.removeApproval(e.getKey());
-      }
-    }
-  }
-
-  private ChangeControl changeControl(Change c) throws NoSuchChangeException {
-    return changeControlFactory.controlFor(
-        c, identifiedUserFactory.create(c.getOwner()));
-  }
-
-  private void setNew(ChangeNotes notes, final ChangeMessage msg)
-      throws NoSuchChangeException, IOException {
-    Change c = notes.getChange();
-
-    Change change = null;
-    ChangeUpdate update = null;
-    try {
-      db.changes().beginTransaction(c.getId());
-      try {
-        change = db.changes().atomicUpdate(
-            c.getId(),
-            new AtomicUpdate<Change>() {
-          @Override
-          public Change update(Change c) {
-            if (c.getStatus().isOpen()) {
-              c.setStatus(Change.Status.NEW);
-              ChangeUtil.updated(c);
-            }
-            return c;
-          }
-        });
-        ChangeControl control = changeControl(change);
-
-        //TODO(yyonas): atomic change is not propagated.
-        update = updateFactory.create(control, c.getLastUpdatedOn());
-        if (msg != null) {
-          cmUtil.addChangeMessage(db, update, msg);
-        }
-        db.commit();
-      } finally {
-        db.rollback();
-      }
-    } catch (OrmException err) {
-      logWarn("Cannot record merge failure message", err);
-    }
-    if (update != null) {
-      update.commit();
-    }
-    indexer.index(db, change);
-
-    PatchSetApproval submitter = null;
-    try {
-      submitter = approvalsUtil.getSubmitter(
-          db, notes, notes.getChange().currentPatchSetId());
-    } catch (Exception e) {
-      logError("Cannot get submitter for change " + notes.getChangeId(), e);
-    }
-    if (submitter != null) {
-      try {
-        hooks.doMergeFailedHook(c,
-            accountCache.get(submitter.getAccountId()).getAccount(),
-            db.patchSets().get(c.currentPatchSetId()), msg.getMessage(), db);
-      } catch (OrmException ex) {
-        logError("Cannot run hook for merge failed " + c.getId(), ex);
-      }
-    }
-  }
-
-  private void abandonAllOpenChanges(Project.NameKey destProject)
-      throws NoSuchChangeException {
+  private void abandonAllOpenChangeForDeletedProject(
+      Project.NameKey destProject) {
     try {
       for (ChangeData cd : internalChangeQuery.byProjectOpen(destProject)) {
-        abandonOneChange(cd.change());
-      }
-    } catch (IOException | OrmException e) {
-      logWarn("Cannot abandon changes for deleted project ", e);
-    }
-  }
+        try (BatchUpdate bu = batchUpdateFactory.create(db, destProject,
+            internalUserFactory.create(), ts)) {
+          bu.setRequestId(submissionId);
+          bu.addOp(cd.getId(), new BatchUpdate.Op() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              Change change = ctx.getChange();
+              if (!change.getStatus().isOpen()) {
+                return false;
+              }
 
-  private void abandonOneChange(Change change) throws OrmException,
-    NoSuchChangeException,  IOException {
-    db.changes().beginTransaction(change.getId());
-
-    //TODO(dborowitz): support InternalUser in ChangeUpdate
-    ChangeControl control = changeControlFactory.controlFor(change,
-        identifiedUserFactory.create(change.getOwner()));
-    ChangeUpdate update = updateFactory.create(control);
-    try {
-      change = db.changes().atomicUpdate(
-        change.getId(),
-        new AtomicUpdate<Change>() {
-          @Override
-          public Change update(Change change) {
-            if (change.getStatus().isOpen()) {
               change.setStatus(Change.Status.ABANDONED);
-              return change;
+
+              ChangeMessage msg = new ChangeMessage(
+                  new ChangeMessage.Key(change.getId(),
+                      ChangeUtil.messageUUID(ctx.getDb())),
+                  null, change.getLastUpdatedOn(), change.currentPatchSetId());
+              msg.setMessage("Project was deleted.");
+              cmUtil.addChangeMessage(ctx.getDb(),
+                  ctx.getUpdate(change.currentPatchSetId()), msg);
+
+              return true;
             }
-            return null;
+          });
+          try {
+            bu.execute();
+          } catch (UpdateException | RestApiException e) {
+            logWarn("Cannot abandon changes for deleted project " + destProject,
+                e);
           }
-        });
-
-      if (change != null) {
-        ChangeMessage msg = new ChangeMessage(
-            new ChangeMessage.Key(
-                change.getId(),
-                ChangeUtil.messageUUID(db)),
-            null,
-            change.getLastUpdatedOn(),
-            change.currentPatchSetId());
-        msg.setMessage("Project was deleted.");
-
-        //TODO(yyonas): atomic change is not propagated.
-        cmUtil.addChangeMessage(db, update, msg);
-        db.commit();
-        indexer.index(db, change);
+        }
       }
-    } finally {
-      db.rollback();
+    } catch (OrmException e) {
+      logWarn("Cannot abandon changes for deleted project " + destProject, e);
     }
-    update.commit();
   }
 
   private void logDebug(String msg, Object... args) {
     if (log.isDebugEnabled()) {
-      log.debug("[" + submissionId + "]" + msg, args);
+      log.debug(submissionId + msg, args);
     }
   }
 
   private void logWarn(String msg, Throwable t) {
     if (log.isWarnEnabled()) {
-      log.warn("[" + submissionId + "]" + msg, t);
+      log.warn(submissionId + msg, t);
     }
   }
 
   private void logWarn(String msg) {
     if (log.isWarnEnabled()) {
-      log.warn("[" + submissionId + "]" + msg);
+      log.warn(submissionId + msg);
     }
   }
 
   private void logError(String msg, Throwable t) {
     if (log.isErrorEnabled()) {
       if (t != null) {
-        log.error("[" + submissionId + "]" + msg, t);
+        log.error(submissionId + msg, t);
       } else {
-        log.error("[" + submissionId + "]" + msg);
+        log.error(submissionId + msg);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
new file mode 100644
index 0000000..cd76aff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -0,0 +1,224 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevSort;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * This is a helper class for MergeOp and not intended for general use.
+ *
+ * Some database backends require to open a repository just once within
+ * a transaction of a submission, this caches open repositories to satisfy
+ * that requirement.
+ */
+public class MergeOpRepoManager implements AutoCloseable {
+  public class OpenRepo {
+    final Repository repo;
+    final CodeReviewRevWalk rw;
+    final RevFlag canMergeFlag;
+    final ObjectInserter ins;
+
+    final ProjectState project;
+    BatchUpdate update;
+
+    private final ObjectReader reader;
+    private final Map<Branch.NameKey, OpenBranch> branches;
+
+    private OpenRepo(Repository repo, ProjectState project) {
+      this.repo = repo;
+      this.project = project;
+      ins = repo.newObjectInserter();
+      reader = ins.newReader();
+      rw = CodeReviewCommit.newRevWalk(reader);
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.COMMIT_TIME_DESC, true);
+      rw.setRetainBody(false);
+      canMergeFlag = rw.newFlag("CAN_MERGE");
+      rw.retainOnReset(canMergeFlag);
+
+      branches = Maps.newHashMapWithExpectedSize(1);
+    }
+
+    OpenBranch getBranch(Branch.NameKey branch) throws IntegrationException {
+      OpenBranch ob = branches.get(branch);
+      if (ob == null) {
+        ob = new OpenBranch(this, branch);
+        branches.put(branch, ob);
+      }
+      return ob;
+    }
+
+    Project.NameKey getProjectName() {
+      return project.getProject().getNameKey();
+    }
+
+    BatchUpdate getUpdate() {
+      checkState(db != null, "call setContext before getUpdate");
+      if (update == null) {
+        update = batchUpdateFactory.create(db, getProjectName(), caller, ts)
+            .setRepository(repo, rw, ins)
+            .setRequestId(submissionId);
+      }
+      return update;
+    }
+
+    /**
+     * Make sure the update has already executed before reset it.
+     * TODO:czhen Have a flag in BatchUpdate to mark if it has been executed
+     */
+    void resetUpdate() {
+      update = null;
+    }
+
+    void close() {
+      if (update != null) {
+        update.close();
+      }
+      rw.close();
+      reader.close();
+      ins.close();
+      repo.close();
+    }
+  }
+
+  public static class OpenBranch {
+    final RefUpdate update;
+    final CodeReviewCommit oldTip;
+    MergeTip mergeTip;
+
+    OpenBranch(OpenRepo or, Branch.NameKey name) throws IntegrationException {
+      try {
+        update = or.repo.updateRef(name.get());
+        if (update.getOldObjectId() != null) {
+          oldTip = or.rw.parseCommit(update.getOldObjectId());
+        } else if (Objects.equals(or.repo.getFullBranch(), name.get())) {
+          oldTip = null;
+          update.setExpectedOldObjectId(ObjectId.zeroId());
+        } else {
+          throw new IntegrationException("The destination branch "
+              + name + " does not exist anymore.");
+        }
+      } catch (IOException e) {
+        throw new IntegrationException("Cannot open branch " + name, e);
+      }
+    }
+  }
+
+
+  private final Map<Project.NameKey, OpenRepo> openRepos;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+
+  private ReviewDb db;
+  private Timestamp ts;
+  private IdentifiedUser caller;
+  private RequestId submissionId;
+
+  @Inject
+  MergeOpRepoManager(
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      BatchUpdate.Factory batchUpdateFactory) {
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.batchUpdateFactory = batchUpdateFactory;
+
+    openRepos = new HashMap<>();
+  }
+
+  void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller,
+      RequestId submissionId) {
+    this.db = db;
+    this.ts = ts;
+    this.caller = caller;
+    this.submissionId = submissionId;
+  }
+
+  public RequestId getSubmissionId() {
+    return submissionId;
+  }
+
+  public OpenRepo getRepo(Project.NameKey project) {
+    OpenRepo or = openRepos.get(project);
+    checkState(or != null, "repo not yet opened: %s", project);
+    return or;
+  }
+
+  public OpenRepo openRepo(Project.NameKey project)
+      throws NoSuchProjectException, IOException {
+    if (openRepos.containsKey(project)) {
+      return openRepos.get(project);
+    }
+
+    ProjectState projectState = projectCache.get(project);
+    if (projectState == null) {
+      throw new NoSuchProjectException(project);
+    }
+    try {
+      OpenRepo or =
+          new OpenRepo(repoManager.openRepository(project), projectState);
+      openRepos.put(project, or);
+      return or;
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchProjectException(project);
+    }
+  }
+
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects) {
+    List<BatchUpdate> updates = new ArrayList<>(projects.size());
+    for (Project.NameKey project : projects) {
+      updates.add(getRepo(project).getUpdate());
+    }
+    return updates;
+  }
+
+  @Override
+  public void close() {
+    for (OpenRepo repo : openRepos.values()) {
+      repo.close();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
index aa55751..197b8c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevCommitList;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
index c9a5c3e..284e9ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -23,8 +28,10 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -65,6 +72,14 @@
 public class MergeSuperSet {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
+  public static void reloadChanges(ChangeSet cs) throws OrmException {
+    // Clear exactly the fields requested by query() below.
+    for (ChangeData cd : cs.changes()) {
+      cd.reloadChange();
+      cd.setPatchSets(null);
+    }
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager repoManager;
@@ -81,40 +96,101 @@
     this.repoManager = repoManager;
   }
 
-  public ChangeSet completeChangeSet(ReviewDb db, Change change)
+  public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
       OrmException {
-    ChangeData cd = changeDataFactory.create(db, change.getId());
+    ChangeData cd =
+        changeDataFactory.create(db, change.getProject(), change.getId());
+    cd.changeControl(user);
+    ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
     if (Submit.wholeTopicEnabled(cfg)) {
-      return completeChangeSetIncludingTopics(db, new ChangeSet(cd));
-    } else {
-      return completeChangeSetWithoutTopic(db, new ChangeSet(cd));
+      return completeChangeSetIncludingTopics(db, cs, user);
     }
+    return completeChangeSetWithoutTopic(db, cs, user);
   }
 
-  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
-    List<ChangeData> ret = new ArrayList<>();
+  private static ImmutableListMultimap<Project.NameKey, ChangeData>
+      byProject(Iterable<ChangeData> changes) throws OrmException {
+    ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
+        new ImmutableListMultimap.Builder<>();
+    for (ChangeData cd : changes) {
+      builder.put(cd.change().getProject(), cd);
+    }
+    return builder.build();
+  }
 
-    Multimap<Project.NameKey, Change.Id> pc = changes.changesByProject();
+  private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible)
+      throws OrmException {
+    // Submit type prolog rules mean that the submit type can depend on the
+    // submitting user and the content of the change.
+    //
+    // If the current user can see the change, run that evaluation to get a
+    // preview of what would happen on submit.  If the current user can't see
+    // the change, instead of guessing who would do the submitting, rely on the
+    // project configuration and ignore the prolog rule.  If the prolog rule
+    // doesn't match that, we may pick the wrong submit type and produce a
+    // misleading (but still nonzero) count of the non visible changes that
+    // would be submitted together with the visible ones.
+    if (!visible) {
+      return cd.changeControl().getProject().getSubmitType();
+    }
+
+    SubmitTypeRecord str =
+        ps == cd.currentPatchSet()
+            ? cd.submitTypeRecord()
+            : new SubmitRuleEvaluator(cd).setPatchSet(ps).getSubmitType();
+    if (!str.isOk()) {
+      logErrorAndThrow("Failed to get submit type for " + cd.getId()
+          + ": " + str.errorMessage);
+    }
+    return str.type;
+  }
+
+  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes,
+      CurrentUser user) throws MissingObjectException,
+      IncorrectObjectTypeException, IOException, OrmException {
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+
+    Multimap<Project.NameKey, ChangeData> pc =
+        byProject(
+            Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
     for (Project.NameKey project : pc.keySet()) {
       try (Repository repo = repoManager.openRepository(project);
            RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        for (Change.Id cId : pc.get(project)) {
-          ChangeData cd = changeDataFactory.create(db, cId);
-
-          SubmitTypeRecord r = new SubmitRuleEvaluator(cd).getSubmitType();
-          if (r.status != SubmitTypeRecord.Status.OK) {
-            logErrorAndThrow("Failed to get submit type for " + cd.getId());
+        for (ChangeData cd : pc.get(project)) {
+          checkState(cd.hasChangeControl(),
+              "completeChangeSet forgot to set changeControl for current user"
+              + " at ChangeData creation time");
+          boolean visible = changes.ids().contains(cd.getId());
+          if (visible && !cd.changeControl().isVisible(db, cd)) {
+            // We thought the change was visible, but it isn't.
+            // This can happen if the ACL changes during the
+            // completeChangeSet computation, for example.
+            visible = false;
           }
-          if (r.type == SubmitType.CHERRY_PICK) {
-            ret.add(cd);
+          List<ChangeData> dest = visible ? visibleChanges : nonVisibleChanges;
+
+          // Pick a revision to use for traversal.  If any of the patch sets
+          // is visible, we use the most recent one.  Otherwise, use the current
+          // patch set.
+          PatchSet ps = cd.currentPatchSet();
+          boolean visiblePatchSet = visible;
+          if (!cd.changeControl().isPatchVisible(ps, cd)) {
+            Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
+            if (Iterables.isEmpty(visiblePatchSets)) {
+              visiblePatchSet = false;
+            } else {
+              ps = Iterables.getLast(visiblePatchSets);
+            }
+          }
+
+          if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
+            dest.add(cd);
             continue;
           }
 
           // Get the underlying git commit object
-          PatchSet ps = cd.currentPatchSet();
           String objIdStr = ps.getRevision().get();
           RevCommit commit = rw.parseCommit(ObjectId.fromString(objIdStr));
 
@@ -132,48 +208,113 @@
           }
 
           List<String> hashes = new ArrayList<>();
+          // Always include the input, even if merged. This allows
+          // SubmitStrategyOp to correct the situation later, assuming it gets
+          // returned by byCommitsOnBranchNotMerged below.
+          hashes.add(objIdStr);
           for (RevCommit c : rw) {
-            hashes.add(c.name());
+            if (!c.equals(commit)) {
+              hashes.add(c.name());
+            }
           }
 
           if (!hashes.isEmpty()) {
-            // Merged changes are ok to exclude
-            Iterable<ChangeData> destChanges = queryProvider.get()
+            Iterable<ChangeData> destChanges = query()
                 .byCommitsOnBranchNotMerged(
                   repo, db, cd.change().getDest(), hashes);
             for (ChangeData chd : destChanges) {
-              ret.add(chd);
+              chd.changeControl(user);
+              dest.add(chd);
             }
           }
         }
       }
     }
 
-    return new ChangeSet(ret);
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
+  }
+
+  /**
+   * Completes {@code cs} with any additional changes from its topics
+   * <p>
+   * {@link #completeChangeSetIncludingTopics} calls this repeatedly,
+   * alternating with {@link #completeChangeSetWithoutTopic}, to discover
+   * what additional changes should be submitted with a change until the
+   * set stops growing.
+   * <p>
+   * {@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics
+   * already explored to avoid wasted work.
+   *
+   * @return the resulting larger {@link ChangeSet}
+   */
+  private ChangeSet topicClosure(
+      ReviewDb db, ChangeSet cs, CurrentUser user,
+      Set<String> topicsSeen, Set<String> visibleTopicsSeen)
+      throws OrmException {
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+
+    for (ChangeData cd : cs.changes()) {
+      visibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || visibleTopicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+        topicCd.changeControl(user);
+        if (topicCd.changeControl().isVisible(db, topicCd)) {
+          visibleChanges.add(topicCd);
+        } else {
+          nonVisibleChanges.add(topicCd);
+        }
+      }
+      topicsSeen.add(topic);
+      visibleTopicsSeen.add(topic);
+    }
+    for (ChangeData cd : cs.nonVisibleChanges()) {
+      nonVisibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || topicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+        topicCd.changeControl(user);
+        nonVisibleChanges.add(topicCd);
+      }
+      topicsSeen.add(topic);
+    }
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
   private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changes) throws MissingObjectException,
-      IncorrectObjectTypeException, IOException, OrmException {
-    Set<String> topicsTraversed = new HashSet<>();
-    boolean done = false;
-    ChangeSet newCs = completeChangeSetWithoutTopic(db, changes);
-    while (!done) {
-      List<ChangeData> chgs = new ArrayList<>();
-      done = true;
-      for (ChangeData cd : newCs.changes()) {
-        chgs.add(cd);
-        String topic = cd.change().getTopic();
-        if (!Strings.isNullOrEmpty(topic) && !topicsTraversed.contains(topic)) {
-          chgs.addAll(queryProvider.get().byTopicOpen(topic));
-          done = false;
-          topicsTraversed.add(topic);
-        }
-      }
-      changes = new ChangeSet(chgs);
-      newCs = completeChangeSetWithoutTopic(db, changes);
-    }
-    return newCs;
+      ReviewDb db, ChangeSet changes, CurrentUser user)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException,
+      OrmException {
+    Set<String> topicsSeen = new HashSet<>();
+    Set<String> visibleTopicsSeen = new HashSet<>();
+    int oldSeen;
+    int seen = 0;
+
+    do {
+      oldSeen = seen;
+
+      changes = completeChangeSetWithoutTopic(db, changes, user);
+      changes = topicClosure(db, changes, user, topicsSeen, visibleTopicsSeen);
+
+      seen = topicsSeen.size() + visibleTopicsSeen.size();
+    } while (seen != oldSeen);
+    return changes;
+  }
+
+  private InternalChangeQuery query() {
+    // Request fields required for completing the ChangeSet without having to
+    // touch the database. This provides reasonable performance when loading the
+    // change screen; callers that care about reading the latest value of these
+    // fields should clear them explicitly using reloadChanges().
+    Set<String> fields = ImmutableSet.of(
+        ChangeField.CHANGE.getName(),
+        ChangeField.PATCH_SET.getName());
+    return queryProvider.get().setRequestedFields(fields);
   }
 
   private void logError(String msg) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
index 7be7014..5ea0c02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 
 import org.eclipse.jgit.lib.ObjectId;
 
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.Map;
 
 /**
@@ -50,7 +50,7 @@
     checkArgument(!toMerge.isEmpty(), "toMerge may not be empty");
     this.initialTip = initialTip;
     this.branchTip = initialTip;
-    this.mergeResults = Maps.newHashMap();
+    this.mergeResults = new HashMap<>();
     // Assume fast-forward merge until opposite is proven.
     for (CodeReviewCommit commit : toMerge) {
       mergeResults.put(commit.copy(), commit.copy());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 13476ef..ae11630 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -19,11 +19,15 @@
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -36,6 +40,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
@@ -43,11 +48,13 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
+import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -58,6 +65,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.FooterKey;
@@ -70,8 +78,6 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -81,6 +87,15 @@
 import java.util.Objects;
 import java.util.Set;
 
+/**
+ * Utility methods used during the merge process.
+ * <p>
+ * <strong>Note:</strong> Unless otherwise specified, the methods in this class
+ * <strong>do not</strong> flush {@link ObjectInserter}s. Callers that want to
+ * read back objects before flushing should use {@link
+ * ObjectInserter#newReader()}. This is already the default behavior of {@code
+ * BatchUpdate}.
+ */
 public class MergeUtil {
   private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
   private static final String R_HEADS_MASTER =
@@ -96,7 +111,7 @@
         : MergeStrategy.RESOLVE;
   }
 
-  public static interface Factory {
+  public interface Factory {
     MergeUtil create(ProjectState project);
     MergeUtil create(ProjectState project, boolean useContentMerge);
   }
@@ -189,10 +204,46 @@
       mergeCommit.setAuthor(originalCommit.getAuthorIdent());
       mergeCommit.setCommitter(cherryPickCommitterIdent);
       mergeCommit.setMessage(commitMsg);
-      return rw.parseCommit(commit(inserter, mergeCommit));
-    } else {
-      throw new MergeConflictException("merge conflict");
+      return rw.parseCommit(inserter.insert(mergeCommit));
     }
+    throw new MergeConflictException("merge conflict");
+  }
+
+  public static RevCommit createMergeCommit(Repository repo, ObjectInserter inserter,
+      RevCommit mergeTip, RevCommit originalCommit, String mergeStrategy,
+      PersonIdent committerIndent, String commitMsg, RevWalk rw)
+      throws IOException, MergeIdenticalTreeException, MergeConflictException {
+
+    if (rw.isMergedInto(originalCommit, mergeTip)) {
+      throw new ChangeAlreadyMergedException(
+          "'" + originalCommit.getName() + "' has already been merged");
+    }
+
+    Merger m = newMerger(repo, inserter, mergeStrategy);
+    if (m.merge(false, mergeTip, originalCommit)) {
+      ObjectId tree = m.getResultTreeId();
+
+      CommitBuilder mergeCommit = new CommitBuilder();
+      mergeCommit.setTreeId(tree);
+      mergeCommit.setParentIds(mergeTip, originalCommit);
+      mergeCommit.setAuthor(committerIndent);
+      mergeCommit.setCommitter(committerIndent);
+      mergeCommit.setMessage(commitMsg);
+      return rw.parseCommit(inserter.insert(mergeCommit));
+    }
+    List<String> conflicts = ImmutableList.of();
+    if (m instanceof ResolveMerger) {
+      conflicts = ((ResolveMerger) m).getUnmergedPaths();
+    }
+    throw new MergeConflictException(createConflictMessage(conflicts));
+  }
+
+  public static String createConflictMessage(List<String> conflicts) {
+    StringBuilder sb = new StringBuilder("merge conflict(s)");
+    for (String c : conflicts) {
+      sb.append('\n' + c);
+    }
+    return sb.toString();
   }
 
   public String createCherryPickCommitMessage(RevCommit n, ChangeControl ctl,
@@ -243,7 +294,7 @@
         continue;
       }
 
-      if (a.isSubmit()) {
+      if (a.isLegacySubmit()) {
         // Submit is treated specially, below (becomes committer)
         //
         if (submitAudit == null
@@ -405,7 +456,10 @@
         m.setBase(toMerge.getParent(0));
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
-        throw new IntegrationException("Cannot merge " + toMerge.name(), e);
+        throw new IntegrationException(
+            String.format("Cannot merge commit %s with mergetip %s",
+                toMerge.name(), mergeTip.name()),
+            e);
       }
     }
 
@@ -430,21 +484,19 @@
 
   public CodeReviewCommit mergeOneCommit(PersonIdent author,
       PersonIdent committer, Repository repo, CodeReviewRevWalk rw,
-      ObjectInserter inserter, RevFlag canMergeFlag, Branch.NameKey destBranch,
+      ObjectInserter inserter, Branch.NameKey destBranch,
       CodeReviewCommit mergeTip, CodeReviewCommit n)
       throws IntegrationException {
     final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
     try {
       if (m.merge(new AnyObjectId[] {mergeTip, n})) {
-        return writeMergeCommit(author, committer, rw, inserter, canMergeFlag,
-            destBranch, mergeTip, m.getResultTreeId(), n);
-      } else {
-        failed(rw, canMergeFlag, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
+        return writeMergeCommit(author, committer, rw, inserter, destBranch,
+            mergeTip, m.getResultTreeId(), n);
       }
+      failed(rw, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
     } catch (NoMergeBaseException e) {
       try {
-        failed(rw, canMergeFlag, mergeTip, n,
-            getCommitMergeStatus(e.getReason()));
+        failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
       } catch (IOException e2) {
         throw new IntegrationException("Cannot merge " + n.name(), e);
       }
@@ -467,10 +519,9 @@
   }
 
   private static CodeReviewCommit failed(CodeReviewRevWalk rw,
-      RevFlag canMergeFlag, CodeReviewCommit mergeTip, CodeReviewCommit n,
-      CommitMergeStatus failure)
+      CodeReviewCommit mergeTip, CodeReviewCommit n, CommitMergeStatus failure)
       throws MissingObjectException, IncorrectObjectTypeException, IOException {
-    rw.resetRetain(canMergeFlag);
+    rw.reset();
     rw.markStart(n);
     rw.markUninteresting(mergeTip);
     CodeReviewCommit failed;
@@ -482,12 +533,11 @@
 
   public CodeReviewCommit writeMergeCommit(PersonIdent author,
       PersonIdent committer, CodeReviewRevWalk rw, ObjectInserter inserter,
-      RevFlag canMergeFlag, Branch.NameKey destBranch,
-      CodeReviewCommit mergeTip, ObjectId treeId, CodeReviewCommit n)
-      throws IOException, MissingObjectException,
+      Branch.NameKey destBranch, CodeReviewCommit mergeTip, ObjectId treeId,
+      CodeReviewCommit n) throws IOException, MissingObjectException,
       IncorrectObjectTypeException {
     final List<CodeReviewCommit> merged = new ArrayList<>();
-    rw.resetRetain(canMergeFlag);
+    rw.reset();
     rw.markStart(n);
     rw.markUninteresting(mergeTip);
     CodeReviewCommit crc;
@@ -521,7 +571,7 @@
     mergeCommit.setMessage(msgbuf.toString());
 
     CodeReviewCommit mergeResult =
-        rw.parseCommit(commit(inserter, mergeCommit));
+        rw.parseCommit(inserter.insert(mergeCommit));
     mergeResult.setControl(n.getControl());
     return mergeResult;
   }
@@ -578,23 +628,27 @@
       // new recursive merger, and instruct to operate in core.
       if (useRecursiveMerge) {
         return MergeStrategy.RECURSIVE.getName();
-      } else {
-        return MergeStrategy.RESOLVE.getName();
       }
-    } else {
-      // No auto conflict resolving allowed. If any of the
-      // affected files was modified, merge will fail.
-      return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
+      return MergeStrategy.RESOLVE.getName();
     }
+    // No auto conflict resolving allowed. If any of the
+    // affected files was modified, merge will fail.
+    return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
   }
 
   public static ThreeWayMerger newThreeWayMerger(Repository repo,
       final ObjectInserter inserter, String strategyName) {
+    Merger m = newMerger(repo, inserter, strategyName);
+    checkArgument(m instanceof ThreeWayMerger,
+        "merge strategy %s does not support three-way merging", strategyName);
+    return (ThreeWayMerger) m;
+  }
+
+  public static Merger newMerger(Repository repo,
+      final ObjectInserter inserter, String strategyName) {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
     checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
     Merger m = strategy.newMerger(repo, true);
-    checkArgument(m instanceof ThreeWayMerger,
-        "merge strategy %s does not support three-way merging", strategyName);
     m.setObjectInserter(new ObjectInserter.Filter() {
       @Override
       protected ObjectInserter delegate() {
@@ -609,15 +663,7 @@
       public void close() {
       }
     });
-    return (ThreeWayMerger) m;
-  }
-
-  public ObjectId commit(final ObjectInserter inserter,
-      final CommitBuilder mergeCommit) throws IOException,
-      UnsupportedEncodingException {
-    ObjectId id = inserter.insert(mergeCommit);
-    inserter.flush();
-    return id;
+    return m;
   }
 
   public void markCleanMerges(final RevWalk rw,
@@ -645,7 +691,7 @@
 
       CodeReviewCommit c;
       while ((c = (CodeReviewCommit) rw.next()) != null) {
-        if (c.getPatchsetId() != null) {
+        if (c.getPatchsetId() != null && c.getStatusCode() == null) {
           c.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
         }
       }
@@ -653,4 +699,71 @@
       throw new IntegrationException("Cannot mark clean merges", e);
     }
   }
+
+  public Set<Change.Id> findUnmergedChanges(Set<Change.Id> expected,
+      CodeReviewRevWalk rw, RevFlag canMergeFlag, CodeReviewCommit oldTip,
+      CodeReviewCommit mergeTip, Iterable<Change.Id> alreadyMerged)
+      throws IntegrationException {
+    if (mergeTip == null) {
+      return expected;
+    }
+
+    try {
+      Set<Change.Id> found = Sets.newHashSetWithExpectedSize(expected.size());
+      Iterables.addAll(found, alreadyMerged);
+      rw.resetRetain(canMergeFlag);
+      rw.sort(RevSort.TOPO);
+      rw.markStart(mergeTip);
+      if (oldTip != null) {
+        rw.markUninteresting(oldTip);
+      }
+
+      CodeReviewCommit c;
+      while ((c = rw.next()) != null) {
+        if (c.getPatchsetId() == null) {
+          continue;
+        }
+        Change.Id id = c.getPatchsetId().getParentKey();
+        if (!expected.contains(id)) {
+          continue;
+        }
+        found.add(id);
+        if (found.size() == expected.size()) {
+          return Collections.emptySet();
+        }
+      }
+      return Sets.difference(expected, found);
+    } catch (IOException e) {
+      throw new IntegrationException("Cannot check if changes were merged", e);
+    }
+  }
+
+  public static CodeReviewCommit findAnyMergedInto(CodeReviewRevWalk rw,
+      Iterable<CodeReviewCommit> commits, CodeReviewCommit tip) throws IOException {
+    for (CodeReviewCommit c : commits) {
+      // TODO(dborowitz): Seems like this could get expensive for many patch
+      // sets. Is there a more efficient implementation?
+      if (rw.isMergedInto(c, tip)) {
+        return c;
+      }
+    }
+    return null;
+  }
+
+  public static RevCommit resolveCommit(Repository repo, RevWalk rw, String str)
+      throws BadRequestException, ResourceNotFoundException, IOException {
+    try {
+      ObjectId commitId = repo.resolve(str);
+      if (commitId == null) {
+        throw new BadRequestException(
+            "Cannot resolve '" + str + "' to a commit");
+      }
+      return rw.parseCommit(commitId);
+    } catch (AmbiguousObjectException | IncorrectObjectTypeException |
+        RevisionSyntaxException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (MissingObjectException e) {
+      throw new ResourceNotFoundException(e.getMessage());
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
new file mode 100644
index 0000000..ce445c6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -0,0 +1,213 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeMerged;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+
+public class MergedByPushOp extends BatchUpdate.Op {
+  private static final Logger log =
+      LoggerFactory.getLogger(MergedByPushOp.class);
+
+  public interface Factory {
+    MergedByPushOp create(RequestScopePropagator requestScopePropagator,
+        PatchSet.Id psId, String refName);
+  }
+
+  private final RequestScopePropagator requestScopePropagator;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final MergedSender.Factory mergedSenderFactory;
+  private final PatchSetUtil psUtil;
+  private final ExecutorService sendEmailExecutor;
+  private final ChangeMerged changeMerged;
+
+  private final PatchSet.Id psId;
+  private final String refName;
+
+  private Change change;
+  private boolean correctBranch;
+  private Provider<PatchSet> patchSetProvider;
+  private PatchSet patchSet;
+  private PatchSetInfo info;
+
+  @AssistedInject
+  MergedByPushOp(
+      PatchSetInfoFactory patchSetInfoFactory,
+      ChangeMessagesUtil cmUtil,
+      MergedSender.Factory mergedSenderFactory,
+      PatchSetUtil psUtil,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ChangeMerged changeMerged,
+      @Assisted RequestScopePropagator requestScopePropagator,
+      @Assisted PatchSet.Id psId,
+      @Assisted String refName) {
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.cmUtil = cmUtil;
+    this.mergedSenderFactory = mergedSenderFactory;
+    this.psUtil = psUtil;
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.changeMerged = changeMerged;
+    this.requestScopePropagator = requestScopePropagator;
+    this.psId = psId;
+    this.refName = refName;
+  }
+
+  public String getMergedIntoRef() {
+    return refName;
+  }
+
+  public MergedByPushOp setPatchSetProvider(
+      Provider<PatchSet> patchSetProvider) {
+    this.patchSetProvider = checkNotNull(patchSetProvider);
+    return this;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws OrmException, IOException {
+    change = ctx.getChange();
+    correctBranch = refName.equals(change.getDest().get());
+    if (!correctBranch) {
+      return false;
+    }
+
+    if (patchSetProvider != null) {
+      // Caller might have also arranged for construction of a new patch set
+      // that is not present in the old notes so we can't use PatchSetUtil.
+      patchSet = patchSetProvider.get();
+    } else {
+      patchSet = checkNotNull(
+          psUtil.get(ctx.getDb(), ctx.getNotes(), psId),
+          "patch set %s not found", psId);
+    }
+    info = getPatchSetInfo(ctx);
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    Change.Status status = change.getStatus();
+    if (status == Change.Status.MERGED) {
+      return true;
+    }
+    if (status.isOpen()) {
+      change.setCurrentPatchSet(info);
+      change.setStatus(Change.Status.MERGED);
+
+      // we cannot reconstruct the submit records for when this change was
+      // submitted, this is why we must fix the status
+      update.fixStatus(Change.Status.MERGED);
+    }
+
+    StringBuilder msgBuf = new StringBuilder();
+    msgBuf.append("Change has been successfully pushed");
+    if (!refName.equals(change.getDest().get())) {
+      msgBuf.append(" into ");
+      if (refName.startsWith(Constants.R_HEADS)) {
+        msgBuf.append("branch ");
+        msgBuf.append(Repository.shortenRefName(refName));
+      } else {
+        msgBuf.append(refName);
+      }
+    }
+    msgBuf.append(".");
+    ChangeMessage msg = new ChangeMessage(
+        new ChangeMessage.Key(change.getId(),
+            ChangeUtil.messageUUID(ctx.getDb())),
+        ctx.getAccountId(), ctx.getWhen(), psId);
+    msg.setMessage(msgBuf.toString());
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    PatchSetApproval submitter = new PatchSetApproval(
+          new PatchSetApproval.Key(
+              change.currentPatchSetId(),
+              ctx.getAccountId(),
+              LabelId.legacySubmit()),
+              (short) 1, ctx.getWhen());
+    update.putApproval(submitter.getLabel(), submitter.getValue());
+    ctx.getDb().patchSetApprovals().upsert(
+        Collections.singleton(submitter));
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(final Context ctx) {
+    if (!correctBranch) {
+      return;
+    }
+    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          MergedSender cm =
+              mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+          cm.setFrom(ctx.getAccountId());
+          cm.setPatchSet(patchSet, info);
+          cm.send();
+        } catch (Exception e) {
+          log.error("Cannot send email for submitted patch set " + psId, e);
+        }
+      }
+
+      @Override
+      public String toString() {
+        return "send-email merged";
+      }
+    }));
+
+    changeMerged.fire(change, patchSet,
+        ctx.getAccount(),
+        patchSet.getRevision().get(),
+        ctx.getWhen());
+  }
+
+  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
+    RevWalk rw = ctx.getRevWalk();
+    RevCommit commit = rw.parseCommit(
+        ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
+    return patchSetInfoFactory.get(rw, commit, psId);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index 840b167..7e47d1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -34,7 +34,7 @@
 import java.io.IOException;
 
 /** Helps with the updating of a {@link VersionedMetaData}. */
-public class MetaDataUpdate {
+public class MetaDataUpdate implements AutoCloseable {
   public static class User {
     private final InternalFactory factory;
     private final GitRepositoryManager mgr;
@@ -79,7 +79,10 @@
    */
     public MetaDataUpdate create(Project.NameKey name, IdentifiedUser user,
         BatchRefUpdate batch) throws RepositoryNotFoundException, IOException {
-      return create(name, mgr.openRepository(name), user, batch);
+      Repository repo = mgr.openRepository(name);
+      MetaDataUpdate md = create(name, repo, user, batch);
+      md.setCloseRepository(true);
+      return md;
     }
 
     /**
@@ -89,8 +92,36 @@
      * multiple commits to a single metadata ref, see
      * {@link VersionedMetaData#openUpdate(MetaDataUpdate)}.
      *
+     * Important: Create a new MetaDataUpdate instance for each update:
+     * <pre>
+     * <code>
+     *   try (Repository repo = repoMgr.openRepository(allUsersName);
+     *       RevWalk rw = new RevWalk(repo) {
+     *     BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+     *     // WRONG: create the MetaDataUpdate instance here and reuse it for
+     *     //        all updates in the loop
+     *     for{@code (Map.Entry<Account.Id, DiffPreferencesInfo> e : diffPrefsFromDb)} {
+     *       // CORRECT: create a new MetaDataUpdate instance for each update
+     *       try (MetaDataUpdate md =
+     *           metaDataUpdateFactory.create(allUsersName, batchUpdate)) {
+     *         md.setMessage("Import diff preferences from reviewdb\n");
+     *         VersionedAccountPreferences vPrefs =
+     *             VersionedAccountPreferences.forUser(e.getKey());
+     *         storeSection(vPrefs.getConfig(), UserConfigSections.DIFF, null,
+     *             e.getValue(), DiffPreferencesInfo.defaults());
+     *         vPrefs.commit(md);
+     *       } catch (ConfigInvalidException e) {
+     *         // TODO handle exception
+     *       }
+     *     }
+     *     batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+     *   }
+     * </code>
+     * </pre>
+     *
      * @param name project name.
-     * @param repository GIT respository
+     * @param repository the repository to update; the caller is responsible for
+     *     closing the repository.
      * @param user user for the update.
      * @param batch batch update to use; the caller is responsible for committing
      *     the update.
@@ -98,8 +129,8 @@
     public MetaDataUpdate create(Project.NameKey name, Repository repository,
         IdentifiedUser user, BatchRefUpdate batch) {
       MetaDataUpdate md = factory.create(name, repository, batch);
-      md.getCommitBuilder().setAuthor(createPersonIdent(user));
       md.getCommitBuilder().setCommitter(serverIdent);
+      md.setAuthor(user);
       return md;
     }
 
@@ -130,7 +161,9 @@
     /** @see User#create(Project.NameKey, IdentifiedUser, BatchRefUpdate) */
     public MetaDataUpdate create(Project.NameKey name, BatchRefUpdate batch)
         throws RepositoryNotFoundException, IOException {
-      MetaDataUpdate md = factory.create(name, mgr.openRepository(name), batch);
+      Repository repo = mgr.openRepository(name);
+      MetaDataUpdate md = factory.create(name, repo, batch);
+      md.setCloseRepository(true);
       md.getCommitBuilder().setAuthor(serverIdent);
       md.getCommitBuilder().setCommitter(serverIdent);
       return md;
@@ -139,31 +172,34 @@
 
   interface InternalFactory {
     MetaDataUpdate create(@Assisted Project.NameKey projectName,
-        @Assisted Repository db, @Assisted @Nullable BatchRefUpdate batch);
+        @Assisted Repository repository,
+        @Assisted @Nullable BatchRefUpdate batch);
   }
 
   private final GitReferenceUpdated gitRefUpdated;
   private final Project.NameKey projectName;
-  private final Repository db;
+  private final Repository repository;
   private final BatchRefUpdate batch;
   private final CommitBuilder commit;
   private boolean allowEmpty;
   private boolean insertChangeId;
+  private boolean closeRepository;
+  private IdentifiedUser author;
 
   @AssistedInject
   public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
-      @Assisted Project.NameKey projectName, @Assisted Repository db,
+      @Assisted Project.NameKey projectName, @Assisted Repository repository,
       @Assisted @Nullable BatchRefUpdate batch) {
     this.gitRefUpdated = gitRefUpdated;
     this.projectName = projectName;
-    this.db = db;
+    this.repository = repository;
     this.batch = batch;
     this.commit = new CommitBuilder();
   }
 
   public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
-      Project.NameKey projectName, Repository db) {
-    this(gitRefUpdated, projectName, db, null);
+      Project.NameKey projectName, Repository repository) {
+    this(gitRefUpdated, projectName, repository, null);
   }
 
   /** Set the commit message used when committing the update. */
@@ -171,8 +207,9 @@
     getCommitBuilder().setMessage(message);
   }
 
-  public void setAuthor(IdentifiedUser user) {
-    getCommitBuilder().setAuthor(user.newCommitterIdent(
+  public void setAuthor(IdentifiedUser author) {
+    this.author = author;
+    getCommitBuilder().setAuthor(author.newCommitterIdent(
         getCommitBuilder().getCommitter().getWhen(),
         getCommitBuilder().getCommitter().getTimeZone()));
   }
@@ -185,14 +222,21 @@
     this.insertChangeId = insertChangeId;
   }
 
+  public void setCloseRepository(boolean closeRepository) {
+    this.closeRepository = closeRepository;
+  }
+
   /** @return batch in which to run the update, or {@code null} for no batch. */
   BatchRefUpdate getBatch() {
     return batch;
   }
 
   /** Close the cached Repository handle. */
+  @Override
   public void close() {
-    getRepository().close();
+    if (closeRepository) {
+      getRepository().close();
+    }
   }
 
   Project.NameKey getProjectName() {
@@ -200,7 +244,7 @@
   }
 
   public Repository getRepository() {
-    return db;
+    return repository;
   }
 
   boolean allowEmpty() {
@@ -215,7 +259,8 @@
     return commit;
   }
 
-  void fireGitRefUpdatedEvent(RefUpdate ru) {
-    gitRefUpdated.fire(projectName, ru);
+  protected void fireGitRefUpdatedEvent(RefUpdate ru) {
+    gitRefUpdated.fire(
+        projectName, ru, author == null ? null : author.getAccount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
new file mode 100644
index 0000000..dffcf30
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -0,0 +1,76 @@
+//Copyright (C) 2015 The Android Open Source Project
+//
+//Licensed under the Apache License, Version 2.0 (the "License");
+//you may not use this file except in compliance with the License.
+//You may obtain a copy of the License at
+//
+//http://www.apache.org/licenses/LICENSE-2.0
+//
+//Unless required by applicable law or agreed to in writing, software
+//distributed under the License is distributed on an "AS IS" BASIS,
+//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//See the License for the specific language governing permissions and
+//limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.nio.file.Path;
+
+public class MultiBaseLocalDiskRepositoryManager extends
+    LocalDiskRepositoryManager {
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      bind(GitRepositoryManager.class).to(
+          MultiBaseLocalDiskRepositoryManager.class);
+      bind(LocalDiskRepositoryManager.class).to(
+          MultiBaseLocalDiskRepositoryManager.class);
+      listener().to(MultiBaseLocalDiskRepositoryManager.class);
+      listener().to(MultiBaseLocalDiskRepositoryManager.Lifecycle.class);
+    }
+  }
+
+  private final RepositoryConfig config;
+
+  @Inject
+  MultiBaseLocalDiskRepositoryManager(SitePaths site,
+      @GerritServerConfig Config cfg,
+      RepositoryConfig config) {
+    super(site, cfg);
+    this.config = config;
+
+    for (Path alternateBasePath : config.getAllBasePaths()) {
+      checkState(alternateBasePath.isAbsolute(),
+          "repository.<name>.basePath must be absolute: %s", alternateBasePath);
+    }
+  }
+
+  @Override
+  public Path getBasePath(NameKey name) {
+    Path alternateBasePath = config.getBasePath(name);
+    return alternateBasePath != null
+        ? alternateBasePath
+        : super.getBasePath(name);
+  }
+
+  @Override
+  protected void scanProjects(ProjectVisitor visitor) {
+    super.scanProjects(visitor);
+    for (Path path : config.getAllBasePaths()) {
+      visitor.setStartFolder(path);
+      super.scanProjects(visitor);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
index 2c5e512..99abbc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -261,7 +261,7 @@
         throw new IOException("Couldn't update " + notesBranch + ". "
             + result.name());
       } else {
-        gitRefUpdated.fire(project, refUpdate);
+        gitRefUpdated.fire(project, refUpdate, null);
         break;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
index abc53f4..d8ed075 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.server.mail.Address;
 
 import java.util.EnumSet;
+import java.util.HashSet;
 import java.util.Set;
 
 public class NotifyConfig implements Comparable<NotifyConfig> {
-  public static enum Header {
+  public enum Header {
     TO, CC, BCC
   }
 
@@ -33,8 +33,8 @@
   private String filter;
 
   private Header header;
-  private Set<GroupReference> groups = Sets.newHashSet();
-  private Set<Address> addresses = Sets.newHashSet();
+  private Set<GroupReference> groups = new HashSet<>();
+  private Set<Address> addresses = new HashSet<>();
 
   public String getName() {
     return name;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index 6087432..91bc428 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.server.config.RequestScopedReviewDbProvider;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -25,6 +24,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Scope;
 
+import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Callable;
 
@@ -37,7 +37,7 @@
     private final Map<Key<?>, Object> map;
 
     private Context() {
-      map = Maps.newHashMap();
+      map = new HashMap<>();
     }
 
     private <T> T get(Key<T> key, Provider<T> creator) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 1519d84..585909a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -39,23 +39,28 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.common.errors.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.CommentLinkInfo;
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
+import com.google.gerrit.server.project.RefPattern;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.StringUtils;
 
 import java.io.IOException;
@@ -66,6 +71,7 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -119,12 +125,17 @@
   private static final String KEY_CHECK_RECEIVED_OBJECTS = "checkReceivedObjects";
   private static final String KEY_ENABLE_SIGNED_PUSH = "enableSignedPush";
   private static final String KEY_REQUIRE_SIGNED_PUSH = "requireSignedPush";
+  private static final String KEY_REJECT_IMPLICIT_MERGES = "rejectImplicitMerges";
 
   private static final String SUBMIT = "submit";
   private static final String KEY_ACTION = "action";
   private static final String KEY_MERGE_CONTENT = "mergeContent";
   private static final String KEY_STATE = "state";
 
+  private static final String SUBSCRIBE_SECTION = "allowSuperproject";
+  private static final String SUBSCRIBE_MATCH_REFS = "matching";
+  private static final String SUBSCRIBE_MULTI_MATCH_REFS = "all";
+
   private static final String DASHBOARD = "dashboard";
   private static final String KEY_DEFAULT = "default";
   private static final String KEY_LOCAL_DEFAULT = "local-default";
@@ -134,6 +145,7 @@
   private static final String KEY_DEFAULT_VALUE = "defaultValue";
   private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = "copyAllScoresOnMergeFirstParentUpdate";
   private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
   private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
   private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
@@ -141,7 +153,7 @@
   private static final String KEY_CAN_OVERRIDE = "canOverride";
   private static final String KEY_Branch = "branch";
   private static final Set<String> LABEL_FUNCTIONS = ImmutableSet.of(
-      "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp");
+      "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
   private static final String PLUGIN = "plugin";
 
@@ -160,12 +172,14 @@
   private Map<String, NotifyConfig> notifySections;
   private Map<String, LabelType> labelSections;
   private ConfiguredMimeTypes mimeTypes;
-  private List<CommentLinkInfo> commentLinkSections;
+  private Map<Project.NameKey, SubscribeSection> subscribeSections;
+  private List<CommentLinkInfoImpl> commentLinkSections;
   private List<ValidationError> validationErrors;
   private ObjectId rulesId;
   private long maxObjectSizeLimit;
   private Map<String, Config> pluginConfigs;
   private boolean checkReceivedObjects;
+  private Set<String> sectionsWithUnknownPermissions;
 
   public static ProjectConfig read(MetaDataUpdate update) throws IOException,
       ConfigInvalidException {
@@ -181,7 +195,7 @@
     return r;
   }
 
-  public static CommentLinkInfo buildCommentLink(Config cfg, String name,
+  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name,
       boolean allowRaw) throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -209,12 +223,11 @@
     if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && !hasHtml
         && enabled != null) {
       if (enabled) {
-        return new CommentLinkInfo.Enabled(name);
-      } else {
-        return new CommentLinkInfo.Disabled(name);
+        return new CommentLinkInfoImpl.Enabled(name);
       }
+      return new CommentLinkInfoImpl.Disabled(name);
     }
-    return new CommentLinkInfo(name, match, link, html, enabled);
+    return new CommentLinkInfoImpl(name, match, link, html, enabled);
   }
 
   public ProjectConfig(Project.NameKey projectName) {
@@ -254,9 +267,69 @@
     return branchOrderSection;
   }
 
+  public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
+    return subscribeSections;
+  }
+
+  public Collection<SubscribeSection> getSubscribeSections(
+      Branch.NameKey branch) {
+    Collection<SubscribeSection> ret = new ArrayList<>();
+    for (SubscribeSection s : subscribeSections.values()) {
+      if (s.appliesTo(branch)) {
+        ret.add(s);
+      }
+    }
+    return ret;
+  }
+
+  public void addSubscribeSection(SubscribeSection s) {
+    subscribeSections.put(s.getProject(), s);
+  }
+
   public void remove(AccessSection section) {
     if (section != null) {
-      accessSections.remove(section.getName());
+      String name = section.getName();
+      if (sectionsWithUnknownPermissions.contains(name)) {
+        AccessSection a = accessSections.get(name);
+        a.setPermissions(new ArrayList<Permission>());
+      } else {
+        accessSections.remove(name);
+      }
+    }
+  }
+
+  public void remove(AccessSection section, Permission permission) {
+    if (permission == null) {
+      remove(section);
+    } else if (section != null) {
+      AccessSection a = accessSections.get(section.getName());
+      a.remove(permission);
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
+    }
+  }
+
+  public void remove(AccessSection section,
+      Permission permission, PermissionRule rule) {
+    if (rule == null) {
+      remove(section, permission);
+    } else if (section != null && permission != null) {
+      AccessSection a = accessSections.get(section.getName());
+      if (a == null) {
+        return;
+      }
+      Permission p = a.getPermission(permission.getName());
+      if (p == null) {
+        return;
+      }
+      p.remove(rule);
+      if (p.getRules().isEmpty()) {
+        a.remove(permission);
+      }
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
     }
   }
 
@@ -314,7 +387,7 @@
     return labelSections;
   }
 
-  public Collection<CommentLinkInfo> getCommentLinkSections() {
+  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
     return commentLinkSections;
   }
 
@@ -389,9 +462,8 @@
   public List<ValidationError> getValidationErrors() {
     if (validationErrors != null) {
       return Collections.unmodifiableList(validationErrors);
-    } else {
-      return Collections.emptyList();
     }
+    return Collections.emptyList();
   }
 
   @Override
@@ -424,6 +496,8 @@
     p.setRequireSignedPush(getEnum(rc, RECEIVE, null,
           KEY_REQUIRE_SIGNED_PUSH, InheritableBoolean.INHERIT));
     p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
+    p.setRejectImplicitMerges(getEnum(rc, RECEIVE, null,
+        KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
 
     p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, defaultSubmitAction));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
@@ -439,6 +513,7 @@
     loadNotifySections(rc, groupsByName);
     loadLabelSections(rc);
     loadCommentLinkSections(rc);
+    loadSubscribeSections(rc);
     mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
@@ -503,7 +578,7 @@
    */
   private void loadNotifySections(
       Config rc, Map<String, GroupReference> groupsByName) {
-    notifySections = Maps.newHashMap();
+    notifySections = new HashMap<>();
     for (String sectionName : rc.getSubsections(NOTIFY)) {
       NotifyConfig n = new NotifyConfig();
       n.setName(sectionName);
@@ -549,6 +624,7 @@
   private void loadAccessSections(
       Config rc, Map<String, GroupReference> groupsByName) {
     accessSections = new HashMap<>();
+    sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
       if (RefConfigSection.isValid(refName) && isValidRegex(refName)) {
         AccessSection as = getAccessSection(refName, true);
@@ -566,6 +642,8 @@
             Permission perm = as.getPermission(varName, true);
             loadPermissionRules(rc, ACCESS, refName, varName, groupsByName,
                 perm, Permission.hasRange(varName));
+          } else {
+            sectionsWithUnknownPermissions.add(as.getName());
           }
         }
       }
@@ -585,8 +663,8 @@
 
   private boolean isValidRegex(String refPattern) {
     try {
-      Pattern.compile(refPattern.replace("${username}/", ""));
-    } catch (PatternSyntaxException e) {
+      RefPattern.validateRegExp(refPattern);
+    } catch (InvalidNameException e) {
       error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: "
           + e.getMessage()));
       return false;
@@ -659,7 +737,7 @@
 
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
-    labelSections = Maps.newLinkedHashMap();
+    labelSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(LABEL)) {
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
@@ -669,7 +747,7 @@
       }
       lowerNames.put(lower, name);
 
-      List<LabelValue> values = Lists.newArrayList();
+      List<LabelValue> values = new ArrayList<>();
       for (String value : rc.getStringList(LABEL, name, KEY_VALUE)) {
         try {
           values.add(parseLabelValue(value));
@@ -716,6 +794,9 @@
       label.setCopyMaxScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE,
               LabelType.DEF_COPY_MAX_SCORE));
+      label.setCopyAllScoresOnMergeFirstParentUpdate(
+          rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
       label.setCopyAllScoresOnTrivialRebase(
           rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
               LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
@@ -767,13 +848,35 @@
     commentLinkSections = ImmutableList.copyOf(commentLinkSections);
   }
 
+  private void loadSubscribeSections(Config rc) throws ConfigInvalidException {
+    Set<String> subsections = rc.getSubsections(SUBSCRIBE_SECTION);
+    subscribeSections = new HashMap<>();
+    try {
+      for (String projectName : subsections) {
+        Project.NameKey p = new Project.NameKey(projectName);
+        SubscribeSection ss = new SubscribeSection(p);
+        for (String s : rc.getStringList(SUBSCRIBE_SECTION,
+            projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
+          ss.addMultiMatchRefSpec(s);
+        }
+        for (String s : rc.getStringList(SUBSCRIBE_SECTION,
+            projectName, SUBSCRIBE_MATCH_REFS)) {
+          ss.addMatchingRefSpec(s);
+        }
+        subscribeSections.put(p, ss);
+      }
+    } catch (IllegalArgumentException e) {
+      throw new ConfigInvalidException(e.getMessage());
+    }
+  }
+
   private void loadReceiveSection(Config rc) {
     checkReceivedObjects = rc.getBoolean(RECEIVE, KEY_CHECK_RECEIVED_OBJECTS, true);
     maxObjectSizeLimit = rc.getLong(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT, 0);
   }
 
   private void loadPluginSections(Config rc) {
-    pluginConfigs = Maps.newHashMap();
+    pluginConfigs = new HashMap<>();
     for (String plugin : rc.getSubsections(PLUGIN)) {
       Config pluginConfig = new Config();
       pluginConfigs.put(plugin, pluginConfig);
@@ -844,6 +947,8 @@
         p.getEnableSignedPush(), InheritableBoolean.INHERIT);
     set(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_PUSH,
         p.getRequireSignedPush(), InheritableBoolean.INHERIT);
+    set(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES,
+        p.getRejectImplicitMerges(), InheritableBoolean.INHERIT);
 
     set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), defaultSubmitAction);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
@@ -861,6 +966,7 @@
     savePluginSections(rc, keepGroups);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
+    saveSubscribeSections(rc);
 
     saveConfig(PROJECT_CONFIG, rc);
     saveGroupList();
@@ -927,7 +1033,7 @@
   private void saveNotifySections(
       Config rc, Set<AccountGroup.UUID> keepGroups) {
     for (NotifyConfig nc : sort(notifySections.values())) {
-      List<String> email = Lists.newArrayList();
+      List<String> email = new ArrayList<>();
       for (GroupReference gr : nc.getGroups()) {
         if (gr.getUUID() != null) {
           keepGroups.add(gr.getUUID());
@@ -936,7 +1042,7 @@
       }
       Collections.sort(email);
 
-      List<String> addrs = Lists.newArrayList();
+      List<String> addrs = new ArrayList<>();
       for (Address addr : nc.getAddresses()) {
         addrs.add(addr.toString());
       }
@@ -990,7 +1096,7 @@
         boolean needRange = GlobalCapability.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
         for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = rule.getGroup();
+          GroupReference group = resolve(rule.getGroup());
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
@@ -1035,7 +1141,7 @@
         boolean needRange = Permission.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
         for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = rule.getGroup();
+          GroupReference group = resolve(rule.getGroup());
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
@@ -1088,6 +1194,9 @@
       setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+      setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+          label.isCopyAllScoresOnMergeFirstParentUpdate(),
+          LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
       setBooleanConfigKey(rc, name, KEY_CAN_OVERRIDE, label.canOverride(),
           LabelType.DEF_CAN_OVERRIDE);
       List<String> values =
@@ -1140,6 +1249,25 @@
     saveUTF8(GroupList.FILE_NAME, groupList.asText());
   }
 
+  private void saveSubscribeSections(Config rc) {
+    for (Project.NameKey p : subscribeSections.keySet()) {
+      SubscribeSection s = subscribeSections.get(p);
+      List<String> matchings = new ArrayList<>();
+      for (RefSpec r : s.getMatchingRefSpecs()) {
+        matchings.add(r.toString());
+      }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS,
+          matchings);
+
+      List<String> multimatchs = new ArrayList<>();
+      for (RefSpec r : s.getMultiMatchRefSpecs()) {
+        multimatchs.add(r.toString());
+      }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(),
+          SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
+    }
+  }
+
   private <E extends Enum<?>> E getEnum(Config rc, String section,
       String subsection, String name, E defaultValue) {
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
index 32f157d..7032878 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
@@ -22,4 +22,4 @@
   String getRemoteName();
 
   boolean hasCustomizedPrint();
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
index c7d925c..06b87f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.git;
 
 public interface QueueProvider {
-  public static enum QueueType {
+  enum QueueType {
     INTERACTIVE, BATCH
   }
 
-  public WorkQueue.Executor getQueue(QueueType type);
+  WorkQueue.Executor getQueue(QueueType type);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
new file mode 100644
index 0000000..32faeac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.lib.BaseRepositoryBuilder;
+import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+class ReadOnlyRepository extends Repository {
+  private static final String MSG =
+      "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
+
+  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
+    checkNotNull(r);
+    BaseRepositoryBuilder<?, ?> builder = new BaseRepositoryBuilder<>()
+        .setFS(r.getFS())
+        .setGitDir(r.getDirectory());
+
+    if (!r.isBare()) {
+      builder.setWorkTree(r.getWorkTree())
+          .setIndexFile(r.getIndexFile());
+    }
+    return builder;
+  }
+
+  private final Repository delegate;
+  private final RefDb refdb;
+  private final ObjDb objdb;
+
+  ReadOnlyRepository(Repository delegate) {
+    super(builder(delegate));
+    this.delegate = delegate;
+    this.refdb = new RefDb(delegate.getRefDatabase());
+    this.objdb = new ObjDb(delegate.getObjectDatabase());
+  }
+
+  @Override
+  public void create(boolean bare) throws IOException {
+    throw new UnsupportedOperationException(MSG);
+  }
+
+  @Override
+  public ObjectDatabase getObjectDatabase() {
+    return objdb;
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return refdb;
+  }
+
+  @Override
+  public StoredConfig getConfig() {
+    return delegate.getConfig();
+  }
+
+  @Override
+  public AttributesNodeProvider createAttributesNodeProvider() {
+    return delegate.createAttributesNodeProvider();
+  }
+
+  @Override
+  public void scanForRepoChanges() throws IOException {
+    delegate.scanForRepoChanges();
+  }
+
+  @Override
+  public void notifyIndexChanged() {
+    delegate.notifyIndexChanged();
+  }
+
+  @Override
+  public ReflogReader getReflogReader(String refName) throws IOException {
+    return delegate.getReflogReader(refName);
+  }
+
+  private static class RefDb extends RefDatabase {
+    private final RefDatabase delegate;
+
+    private RefDb(RefDatabase delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public void create() throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public void close() {
+      delegate.close();
+    }
+
+    @Override
+    public boolean isNameConflicting(String name) throws IOException {
+      return delegate.isNameConflicting(name);
+    }
+
+    @Override
+    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public RefRename newRename(String fromName, String toName)
+        throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public Ref getRef(String name) throws IOException {
+      return delegate.getRef(name);
+    }
+
+    @Override
+    public Map<String, Ref> getRefs(String prefix) throws IOException {
+      return delegate.getRefs(prefix);
+    }
+
+    @Override
+    public List<Ref> getAdditionalRefs() throws IOException {
+      return delegate.getAdditionalRefs();
+    }
+
+    @Override
+    public Ref peel(Ref ref) throws IOException {
+      return delegate.peel(ref);
+    }
+  }
+
+  private static class ObjDb extends ObjectDatabase {
+    private final ObjectDatabase delegate;
+
+    private ObjDb(ObjectDatabase delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public ObjectInserter newInserter() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ObjectReader newReader() {
+      return delegate.newReader();
+    }
+
+    @Override
+    public void close() {
+      delegate.close();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
index fdf7c40..ccf876a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 0401367f..49b66331 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
@@ -37,24 +37,20 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.BiMap;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Capable;
@@ -63,36 +59,34 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeKind;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.PluginConfig;
@@ -101,16 +95,16 @@
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.git.validators.RefOperationValidationException;
+import com.google.gerrit.server.git.validators.RefOperationValidators;
+import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.MergedSender;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
@@ -124,12 +118,10 @@
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -174,11 +166,11 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ExecutionException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -188,12 +180,17 @@
       LoggerFactory.getLogger(ReceiveCommits.class);
 
   public static final Pattern NEW_PATCHSET = Pattern.compile(
-      "^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/new)?$");
+      "^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
 
   private static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Please read the documentation and contact an administrator\n"
           + "if you feel the configuration is incorrect";
 
+  private static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
+      "same Change-Id in multiple changes.\n"
+          + "Squash the commits with the same Change-Id or "
+          + "ensure Change-Ids are unique for each commit";
+
   private enum Error {
         CONFIG_UPDATE("You are not allowed to perform this operation.\n"
         + "Configuration changes can only be pushed by project owners\n"
@@ -270,6 +267,9 @@
         public RestApiException apply(Exception input) {
           if (input instanceof RestApiException) {
             return (RestApiException) input;
+          } else if ((input instanceof ExecutionException)
+              && (input.getCause() instanceof RestApiException)) {
+            return (RestApiException) input.getCause();
           }
           return new RestApiException("Error inserting change/patchset", input);
         }
@@ -280,38 +280,31 @@
 
   private final IdentifiedUser user;
   private final ReviewDb db;
+  private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeUpdate.Factory updateFactory;
-  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ChangeNotes.Factory notesFactory;
   private final AccountResolver accountResolver;
   private final CmdLineParser.Factory optionParserFactory;
-  private final MergedSender.Factory mergedSenderFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ChangeHooks hooks;
-  private final ApprovalsUtil approvalsUtil;
-  private final ApprovalCopier approvalCopier;
-  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final String canonicalWebUrl;
   private final CommitValidators.Factory commitValidatorsFactory;
+  private final RefOperationValidators.Factory refValidatorsFactory;
   private final TagCache tagCache;
   private final AccountCache accountCache;
-  private final ChangesCollection changes;
   private final ChangeInserter.Factory changeInserterFactory;
-  private final ExecutorService sendEmailExecutor;
-  private final ListeningExecutorService changeUpdateExector;
   private final RequestScopePropagator requestScopePropagator;
-  private final ChangeIndexer indexer;
   private final SshInfo sshInfo;
   private final AllProjectsName allProjectsName;
   private final ReceiveConfig receiveConfig;
-  private final ChangeKindCache changeKindCache;
+  private final DynamicSet<ReceivePackInitializer> initializers;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
+  private final ReplaceOp.Factory replaceOpFactory;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
 
   private final ProjectControl projectControl;
   private final Project project;
@@ -319,27 +312,28 @@
   private final Repository repo;
   private final ReceivePack rp;
   private final NoteMap rejectCommits;
+  private final RequestId receiveId;
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
 
   private List<CreateRequest> newChanges = Collections.emptyList();
   private final Map<Change.Id, ReplaceRequest> replaceByChange =
-      new HashMap<>();
+      new LinkedHashMap<>();
   private final List<UpdateGroupsRequest> updateGroups = new ArrayList<>();
-  private final Set<RevCommit> validCommits = new HashSet<>();
+  private final Set<ObjectId> validCommits = new HashSet<>();
 
   private ListMultimap<Change.Id, Ref> refsByChange;
   private SetMultimap<ObjectId, Ref> refsById;
   private Map<String, Ref> allRefs;
 
-  private final Provider<SubmoduleOp> subOpProvider;
-  private final Provider<Submit> submitProvider;
+  private final SubmoduleOp.Factory subOpFactory;
   private final Provider<MergeOp> mergeOpProvider;
+  private final Provider<MergeOpRepoManager> ormProvider;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final NotesMigration notesMigration;
   private final ChangeEditUtil editUtil;
 
-  private final List<CommitValidationMessage> messages = new ArrayList<>();
+  private final List<ValidationMessage> messages = new ArrayList<>();
   private ListMultimap<Error, String> errors = LinkedListMultimap.create();
   private Task newProgress;
   private Task replaceProgress;
@@ -349,82 +343,70 @@
   private BatchRefUpdate batch;
 
   @Inject
-  ReceiveCommits(final ReviewDb db,
-      final Provider<InternalChangeQuery> queryProvider,
-      final SchemaFactory<ReviewDb> schemaFactory,
-      final ChangeData.Factory changeDataFactory,
-      final ChangeUpdate.Factory updateFactory,
-      final AccountResolver accountResolver,
-      final CmdLineParser.Factory optionParserFactory,
-      final MergedSender.Factory mergedSenderFactory,
-      final ReplacePatchSetSender.Factory replacePatchSetFactory,
-      final GitReferenceUpdated gitRefUpdated,
-      final PatchSetInfoFactory patchSetInfoFactory,
-      final ChangeHooks hooks,
-      final ApprovalsUtil approvalsUtil,
-      final ApprovalCopier approvalCopier,
-      final ChangeMessagesUtil cmUtil,
-      final ProjectCache projectCache,
-      final GitRepositoryManager repoManager,
-      final TagCache tagCache,
-      final AccountCache accountCache,
-      final ChangeCache changeCache,
-      final ChangesCollection changes,
-      final ChangeInserter.Factory changeInserterFactory,
-      final CommitValidators.Factory commitValidatorsFactory,
-      @CanonicalWebUrl final String canonicalWebUrl,
-      @SendEmailExecutor final ExecutorService sendEmailExecutor,
-      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
-      final RequestScopePropagator requestScopePropagator,
-      final ChangeIndexer indexer,
-      final SshInfo sshInfo,
-      final AllProjectsName allProjectsName,
-      ReceiveConfig config,
-      @Assisted final ProjectControl projectControl,
-      @Assisted final Repository repo,
-      final Provider<SubmoduleOp> subOpProvider,
-      final Provider<Submit> submitProvider,
-      final Provider<MergeOp> mergeOpProvider,
-      final ChangeKindCache changeKindCache,
-      final DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      final NotesMigration notesMigration,
-      final ChangeEditUtil editUtil,
-      final BatchUpdate.Factory batchUpdateFactory,
-      final SetHashtagsOp.Factory hashtagsFactory) throws IOException {
+  ReceiveCommits(ReviewDb db,
+      Sequences seq,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      AccountResolver accountResolver,
+      CmdLineParser.Factory optionParserFactory,
+      GitReferenceUpdated gitRefUpdated,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetUtil psUtil,
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager,
+      TagCache tagCache,
+      AccountCache accountCache,
+      @Nullable SearchingChangeCacheImpl changeCache,
+      ChangeInserter.Factory changeInserterFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      RefOperationValidators.Factory refValidatorsFactory,
+      @CanonicalWebUrl String canonicalWebUrl,
+      RequestScopePropagator requestScopePropagator,
+      SshInfo sshInfo,
+      AllProjectsName allProjectsName,
+      ReceiveConfig receiveConfig,
+      TransferConfig transferConfig,
+      DynamicSet<ReceivePackInitializer> initializers,
+      Provider<LazyPostReceiveHookChain> lazyPostReceive,
+      @Assisted ProjectControl projectControl,
+      @Assisted Repository repo,
+      SubmoduleOp.Factory subOpFactory,
+      Provider<MergeOp> mergeOpProvider,
+      Provider<MergeOpRepoManager> ormProvider,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      NotesMigration notesMigration,
+      ChangeEditUtil editUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      SetHashtagsOp.Factory hashtagsFactory,
+      ReplaceOp.Factory replaceOpFactory,
+      MergedByPushOp.Factory mergedByPushOpFactory) throws IOException {
     this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
+    this.seq = seq;
     this.queryProvider = queryProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.updateFactory = updateFactory;
-    this.schemaFactory = schemaFactory;
+    this.notesFactory = notesFactory;
     this.accountResolver = accountResolver;
     this.optionParserFactory = optionParserFactory;
-    this.mergedSenderFactory = mergedSenderFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.patchSetInfoFactory = patchSetInfoFactory;
-    this.hooks = hooks;
-    this.approvalsUtil = approvalsUtil;
-    this.approvalCopier = approvalCopier;
-    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.projectCache = projectCache;
     this.repoManager = repoManager;
     this.canonicalWebUrl = canonicalWebUrl;
     this.tagCache = tagCache;
     this.accountCache = accountCache;
-    this.changes = changes;
     this.changeInserterFactory = changeInserterFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.sendEmailExecutor = sendEmailExecutor;
-    this.changeUpdateExector = changeUpdateExector;
+    this.refValidatorsFactory = refValidatorsFactory;
     this.requestScopePropagator = requestScopePropagator;
-    this.indexer = indexer;
     this.sshInfo = sshInfo;
     this.allProjectsName = allProjectsName;
-    this.receiveConfig = config;
-    this.changeKindCache = changeKindCache;
+    this.receiveConfig = receiveConfig;
+    this.initializers = initializers;
     this.batchUpdateFactory = batchUpdateFactory;
     this.hashtagsFactory = hashtagsFactory;
+    this.replaceOpFactory = replaceOpFactory;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
 
     this.projectControl = projectControl;
     this.labelTypes = projectControl.getLabelTypes();
@@ -432,10 +414,11 @@
     this.repo = repo;
     this.rp = new ReceivePack(repo);
     this.rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk());
+    this.receiveId = RequestId.forProject(project.getNameKey());
 
-    this.subOpProvider = subOpProvider;
-    this.submitProvider = submitProvider;
+    this.subOpFactory = subOpFactory;
     this.mergeOpProvider = mergeOpProvider;
+    this.ormProvider = ormProvider;
     this.pluginConfigEntries = pluginConfigEntries;
     this.notesMigration = notesMigration;
 
@@ -449,11 +432,16 @@
     rp.setAllowCreates(true);
     rp.setAllowDeletes(true);
     rp.setAllowNonFastForwards(true);
+    rp.setRefLogIdent(user.newRefLogIdent());
+    rp.setTimeout(transferConfig.getTimeout());
+    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(
+        projectControl.getProjectState()));
     rp.setCheckReceivedObjects(ps.getConfig().getCheckReceivedObjects());
     rp.setRefFilter(new RefFilter() {
       @Override
       public Map<String, Ref> filter(Map<String, Ref> refs) {
-        Map<String, Ref> filteredRefs = Maps.newHashMapWithExpectedSize(refs.size());
+        Map<String, Ref> filteredRefs =
+            Maps.newHashMapWithExpectedSize(refs.size());
         for (Map.Entry<String, Ref> e : refs.entrySet()) {
           String name = e.getKey();
           if (!name.startsWith(REFS_CHANGES)
@@ -466,9 +454,11 @@
     });
 
     if (!projectControl.allRefsAreVisible()) {
-      rp.setCheckReferencedObjectsAreReachable(config.checkReferencedObjectsAreReachable);
-      rp.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeCache, repo, projectControl, db, false));
+      rp.setCheckReferencedObjectsAreReachable(
+          receiveConfig.checkReferencedObjectsAreReachable);
     }
+    rp.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, notesFactory,
+        changeCache, repo, projectControl, db, false));
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
     advHooks.add(new AdvertiseRefsHook() {
       @Override
@@ -481,7 +471,8 @@
           } catch (ServiceMayNotContinueException e) {
             throw e;
           } catch (IOException e) {
-            ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+            ServiceMayNotContinueException ex =
+                new ServiceMayNotContinueException();
             ex.initCause(e);
             throw ex;
           }
@@ -495,9 +486,16 @@
     });
     advHooks.add(rp.getAdvertiseRefsHook());
     advHooks.add(new ReceiveCommitsAdvertiseRefsHook(
-        db, queryProvider, projectControl.getProject().getNameKey()));
+        queryProvider, projectControl.getProject().getNameKey()));
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
+    rp.setPostReceiveHook(lazyPostReceive.get());
+  }
+
+  public void init() {
+    for (ReceivePackInitializer i : initializers) {
+      i.init(projectControl.getProject().getNameKey(), rp);
+    }
   }
 
   /** Add reviewers for new (or updated) changes. */
@@ -511,7 +509,7 @@
   }
 
   /** Set a message sender for this operation. */
-  public void setMessageSender(final MessageSender ms) {
+  public void setMessageSender(MessageSender ms) {
     messageSender = ms != null ? ms : new ReceivePackMessageSender();
   }
 
@@ -552,7 +550,7 @@
   }
 
   void sendMessages() {
-    for (CommitValidationMessage m : messages) {
+    for (ValidationMessage m : messages) {
       if (m.isError()) {
         messageSender.sendError(m.getMessage());
       } else {
@@ -561,8 +559,8 @@
     }
   }
 
-  void processCommands(final Collection<ReceiveCommand> commands,
-      final MultiProgressMonitor progress) {
+  void processCommands(Collection<ReceiveCommand> commands,
+      MultiProgressMonitor progress) {
     newProgress = progress.beginSubTask("new", UNKNOWN);
     replaceProgress = progress.beginSubTask("updated", UNKNOWN);
     closeProgress = progress.beginSubTask("closed", UNKNOWN);
@@ -579,10 +577,12 @@
     }
     preparePatchSetsForReplace();
 
+    logDebug("Executing batch with {} commands", batch.getCommands().size());
     if (!batch.getCommands().isEmpty()) {
       try {
         if (!batch.isAllowNonFastForwards() && magicBranch != null
             && magicBranch.edit) {
+          logDebug("Allowing non-fast-forward for edit ref");
           batch.setAllowNonFastForwards(true);
         }
         batch.execute(rp.getRevWalk(), commandProgress);
@@ -594,7 +594,7 @@
             cnt++;
           }
         }
-        log.error(String.format(
+        logError(String.format(
             "Failed to store %d refs in %s", cnt, project.getName()), err);
       }
     }
@@ -604,6 +604,7 @@
     replaceProgress.end();
 
     if (!errors.isEmpty()) {
+      logDebug("Handling error conditions: {}", errors.keySet());
       for (Error error : errors.keySet()) {
         rp.sendMessage(buildError(error, errors.get(error)));
       }
@@ -611,70 +612,62 @@
       rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
     }
 
-    Set<Branch.NameKey> branches = Sets.newHashSet();
-    for (final ReceiveCommand c : commands) {
-        if (c.getResult() == OK) {
-          if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-              tagCache.updateFastForward(project.getNameKey(),
-                  c.getRefName(),
-                  c.getOldId(),
-                  c.getNewId());
-          }
+    Set<Branch.NameKey> branches = new HashSet<>();
+    for (ReceiveCommand c : batch.getCommands()) {
+      if (c.getResult() == OK) {
+        String refName = c.getRefName();
+        if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
+          logDebug("Updating tag cache on fast-forward of {}", c.getRefName());
+          tagCache.updateFastForward(project.getNameKey(),
+              refName,
+              c.getOldId(),
+              c.getNewId());
+        }
 
-          if (isHead(c) || isConfig(c)) {
-            switch (c.getType()) {
-              case CREATE:
-              case UPDATE:
-              case UPDATE_NONFASTFORWARD:
-                autoCloseChanges(c);
-                branches.add(new Branch.NameKey(project.getNameKey(),
-                    c.getRefName()));
-                break;
+        if (isHead(c) || isConfig(c)) {
+          switch (c.getType()) {
+            case CREATE:
+            case UPDATE:
+            case UPDATE_NONFASTFORWARD:
+              autoCloseChanges(c);
+              branches.add(new Branch.NameKey(project.getNameKey(),
+                  refName));
+              break;
 
-              case DELETE:
-                ResultSet<SubmoduleSubscription> submoduleSubscriptions = null;
-                Branch.NameKey projRef = new Branch.NameKey(project.getNameKey(),
-                    c.getRefName());
-                try {
-                  submoduleSubscriptions =
-                      db.submoduleSubscriptions().bySuperProject(projRef);
-                  db.submoduleSubscriptions().delete(submoduleSubscriptions);
-                } catch (OrmException e) {
-                  log.error("Cannot delete submodule subscription(s) of branch "
-                      + projRef + ": " + submoduleSubscriptions, e);
-                }
-                break;
-            }
-          }
-
-          if (isConfig(c)) {
-            projectCache.evict(project);
-            ProjectState ps = projectCache.get(project.getNameKey());
-            repoManager.setProjectDescription(project.getNameKey(), //
-                ps.getProject().getDescription());
-          }
-
-          if (!MagicBranch.isMagicBranch(c.getRefName())) {
-            // We only fire gitRefUpdated for direct refs updates.
-            // Events for change refs are fired when they are created.
-            //
-            gitRefUpdated.fire(project.getNameKey(), c);
-            hooks.doRefUpdatedHook(
-                new Branch.NameKey(project.getNameKey(), c.getRefName()),
-                c.getOldId(),
-                c.getNewId(),
-                user.getAccount());
+            case DELETE:
+              break;
           }
         }
+
+        if (isConfig(c)) {
+          logDebug("Reloading project in cache");
+          projectCache.evict(project);
+          ProjectState ps = projectCache.get(project.getNameKey());
+          repoManager.setProjectDescription(project.getNameKey(), //
+              ps.getProject().getDescription());
+        }
+
+        if (!MagicBranch.isMagicBranch(refName)
+            && !refName.startsWith(REFS_CHANGES)) {
+          logDebug("Firing ref update for {}", c.getRefName());
+          // We only fire gitRefUpdated for direct refs updates.
+          // Events for change refs are fired when they are created.
+          //
+          gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
+        } else {
+          logDebug("Assuming ref update event for {} has fired",
+              c.getRefName());
+        }
+      }
     }
+
     // Update superproject gitlinks if required.
-    SubmoduleOp op = subOpProvider.get();
-    try {
-       op.updateSubmoduleSubscriptions(db, branches);
-       op.updateSuperProjects(db, branches);
+    try (MergeOpRepoManager orm = ormProvider.get()) {
+      orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+      SubmoduleOp op = subOpFactory.create(branches, orm);
+      op.updateSuperProjects();
     } catch (SubmoduleException e) {
-      log.error("Can't update submodule subscriptions "
-          + "or update the superprojects", e);
+      logError("Can't update the superprojects", e);
     }
 
     closeProgress.end();
@@ -688,15 +681,16 @@
         Iterables.filter(newChanges, new Predicate<CreateRequest>() {
           @Override
           public boolean apply(CreateRequest input) {
-            return input.created;
+            return input.change != null;
           }
         });
     if (!Iterables.isEmpty(created)) {
       addMessage("");
       addMessage("New Changes:");
       for (CreateRequest c : created) {
-        addMessage(formatChangeUrl(canonicalWebUrl, c.change,
-            c.change.getSubject(), false));
+        addMessage(
+            formatChangeUrl(canonicalWebUrl, c.change, c.change.getSubject(),
+                c.change.getStatus() == Change.Status.DRAFT, false));
       }
       addMessage("");
     }
@@ -713,7 +707,7 @@
             new Function<ReplaceRequest, Integer>() {
               @Override
               public Integer apply(ReplaceRequest in) {
-                return in.change.getId().get();
+                return in.notes.getChangeId().get();
               }
             }));
     if (!updated.isEmpty()) {
@@ -721,22 +715,36 @@
       addMessage("Updated Changes:");
       boolean edit = magicBranch != null && magicBranch.edit;
       for (ReplaceRequest u : updated) {
-        addMessage(formatChangeUrl(canonicalWebUrl, u.change,
-            u.newCommit.getShortMessage(), edit));
+        String subject;
+        if (edit) {
+          try {
+            subject =
+                rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
+          } catch (IOException e) {
+            // Log and fall back to original change subject
+            logWarn("failed to get subject for edit patch set", e);
+            subject = u.notes.getChange().getSubject();
+          }
+        } else {
+          subject = u.info.getSubject();
+        }
+        addMessage(formatChangeUrl(canonicalWebUrl, u.notes.getChange(),
+            subject, u.replaceOp != null && u.replaceOp.getPatchSet().isDraft(),
+            edit));
       }
       addMessage("");
     }
   }
 
   private static String formatChangeUrl(String url, Change change,
-      String subject, boolean edit) {
+      String subject, boolean draft, boolean edit) {
     StringBuilder m = new StringBuilder()
         .append("  ")
         .append(url)
         .append(change.getChangeId())
         .append(" ")
         .append(ChangeUtil.cropSubject(subject));
-    if (change.getStatus() == Change.Status.DRAFT) {
+    if (draft) {
       m.append(" [DRAFT]");
     }
     if (edit) {
@@ -758,30 +766,43 @@
           okToInsert++;
         }
       } else if (replace.cmd != null && replace.cmd.getResult() == OK) {
+        String refName = replace.inputCommand.getRefName();
+        checkState(
+            NEW_PATCHSET.matcher(refName).matches(),
+            "expected a new patch set command as input when creating %s;"
+                + " got %s",
+            replace.cmd.getRefName(), refName);
         try {
-          if (replace.insertPatchSet().checkedGet() != null) {
-            replace.inputCommand.setResult(OK);
-          }
-        } catch (IOException | RestApiException err) {
+          logDebug("One-off insertion of patch set for {}", refName);
+          replace.insertPatchSetWithoutBatchUpdate();
+          replace.inputCommand.setResult(OK);
+        } catch (IOException | UpdateException | RestApiException err) {
           reject(replace.inputCommand, "internal server error");
-          log.error(String.format(
+          logError(String.format(
               "Cannot add patch set to change %d in project %s",
               e.getKey().get(), project.getName()), err);
         }
       } else if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
         reject(replace.inputCommand, "internal server error");
-        log.error(String.format("Replacement for project %s was not attempted",
+        logError(String.format("Replacement for project %s was not attempted",
             project.getName()));
       }
     }
 
-    if (magicBranch == null || magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      // refs/for/ or refs/drafts/ not used, or it already failed earlier.
-      // No need to continue.
+    // refs/for/ or refs/drafts/ not used, or it already failed earlier.
+    // No need to continue.
+    if (magicBranch == null) {
+      logDebug("No magic branch, nothing more to do");
+      return;
+    } else if (magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+      logWarn(String.format(
+          "Skipping change updates on %s because ref update failed: %s %s",
+          project.getName(), magicBranch.cmd.getResult(),
+          Strings.nullToEmpty(magicBranch.cmd.getMessage())));
       return;
     }
 
-    List<String> lastCreateChangeErrors = Lists.newArrayList();
+    List<String> lastCreateChangeErrors = new ArrayList<>();
     for (CreateRequest create : newChanges) {
       if (create.cmd.getResult() == OK) {
         okToInsert++;
@@ -791,7 +812,7 @@
                 create.cmd.getResult(),
                 Strings.nullToEmpty(create.cmd.getMessage())).trim();
         lastCreateChangeErrors.add(createChangeResult);
-        log.error(String.format("Command %s on %s:%s not completed: %s",
+        logError(String.format("Command %s on %s:%s not completed: %s",
             create.cmd.getType(),
             project.getName(),
             create.cmd.getRefName(),
@@ -799,49 +820,73 @@
       }
     }
 
+    logDebug("Counted {} ok to insert, out of {} to replace and {} new",
+        okToInsert, replaceCount, newChanges.size());
+
     if (okToInsert != replaceCount + newChanges.size()) {
       // One or more new references failed to create. Assume the
       // system isn't working correctly anymore and abort.
       reject(magicBranch.cmd, "Unable to create changes: "
           + Joiner.on(' ').join(lastCreateChangeErrors));
-      log.error(String.format(
+      logError(String.format(
           "Only %d of %d new change refs created in %s; aborting",
           okToInsert, replaceCount + newChanges.size(), project.getName()));
       return;
     }
 
-    try {
-      List<CheckedFuture<?, RestApiException>> futures = Lists.newArrayList();
+    try (BatchUpdate bu = batchUpdateFactory.create(db,
+          magicBranch.dest.getParentKey(), user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter()) {
+      bu.setRepository(repo, rp.getRevWalk(), ins)
+          .updateChangesInParallel();
+      bu.setRequestId(receiveId);
       for (ReplaceRequest replace : replaceByChange.values()) {
         if (replace.inputCommand == magicBranch.cmd) {
-          futures.add(replace.insertPatchSet());
+          replace.addOps(bu, replaceProgress);
         }
       }
 
       for (CreateRequest create : newChanges) {
-        futures.add(create.insertChange());
+        create.addOps(bu);
       }
 
       for (UpdateGroupsRequest update : updateGroups) {
-        futures.add(update.updateGroups());
+        update.addOps(bu);
       }
 
-      for (CheckedFuture<?, RestApiException> f : futures) {
-        f.checkedGet();
+      logDebug("Executing batch");
+      try {
+        bu.execute();
+      } catch (UpdateException e) {
+        throw INSERT_EXCEPTION.apply(e);
       }
       magicBranch.cmd.setResult(OK);
-    } catch (RestApiException err) {
-      log.error("Can't insert change/patchset for " + project.getName()
-          + ". " + err.getMessage(), err);
-
-      String rejection = "internal server error";
-      if (err.getCause() != null) {
-        rejection += ": " + err.getCause().getMessage();
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        String rejectMessage = replace.getRejectMessage();
+        if (rejectMessage != null) {
+          logDebug("Rejecting due to message from ReplaceOp");
+          reject(replace.inputCommand, rejectMessage);
+        }
       }
-      reject(magicBranch.cmd, rejection);
-    } catch (IOException err) {
-      log.error("Can't read commits for " + project.getName(), err);
-      reject(magicBranch.cmd, "internal server error");
+
+    } catch (ResourceConflictException e) {
+      addMessage(e.getMessage());
+      reject(magicBranch.cmd, "conflict");
+    } catch (RestApiException | IOException err) {
+      logError("Can't insert change/patch set for " + project.getName(), err);
+      reject(magicBranch.cmd, "internal server error: " + err.getMessage());
+    }
+
+    if (magicBranch != null && magicBranch.submit) {
+      try {
+        submit(newChanges, replaceByChange.values());
+      } catch (ResourceConflictException e) {
+        addMessage(e.getMessage());
+        reject(magicBranch.cmd, "conflict");
+      } catch (RestApiException | OrmException e) {
+        logError("Error submitting changes to " + project.getName(), e);
+        reject(magicBranch.cmd, "error during submit");
+      }
     }
   }
 
@@ -869,11 +914,12 @@
     return displayName;
   }
 
-  private void parseCommands(final Collection<ReceiveCommand> commands) {
-    for (final ReceiveCommand cmd : commands) {
+  private void parseCommands(Collection<ReceiveCommand> commands) {
+    logDebug("Parsing {} commands", commands.size());
+    for (ReceiveCommand cmd : commands) {
       if (cmd.getResult() != NOT_ATTEMPTED) {
         // Already rejected by the core receive process.
-        //
+        logDebug("Already processed by core: {} {}", cmd.getResult(), cmd);
         continue;
       }
 
@@ -883,29 +929,32 @@
         continue;
       }
 
-      HookResult result = hooks.doRefUpdateHook(project, cmd.getRefName(),
-                              user.getAccount(), cmd.getOldId(),
-                              cmd.getNewId());
-
-      if (result != null) {
-        final String message = result.toString().trim();
-        if (result.getExitValue() != 0) {
-          reject(cmd, message);
-          continue;
-        }
-        rp.sendMessage(message);
-      }
-
       if (MagicBranch.isMagicBranch(cmd.getRefName())) {
         parseMagicBranch(cmd);
         continue;
       }
 
-      final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
+      if (projectControl.getProjectState().isAllUsers()
+          && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+        String newName = RefNames.refsUsers(user.getAccountId());
+        logDebug("Swapping out command for {} to {}",
+            RefNames.REFS_USERS_SELF, newName);
+        final ReceiveCommand orgCmd = cmd;
+        cmd = new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName,
+            cmd.getType()) {
+          @Override
+          public void setResult(Result s, String m) {
+            super.setResult(s, m);
+            orgCmd.setResult(s, m);
+          }
+        };
+      }
+
+      Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
       if (m.matches()) {
         // The referenced change must exist and must still be open.
         //
-        final Change.Id changeId = Change.Id.parse(m.group(1));
+        Change.Id changeId = Change.Id.parse(m.group(1));
         parseReplaceCommand(cmd, changeId);
         continue;
       }
@@ -937,6 +986,7 @@
       }
 
       if (isConfig(cmd)) {
+        logDebug("Processing {} command", cmd.getRefName());
         if (!projectControl.isOwner()) {
           reject(cmd, "not project owner");
           continue;
@@ -948,14 +998,14 @@
           case UPDATE_NONFASTFORWARD:
             try {
               ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(repo, cmd.getNewId());
+              cfg.load(rp.getRevWalk(), cmd.getNewId());
               if (!cfg.getValidationErrors().isEmpty()) {
                 addError("Invalid project configuration:");
                 for (ValidationError err : cfg.getValidationErrors()) {
                   addError("  " + err.getMessage());
                 }
                 reject(cmd, "invalid project configuration");
-                log.error("User " + user.getUserName()
+                logError("User " + user.getUserName()
                     + " tried to push invalid project configuration "
                     + cmd.getNewId().name() + " for " + project.getName());
                 continue;
@@ -989,7 +1039,7 @@
                     projectControl.getProjectState().getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
+                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
                   List<String> l =
                       Arrays.asList(projectControl.getProjectState()
                           .getConfig().getPluginConfig(e.getPluginName())
@@ -1006,7 +1056,7 @@
                   continue;
                 }
 
-                if (ProjectConfigEntry.Type.LIST.equals(configEntry.getType())
+                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
                     && value != null && !configEntry.getPermittedValues().contains(value)) {
                   reject(cmd, String.format(
                       "invalid project configuration: The value '%s' is "
@@ -1016,7 +1066,7 @@
               }
             } catch (Exception e) {
               reject(cmd, "invalid project configuration");
-              log.error("User " + user.getUserName()
+              logError("User " + user.getUserName()
                   + " tried to push invalid project configuration "
                   + cmd.getNewId().name() + " for " + project.getName(), e);
               continue;
@@ -1034,24 +1084,27 @@
     }
   }
 
-  private void parseCreate(final ReceiveCommand cmd) {
+  private void parseCreate(ReceiveCommand cmd) {
     RevObject obj;
     try {
       obj = rp.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      log.error("Invalid object " + cmd.getNewId().name() + " for "
+      logError("Invalid object " + cmd.getNewId().name() + " for "
           + cmd.getRefName() + " creation", err);
       reject(cmd, "invalid object");
       return;
     }
+    logDebug("Creating {}", cmd);
 
     if (isHead(cmd) && !isCommit(cmd)) {
       return;
     }
 
     RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    rp.getRevWalk().reset();
-    if (ctl.canCreate(db, rp.getRevWalk(), obj)) {
+    if (ctl.canCreate(db, rp.getRepository(), obj)) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
       validateNewCommits(ctl, cmd);
       batch.addCommand(cmd);
     } else {
@@ -1059,13 +1112,17 @@
     }
   }
 
-  private void parseUpdate(final ReceiveCommand cmd) {
+  private void parseUpdate(ReceiveCommand cmd) {
+    logDebug("Updating {}", cmd);
     RefControl ctl = projectControl.controlForRef(cmd.getRefName());
     if (ctl.canUpdate()) {
       if (isHead(cmd) && !isCommit(cmd)) {
         return;
       }
 
+      if (!validRefOperation(cmd)) {
+        return;
+      }
       validateNewCommits(ctl, cmd);
       batch.addCommand(cmd);
     } else {
@@ -1078,12 +1135,12 @@
     }
   }
 
-  private boolean isCommit(final ReceiveCommand cmd) {
+  private boolean isCommit(ReceiveCommand cmd) {
     RevObject obj;
     try {
       obj = rp.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      log.error("Invalid object " + cmd.getNewId().name() + " for "
+      logError("Invalid object " + cmd.getNewId().name() + " for "
           + cmd.getRefName(), err);
       reject(cmd, "invalid object");
       return false;
@@ -1091,18 +1148,21 @@
 
     if (obj instanceof RevCommit) {
       return true;
-    } else {
-      reject(cmd, "not a commit");
-      return false;
     }
+    reject(cmd, "not a commit");
+    return false;
   }
 
-  private void parseDelete(final ReceiveCommand cmd) {
+  private void parseDelete(ReceiveCommand cmd) {
+    logDebug("Deleting {}", cmd);
     RefControl ctl = projectControl.controlForRef(cmd.getRefName());
     if (ctl.getRefName().startsWith(REFS_CHANGES)) {
       errors.put(Error.DELETE_CHANGES, ctl.getRefName());
       reject(cmd, "cannot delete changes");
     } else if (ctl.canDelete()) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
       batch.addCommand(cmd);
     } else {
       if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
@@ -1114,18 +1174,19 @@
     }
   }
 
-  private void parseRewind(final ReceiveCommand cmd) {
+  private void parseRewind(ReceiveCommand cmd) {
     RevCommit newObject;
     try {
       newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
     } catch (IncorrectObjectTypeException notCommit) {
       newObject = null;
     } catch (IOException err) {
-      log.error("Invalid object " + cmd.getNewId().name() + " for "
+      logError("Invalid object " + cmd.getNewId().name() + " for "
           + cmd.getRefName() + " forced update", err);
       reject(cmd, "invalid object");
       return;
     }
+    logDebug("Rewinding {}", cmd);
 
     RefControl ctl = projectControl.controlForRef(cmd.getRefName());
     if (newObject != null) {
@@ -1136,6 +1197,9 @@
     }
 
     if (ctl.canForceUpdate()) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
       batch.setAllowNonFastForwards(true).addCommand(cmd);
     } else {
       cmd.setResult(REJECTED_NONFASTFORWARD, " need '"
@@ -1143,7 +1207,7 @@
     }
   }
 
-  private static class MagicBranchInput {
+  static class MagicBranchInput {
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
     final ReceiveCommand cmd;
@@ -1152,6 +1216,7 @@
     Set<Account.Id> reviewer = Sets.newLinkedHashSet();
     Set<Account.Id> cc = Sets.newLinkedHashSet();
     Map<String, Short> labels = new HashMap<>();
+    String message;
     List<RevCommit> baseCommit;
     LabelTypes labelTypes;
     CmdLineParser clp;
@@ -1173,6 +1238,12 @@
     @Option(name = "--submit", usage = "immediately submit the change")
     boolean submit;
 
+    @Option(name = "--notify",
+        usage = "Notify handling that defines to whom email notifications "
+            + "should be sent. Allowed values are NONE, OWNER, "
+            + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
+    NotifyHandling notify = NotifyHandling.ALL;
+
     @Option(name = "--reviewer", aliases = {"-r"}, metaVar = "EMAIL",
         usage = "add reviewer to changes")
     void reviewer(Account.Id id) {
@@ -1191,7 +1262,7 @@
 
     @Option(name = "--label", aliases = {"-l"}, metaVar = "LABEL+VALUE",
         usage = "label(s) to assign (defaults to +1 if no value provided")
-    void addLabel(final String token) throws CmdLineException {
+    void addLabel(String token) throws CmdLineException {
       LabelVote v = LabelVote.parse(token);
       try {
         LabelType.checkName(v.label());
@@ -1202,10 +1273,17 @@
       labels.put(v.label(), v.value());
     }
 
+    @Option(name = "--message", aliases = {"-m"}, metaVar = "MESSAGE",
+        usage = "Comment message to apply to the review")
+    void addMessage(final String token) {
+      // git push does not allow spaces in refs.
+      message = token.replace("_", " ");
+    }
+
     @Option(name = "--hashtag", aliases = {"-t"}, metaVar = "HASHTAG",
         usage = "add hashtag to changes")
     void addHashtag(String token) throws CmdLineException {
-      if (!notesMigration.enabled()) {
+      if (!notesMigration.readChanges()) {
         throw clp.reject("cannot add hashtags; noteDb is disabled");
       }
       String hashtag = cleanupHashtag(token);
@@ -1215,7 +1293,6 @@
       //TODO(dpursehouse): validate hashtags
     }
 
-    @Inject
     MagicBranchInput(ReceiveCommand cmd, LabelTypes labelTypes,
         NotesMigration notesMigration) {
       this.cmd = cmd;
@@ -1270,13 +1347,14 @@
     }
   }
 
-  private void parseMagicBranch(final ReceiveCommand cmd) {
+  private void parseMagicBranch(ReceiveCommand cmd) {
     // Permit exactly one new change request per push.
     if (magicBranch != null) {
       reject(cmd, "duplicate request");
       return;
     }
 
+    logDebug("Found magic branch {}", cmd.getRefName());
     magicBranch = new MagicBranchInput(cmd, labelTypes, notesMigration);
     magicBranch.reviewer.addAll(reviewersFromCommandLine);
     magicBranch.cc.addAll(ccFromCommandLine);
@@ -1288,6 +1366,7 @@
       ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet());
     } catch (CmdLineException e) {
       if (!clp.wasHelpRequestedByOption()) {
+        logDebug("Invalid branch syntax");
         reject(cmd, e.getMessage());
         return;
       }
@@ -1301,7 +1380,13 @@
       reject(cmd, "see help");
       return;
     }
+    if (projectControl.getProjectState().isAllUsers()
+        && RefNames.REFS_USERS_SELF.equals(ref)) {
+      logDebug("Handling {}", RefNames.REFS_USERS_SELF);
+      ref = RefNames.refsUsers(user.getAccountId());
+    }
     if (!rp.getAdvertisedRefs().containsKey(ref) && !ref.equals(readHEAD(repo))) {
+      logDebug("Ref {} not found", ref);
       if (ref.startsWith(Constants.R_HEADS)) {
         String n = ref.substring(Constants.R_HEADS.length());
         reject(cmd, "branch " + n + " not found");
@@ -1352,9 +1437,10 @@
     RevCommit tip;
     try {
       tip = walk.parseCommit(magicBranch.cmd.getNewId());
+      logDebug("Tip of push: {}", tip.name());
     } catch (IOException ex) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      log.error("Invalid pack upload; one or more objects weren't sent", ex);
+      logError("Invalid pack upload; one or more objects weren't sent", ex);
       return;
     }
 
@@ -1363,10 +1449,12 @@
     if (tip.getParentCount() > 1
         || magicBranch.base != null
         || tip.getParentCount() == 0) {
+      logDebug("Forcing newChangeForAllNotInTarget = false");
       newChangeForAllNotInTarget = false;
     }
 
     if (magicBranch.base != null) {
+      logDebug("Handling %base: {}", magicBranch.base);
       magicBranch.baseCommit = Lists.newArrayListWithCapacity(
           magicBranch.base.size());
       for (ObjectId id : magicBranch.base) {
@@ -1379,7 +1467,7 @@
           reject(cmd, "base not found");
           return;
         } catch (IOException e) {
-          log.warn(String.format(
+          logWarn(String.format(
               "Project %s cannot read %s",
               project.getName(), id.name()), e);
           reject(cmd, "internal server error");
@@ -1387,6 +1475,7 @@
         }
       }
     } else if (newChangeForAllNotInTarget) {
+      logDebug("Handling newChangeForAllNotInTarget");
       String destBranch = magicBranch.dest.get();
       try {
         Ref r = repo.getRefDatabase().exactRef(destBranch);
@@ -1398,8 +1487,9 @@
         ObjectId baseHead = r.getObjectId();
         magicBranch.baseCommit =
             Collections.singletonList(walk.parseCommit(baseHead));
+        logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
       } catch (IOException ex) {
-        log.warn(String.format("Project %s cannot read %s", project.getName(),
+        logWarn(String.format("Project %s cannot read %s", project.getName(),
             destBranch), ex);
         reject(cmd, "internal server error");
         return;
@@ -1417,10 +1507,12 @@
         // The destination branch does not yet exist. Assume the
         // history being sent for review will start it and thus
         // is "connected" to the branch.
+        logDebug("Branch is unborn");
         return;
       }
-      final RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      final RevFilter oldRevFilter = walk.getRevFilter();
+      RevCommit h = walk.parseCommit(targetRef.getObjectId());
+      logDebug("Current branch tip: {}", h.name());
+      RevFilter oldRevFilter = walk.getRevFilter();
       try {
         walk.reset();
         walk.setRevFilter(RevFilter.MERGE_BASE);
@@ -1435,7 +1527,7 @@
       }
     } catch (IOException e) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      log.error("Invalid pack upload; one or more objects weren't sent", e);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
     }
   }
 
@@ -1448,31 +1540,33 @@
     }
   }
 
-  private void parseReplaceCommand(final ReceiveCommand cmd,
-      final Change.Id changeId) {
+  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
+    logDebug("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
       reject(cmd, "invalid usage");
       return;
     }
 
-    final RevCommit newCommit;
+    RevCommit newCommit;
     try {
       newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
+      logDebug("Replacing with {}", newCommit);
     } catch (IOException e) {
-      log.error("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
       reject(cmd, "invalid commit");
       return;
     }
 
-    final Change changeEnt;
+    Change changeEnt;
     try {
-      changeEnt = db.changes().get(changeId);
+      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId)
+          .getChange();
     } catch (OrmException e) {
-      log.error("Cannot lookup existing change " + changeId, e);
+      logError("Cannot lookup existing change " + changeId, e);
       reject(cmd, "database error");
       return;
-    }
-    if (changeEnt == null) {
+    } catch (NoSuchChangeException e) {
+      logError("Change not found " + changeId, e);
       reject(cmd, "change " + changeId + " not found");
       return;
     }
@@ -1481,18 +1575,18 @@
       return;
     }
 
+    logDebug("Replacing change {}", changeEnt.getId());
     requestReplace(cmd, true, changeEnt, newCommit);
   }
 
-  private boolean requestReplace(final ReceiveCommand cmd,
-      final boolean checkMergedInto, final Change change,
-      final RevCommit newCommit) {
+  private boolean requestReplace(ReceiveCommand cmd, boolean checkMergedInto,
+      Change change, RevCommit newCommit) {
     if (change.getStatus().isClosed()) {
       reject(cmd, "change " + canonicalWebUrl + change.getId() + " closed");
       return false;
     }
 
-    final ReplaceRequest req =
+    ReplaceRequest req =
         new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
     if (replaceByChange.containsKey(req.ontoChange)) {
       reject(cmd, "duplicate request");
@@ -1503,23 +1597,29 @@
   }
 
   private void selectNewAndReplacedChangesFromMagicBranch() {
-    newChanges = Lists.newArrayList();
+    logDebug("Finding new and replaced changes");
+    newChanges = new ArrayList<>();
 
     SetMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector = new GroupCollector(changeRefsById(), db);
+    GroupCollector groupCollector = GroupCollector.create(changeRefsById(), db, psUtil,
+        notesFactory, project.getNameKey());
 
     rp.getRevWalk().reset();
     rp.getRevWalk().sort(RevSort.TOPO);
     rp.getRevWalk().sort(RevSort.REVERSE, true);
     try {
-      rp.getRevWalk().markStart(
-          rp.getRevWalk().parseCommit(magicBranch.cmd.getNewId()));
+      RevCommit start = rp.getRevWalk().parseCommit(magicBranch.cmd.getNewId());
+      rp.getRevWalk().markStart(start);
       if (magicBranch.baseCommit != null) {
+        logDebug("Marking {} base commits uninteresting",
+            magicBranch.baseCommit.size());
         for (RevCommit c : magicBranch.baseCommit) {
           rp.getRevWalk().markUninteresting(c);
         }
         Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
         if (targetRef != null) {
+          logDebug("Marking target ref {} ({}) uninteresting",
+              magicBranch.ctl.getRefName(), targetRef.getObjectId().name());
           rp.getRevWalk().markUninteresting(
               rp.getRevWalk().parseCommit(targetRef.getObjectId()));
         }
@@ -1529,18 +1629,41 @@
             magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
       }
 
-      List<ChangeLookup> pending = Lists.newArrayList();
-      final Set<Change.Key> newChangeIds = new HashSet<>();
-      final int maxBatchChanges =
+      List<ChangeLookup> pending = new ArrayList<>();
+      Set<Change.Key> newChangeIds = new HashSet<>();
+      int maxBatchChanges =
           receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+      int total = 0;
+      int alreadyTracked = 0;
+      boolean rejectImplicitMerges = start.getParentCount() == 1
+          && projectCache.get(project.getNameKey()).isRejectImplicitMerges();
+      Set<RevCommit> mergedParents;
+      if (rejectImplicitMerges) {
+        mergedParents = new HashSet<>();
+      } else {
+        mergedParents = null;
+      }
+
       for (;;) {
-        final RevCommit c = rp.getRevWalk().next();
+        RevCommit c = rp.getRevWalk().next();
         if (c == null) {
           break;
         }
+        total++;
+        rp.getRevWalk().parseBody(c);
+        String name = c.name();
         groupCollector.visit(c);
         Collection<Ref> existingRefs = existing.get(c);
+
+        if (rejectImplicitMerges) {
+          for (RevCommit p : c.getParents()) {
+            mergedParents.add(p);
+          }
+          mergedParents.remove(c);
+        }
+
         if (!existingRefs.isEmpty()) { // Commit is already tracked.
+          alreadyTracked++;
           // Corner cases where an existing commit might need a new group:
           // A) Existing commit has a null group; wasn't assigned during schema
           //    upgrade, or schema upgrade is performed on a running server.
@@ -1560,11 +1683,15 @@
           if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
             continue;
           }
+          logDebug("Creating new change for {} even though it is already tracked",
+              name);
         }
 
-        if (!validCommit(magicBranch.ctl, magicBranch.cmd, c)) {
+        if (!validCommit(
+            rp.getRevWalk(), magicBranch.ctl, magicBranch.cmd, c)) {
           // Not a change the user can propose? Abort as early as possible.
           newChanges = Collections.emptyList();
+          logDebug("Aborting early due to invalid commit");
           return;
         }
 
@@ -1573,27 +1700,22 @@
           reject(magicBranch.cmd,
               "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
             + "to override please set the base manually");
+          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget",
+              name);
+          // TODO(dborowitz): Should we early return here?
         }
 
-        Change.Key changeKey = new Change.Key("I" + c.name());
-        final List<String> idList = c.getFooterLines(CHANGE_ID);
+        List<String> idList = c.getFooterLines(CHANGE_ID);
         if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(magicBranch.ctl, c, changeKey));
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
           continue;
         }
 
-        final String idStr = idList.get(idList.size() - 1).trim();
-        if (idStr.matches("^I00*$")) {
-          // Reject this invalid line from EGit.
-          reject(magicBranch.cmd, "invalid Change-Id");
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        changeKey = new Change.Key(idStr);
-        pending.add(new ChangeLookup(c, changeKey));
-        if (maxBatchChanges != 0
-            && pending.size() + newChanges.size() > maxBatchChanges) {
+        String idStr = idList.get(idList.size() - 1).trim();
+        pending.add(new ChangeLookup(c, new Change.Key(idStr)));
+        int n = pending.size() + newChanges.size();
+        if (maxBatchChanges != 0 && n > maxBatchChanges) {
+          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
           reject(magicBranch.cmd,
               "the number of pushed changes in a batch exceeds the max limit "
                   + maxBatchChanges);
@@ -1601,17 +1723,35 @@
           return;
         }
       }
+      logDebug("Finished initial RevWalk with {} commits total: {} already"
+          + " tracked, {} new changes with no Change-Id, and {} deferred"
+          + " lookups", total, alreadyTracked, newChanges.size(),
+          pending.size());
+
+      if (rejectImplicitMerges) {
+        rejectImplicitMerges(mergedParents);
+      }
 
       for (Iterator<ChangeLookup> itr = pending.iterator(); itr.hasNext();) {
         ChangeLookup p = itr.next();
         if (newChangeIds.contains(p.changeKey)) {
-          reject(magicBranch.cmd, "squash commits first");
+          logDebug("Multiple commits with Change-Id {}", p.changeKey);
+          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           newChanges = Collections.emptyList();
           return;
         }
 
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
+          logDebug("Multiple changes in project with Change-Id {}: {}",
+              p.changeKey, Lists.transform(
+                  changes,
+                  new Function<ChangeData, String>() {
+                    @Override
+                    public String apply(ChangeData in) {
+                      return in.getId().toString();
+                    }
+                  }));
           // WTF, multiple changes in this project have the same key?
           // Since the commit is new, the user should recreate it with
           // a different Change-Id. In practice, we should never see
@@ -1643,10 +1783,9 @@
           if (requestReplace(
               magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
             continue;
-          } else {
-            newChanges = Collections.emptyList();
-            return;
           }
+          newChanges = Collections.emptyList();
+          return;
         }
 
         if (changes.size() == 0) {
@@ -1658,18 +1797,20 @@
 
           newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(magicBranch.ctl, p.commit, p.changeKey));
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
       }
+      logDebug("Finished deferred lookups with {} updates and {} new changes",
+          replaceByChange.size(), newChanges.size());
     } catch (IOException e) {
       // Should never happen, the core receive process would have
       // identified the missing object earlier before we got control.
       //
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      log.error("Invalid pack upload; one or more objects weren't sent", e);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
       newChanges = Collections.emptyList();
       return;
     } catch (OrmException e) {
-      log.error("Cannot query database to locate prior changes", e);
+      logError("Cannot query database to locate prior changes", e);
       reject(magicBranch.cmd, "database error");
       newChanges = Collections.emptyList();
       return;
@@ -1685,36 +1826,75 @@
     }
 
     try {
-      Multimap<ObjectId, String> groups = groupCollector.getGroups();
-      for (CreateRequest create : newChanges) {
+      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+      for (int i = 0; i < newChanges.size(); i++) {
+        CreateRequest create = newChanges.get(i);
+        create.setChangeId(newIds.get(i));
         batch.addCommand(create.cmd);
-        create.groups = groups.get(create.commit);
+        create.groups = ImmutableList.copyOf(groups.get(create.commit));
       }
       for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.groups = groups.get(replace.newCommit);
+        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
       }
       for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = Sets.newHashSet(groups.get(update.commit));
+        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
       }
-    } catch (OrmException e) {
-      log.error("Error collecting groups for changes", e);
+      logDebug("Finished updating groups from GroupCollector");
+    } catch (OrmException | NoSuchChangeException e) {
+      logError("Error collecting groups for changes", e);
       reject(magicBranch.cmd, "internal server error");
       return;
     }
   }
 
+  private void rejectImplicitMerges(Set<RevCommit> mergedParents)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    if (!mergedParents.isEmpty()) {
+      Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
+      if (targetRef != null) {
+        RevWalk rw = rp.getRevWalk();
+        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+        boolean containsImplicitMerges = true;
+        for (RevCommit p : mergedParents) {
+          containsImplicitMerges &= !rw.isMergedInto(p, tip);
+        }
+
+        if (containsImplicitMerges) {
+          rw.reset();
+          for (RevCommit p : mergedParents) {
+            rw.markStart(p);
+          }
+          rw.markUninteresting(tip);
+          RevCommit c;
+          while ((c = rw.next()) != null) {
+            rw.parseBody(c);
+            messages.add(new CommitValidationMessage(
+                "ERROR: Implicit Merge of " + c.abbreviate(7).name()
+                + " " + c.getShortMessage(), false));
+
+          }
+          reject(magicBranch.cmd, "implicit merges detected");
+        }
+      }
+    }
+  }
+
   private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
+    int i = 0;
     for (Ref ref : allRefs.values()) {
       if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
           && ref.getObjectId() != null) {
         try {
           rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+          i++;
         } catch (IOException e) {
-          log.warn(String.format("Invalid ref %s in %s",
+          logWarn(String.format("Invalid ref %s in %s",
               ref.getName(), project.getName()), e);
         }
       }
     }
+    logDebug("Marked {} heads as uninteresting", i);
   }
 
   private static boolean isValidChangeId(String idStr) {
@@ -1735,127 +1915,121 @@
 
   private class CreateRequest {
     final RevCommit commit;
-    final Change change;
-    final ReceiveCommand cmd;
-    final ChangeInserter ins;
-    boolean created;
-    Collection<String> groups;
+    private final String refName;
 
-    CreateRequest(RefControl ctl, RevCommit c, Change.Key changeKey)
-        throws OrmException {
-      commit = c;
-      change = new Change(changeKey,
-          new Change.Id(db.nextChangeId()),
-          user.getAccountId(),
-          magicBranch.dest,
-          TimeUtil.nowTs());
-      change.setTopic(magicBranch.topic);
-      ins = changeInserterFactory.create(ctl, change, c)
+    Change.Id changeId;
+    ReceiveCommand cmd;
+    ChangeInserter ins;
+    List<String> groups = ImmutableList.of();
+
+    Change change;
+
+    CreateRequest(RevCommit commit, String refName) {
+      this.commit = commit;
+      this.refName = refName;
+    }
+
+    private void setChangeId(int id) {
+      changeId = new Change.Id(id);
+      ins = changeInserterFactory.create(changeId, commit, refName)
           .setDraft(magicBranch.draft)
+          .setTopic(magicBranch.topic)
           // Changes already validated in validateNewCommits.
           .setValidatePolicy(CommitValidators.Policy.NONE);
-      cmd = new ReceiveCommand(ObjectId.zeroId(), c,
-          ins.getPatchSet().getRefName());
+      cmd = new ReceiveCommand(ObjectId.zeroId(), commit,
+          ins.getPatchSetId().toRefName());
       ins.setUpdateRefCommand(cmd);
       if (rp.getPushCertificate() != null) {
         ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
       }
     }
 
-    CheckedFuture<Void, RestApiException> insertChange() throws IOException {
-      rp.getRevWalk().parseBody(commit);
-
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<Void> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<Void>() {
-        @Override
-        public Void call()
-            throws OrmException, RestApiException, UpdateException {
-          if (caller == Thread.currentThread()) {
-            insertChange(ReceiveCommits.this.db);
-          } else {
-            try (ReviewDb threadLocalDb = schemaFactory.open()) {
-              insertChange(threadLocalDb);
-            }
-          }
-          synchronized (newProgress) {
-            newProgress.update(1);
-          }
-          return null;
-        }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
-    }
-
-    private void insertChange(ReviewDb threadLocalDb)
-        throws OrmException, RestApiException, UpdateException {
-      final PatchSet ps = ins.setGroups(groups).getPatchSet();
-      final Account.Id me = user.getAccountId();
-      final List<FooterLine> footerLines = commit.getFooterLines();
-      final MailRecipients recipients = new MailRecipients();
-      Map<String, Short> approvals = new HashMap<>();
-      if (magicBranch != null) {
+    private void addOps(BatchUpdate bu) throws RestApiException {
+      checkState(changeId != null, "must call setChangeId before addOps");
+      try {
+        RevWalk rw = rp.getRevWalk();
+        rw.parseBody(commit);
+        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+        Account.Id me = user.getAccountId();
+        List<FooterLine> footerLines = commit.getFooterLines();
+        MailRecipients recipients = new MailRecipients();
+        Map<String, Short> approvals = new HashMap<>();
+        checkNotNull(magicBranch);
         recipients.add(magicBranch.getMailRecipients());
         approvals = magicBranch.labels;
-      }
-      recipients.add(getRecipientsFromFooters(accountResolver, ps, footerLines));
-      recipients.remove(me);
-      String msg = renderMessageWithApprovals(ps.getPatchSetId(), null,
-          approvals, Collections.<String, PatchSetApproval> emptyMap());
-      try (ObjectInserter oi = repo.newObjectInserter();
-          BatchUpdate bu = batchUpdateFactory.create(threadLocalDb,
-            change.getProject(), user, change.getCreatedOn())) {
-        bu.setRepository(repo, rp.getRevWalk(), oi);
+        recipients.add(getRecipientsFromFooters(
+            db, accountResolver, magicBranch.draft, footerLines));
+        recipients.remove(me);
+        StringBuilder msg = new StringBuilder(
+            ApprovalsUtil.renderMessageWithApprovals(
+                psId.get(), approvals,
+                Collections.<String, PatchSetApproval> emptyMap()));
+        msg.append('.');
+        if (!Strings.isNullOrEmpty(magicBranch.message)) {
+          msg.append("\n").append(magicBranch.message);
+        }
+
         bu.insertChange(ins
             .setReviewers(recipients.getReviewers())
             .setExtraCC(recipients.getCcOnly())
             .setApprovals(approvals)
-            .setMessage(msg)
+            .setMessage(msg.toString())
+            .setNotify(magicBranch.notify)
             .setRequestScopePropagator(requestScopePropagator)
             .setSendMail(true)
             .setUpdateRef(true));
-        if (magicBranch != null) {
+        if (!magicBranch.hashtags.isEmpty()) {
           bu.addOp(
-              ins.getChange().getId(),
+              changeId,
               hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags))
-                .setRunHooks(false));
+                .setFireEvent(false));
         }
-        bu.execute();
-      }
-      created = true;
-
-      if (magicBranch != null && magicBranch.submit) {
-        submit(projectControl.controlFor(change), ps);
+        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+          bu.addOp(
+              changeId,
+              new BatchUpdate.Op() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) {
+                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
+                  return true;
+                }
+              });
+        }
+        bu.addOp(changeId, new BatchUpdate.Op() {
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            change = ctx.getChange();
+            return false;
+          }
+        });
+        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+      } catch (Exception e) {
+        throw INSERT_EXCEPTION.apply(e);
       }
     }
   }
 
-  private void submit(ChangeControl changeCtl, PatchSet ps)
-      throws OrmException, ResourceConflictException {
-    Submit submit = submitProvider.get();
-    RevisionResource rsrc = new RevisionResource(changes.parse(changeCtl), ps);
-    try {
-      mergeOpProvider.get().merge(db, rsrc.getChange(),
-          changeCtl.getUser().asIdentifiedUser(), false);
-    } catch (NoSuchChangeException e) {
-      throw new OrmException(e);
+  private void submit(
+      Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
+      throws OrmException, RestApiException {
+    Map<ObjectId, Change> bySha =
+        Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+    for (CreateRequest r : create) {
+      checkNotNull(r.change,
+          "cannot submit new change %s; op may not have run", r.changeId);
+      bySha.put(r.commit, r.change);
     }
-    addMessage("");
-    Change c = db.changes().get(rsrc.getChange().getId());
-    switch (c.getStatus()) {
-      case MERGED:
-        addMessage("Change " + c.getChangeId() + " merged.");
-        break;
-      case NEW:
-        ChangeMessage msg = submit.getConflictMessage(rsrc);
-        if (msg != null) {
-          addMessage("Change " + c.getChangeId() + ": " + msg.getMessage());
-          break;
-        }
-        //$FALL-THROUGH$
-      default:
-        addMessage("change " + c.getChangeId() + " is "
-            + c.getStatus().name().toLowerCase());
+    for (ReplaceRequest r : replace) {
+      bySha.put(r.newCommitId, r.notes.getChange());
+    }
+    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
+    checkNotNull(tipChange,
+        "tip of push does not correspond to a change; found these changes: %s",
+        bySha);
+    logDebug("Processing submit with tip change {} ({})",
+        tipChange.getId(), magicBranch.cmd.getNewId());
+    try (MergeOp op  = mergeOpProvider.get()) {
+      op.merge(db, tipChange, user, false, new SubmitInput());
     }
   }
 
@@ -1873,7 +2047,7 @@
         }
       }
     } catch (OrmException err) {
-      log.error(String.format(
+      logError(String.format(
           "Cannot read database before replacement for project %s",
           project.getName()), err);
       for (ReplaceRequest req : replaceByChange.values()) {
@@ -1882,7 +2056,7 @@
         }
       }
     } catch (IOException err) {
-      log.error(String.format(
+      logError(String.format(
           "Cannot read repository before replacement for project %s",
           project.getName()), err);
       for (ReplaceRequest req : replaceByChange.values()) {
@@ -1891,6 +2065,7 @@
         }
       }
     }
+    logDebug("Read {} changes to replace", replaceByChange.size());
 
     for (ReplaceRequest req : replaceByChange.values()) {
       if (req.inputCommand.getResult() == NOT_ATTEMPTED && req.cmd != null) {
@@ -1915,67 +2090,43 @@
   }
 
   private void readChangesForReplace() throws OrmException {
-    List<CheckedFuture<Change, OrmException>> futures =
-        Lists.newArrayListWithCapacity(replaceByChange.size());
-    for (ReplaceRequest request : replaceByChange.values()) {
-      futures.add(db.changes().getAsync(request.ontoChange));
+    Collection<ChangeNotes> allNotes =
+        notesFactory.create(
+            db,
+            Collections2.transform(
+                replaceByChange.values(),
+                new Function<ReplaceRequest, Change.Id>() {
+                  @Override
+                  public Change.Id apply(ReplaceRequest in) {
+                    return in.ontoChange;
+                  }
+                }));
+    for (ChangeNotes notes : allNotes) {
+      replaceByChange.get(notes.getChangeId()).notes = notes;
     }
-    for (CheckedFuture<Change, OrmException> f : futures) {
-      Change c = f.checkedGet();
-      if (c != null) {
-        replaceByChange.get(c.getId()).change = c;
-      }
-    }
-  }
-
-  private String renderMessageWithApprovals(int patchSetId, String suffix,
-      Map<String, Short> n, Map<String, PatchSetApproval> c) {
-    StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
-    if (!n.isEmpty()) {
-      boolean first = true;
-      for (Map.Entry<String, Short> e : n.entrySet()) {
-        if (c.containsKey(e.getKey())
-            && c.get(e.getKey()).getValue() == e.getValue()) {
-          continue;
-        }
-        if (first) {
-          msgs.append(":");
-          first = false;
-        }
-        msgs.append(" ")
-            .append(LabelVote.create(e.getKey(), e.getValue()).format());
-      }
-    }
-
-    if (!Strings.isNullOrEmpty(suffix)) {
-      msgs.append(suffix);
-    }
-
-    return msgs.append('.').toString();
   }
 
   private class ReplaceRequest {
     final Change.Id ontoChange;
-    final RevCommit newCommit;
+    final ObjectId newCommitId;
     final ReceiveCommand inputCommand;
     final boolean checkMergedInto;
-    Change change;
+    ChangeNotes notes;
     ChangeControl changeCtl;
     BiMap<RevCommit, PatchSet.Id> revisions;
-    PatchSet newPatchSet;
+    PatchSet.Id psId;
     ReceiveCommand prev;
     ReceiveCommand cmd;
     PatchSetInfo info;
-    ChangeMessage msg;
-    String mergedIntoRef;
     boolean skip;
     private PatchSet.Id priorPatchSet;
-    Collection<String> groups;
+    List<String> groups = ImmutableList.of();
+    private ReplaceOp replaceOp;
 
-    ReplaceRequest(final Change.Id toChange, final RevCommit newCommit,
-        final ReceiveCommand cmd, final boolean checkMergedInto) {
+    ReplaceRequest(Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd,
+        boolean checkMergedInto) {
       this.ontoChange = toChange;
-      this.newCommit = newCommit;
+      this.newCommitId = newCommit.copy();
       this.inputCommand = cmd;
       this.checkMergedInto = checkMergedInto;
 
@@ -1986,34 +2137,57 @@
               rp.getRevWalk().parseCommit(ref.getObjectId()),
               PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          log.warn(String.format(
+          logWarn(String.format(
               "Project %s contains invalid change ref %s",
               project.getName(), ref.getName()), err);
         }
       }
     }
 
-    boolean validate(boolean autoClose) throws IOException {
+    /**
+     * Validate the new patch set commit for this change.
+     * <p>
+     * <strong>Side effects:</strong>
+     * <ul>
+     *   <li>May add error or warning messages to the progress monitor</li>
+     *   <li>Will reject {@code cmd} prior to returning false</li>
+     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a
+     *     walk.</li>
+     * </ul>
+     *
+     * @param autoClose whether the caller intends to auto-close the change
+     *     after adding a new patch set.
+     * @return whether the new commit is valid
+     * @throws IOException
+     * @throws OrmException
+     */
+    boolean validate(boolean autoClose) throws IOException, OrmException {
       if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
         return false;
-      } else if (change == null) {
+      } else if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
 
-      priorPatchSet = change.currentPatchSetId();
+      priorPatchSet = notes.getChange().currentPatchSetId();
       if (!revisions.containsValue(priorPatchSet)) {
         reject(inputCommand, "change " + ontoChange + " missing revisions");
         return false;
       }
 
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
 
-      changeCtl = projectControl.controlFor(change);
-      if (!changeCtl.canAddPatchSet()) {
-        reject(inputCommand, "cannot replace " + ontoChange);
+      changeCtl = projectControl.controlFor(notes);
+      if (!changeCtl.canAddPatchSet(db)) {
+        String locked = ".";
+        if (changeCtl.isPatchSetLocked(db)) {
+          locked = ". Change is patch set locked.";
+        }
+        reject(inputCommand, "cannot add patch set to "
+            + ontoChange + locked);
         return false;
-      } else if (change.getStatus().isClosed()) {
+      } else if (notes.getChange().getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
       } else if (revisions.containsKey(newCommit)) {
@@ -2021,7 +2195,7 @@
         return false;
       }
 
-      for (final Ref r : rp.getRepository().getRefDatabase()
+      for (Ref r : rp.getRepository().getRefDatabase()
           .getRefs("refs/changes").values()) {
         if (r.getObjectId().equals(newCommit)) {
           reject(inputCommand, "commit already exists (in the project)");
@@ -2034,13 +2208,13 @@
         // very common error due to users making a new commit rather than
         // amending when trying to address review comments.
         if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
-          reject(inputCommand, "squash commits first");
+          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           return false;
         }
       }
 
-      rp.getRevWalk().parseBody(newCommit);
-      if (!validCommit(changeCtl.getRefControl(), inputCommand, newCommit)) {
+      if (!validCommit(rp.getRevWalk(), changeCtl.getRefControl(), inputCommand,
+            newCommit)) {
         return false;
       }
       rp.getRevWalk().parseBody(priorCommit);
@@ -2048,23 +2222,21 @@
       // Don't allow the same tree if the commit message is unmodified
       // or no parents were updated (rebase), else warn that only part
       // of the commit was modified.
-      if (newCommit.getTree() == priorCommit.getTree()) {
-        final boolean messageEq =
+      if (newCommit.getTree().equals(priorCommit.getTree())) {
+        boolean messageEq =
             eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        final boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        final boolean authorEq = authorEqual(newCommit, priorCommit);
-        final ObjectReader reader = rp.getRevWalk().getObjectReader();
+        boolean parentsEq = parentsEqual(newCommit, priorCommit);
+        boolean authorEq = authorEqual(newCommit, priorCommit);
+        ObjectReader reader = rp.getRevWalk().getObjectReader();
 
         if (messageEq && parentsEq && authorEq && !autoClose) {
           addMessage(String.format(
               "(W) No changes between prior commit %s and new commit %s",
               reader.abbreviate(priorCommit).name(),
               reader.abbreviate(newCommit).name()));
-          reject(inputCommand, "no changes made");
-          return false;
         } else {
           StringBuilder msg = new StringBuilder();
-          msg.append("(W) ");
+          msg.append("(I) ");
           msg.append(reader.abbreviate(newCommit).name());
           msg.append(":");
           msg.append(" no files changed");
@@ -2090,22 +2262,22 @@
     }
 
     private boolean newEdit() {
-      newPatchSet = new PatchSet(change.currentPatchSetId());
+      psId = notes.getChange().currentPatchSetId();
       Optional<ChangeEdit> edit = null;
 
       try {
-        edit = editUtil.byChange(change, user);
-      } catch (IOException e) {
-        log.error("Cannt retrieve edit", e);
+        edit = editUtil.byChange(changeCtl);
+      } catch (AuthException | IOException e) {
+        logError("Cannot retrieve edit", e);
         return false;
       }
 
       if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(newPatchSet.getId())) {
+        if (edit.get().getBasePatchSet().getId().equals(psId)) {
           // replace edit
           cmd = new ReceiveCommand(
               edit.get().getRef().getObjectId(),
-              newCommit,
+              newCommitId,
               edit.get().getRefName());
         } else {
           // delete old edit ref on rebase
@@ -2126,344 +2298,99 @@
       // create new edit
       cmd = new ReceiveCommand(
           ObjectId.zeroId(),
-          newCommit,
+          newCommitId,
           RefNames.refsEdit(
               user.getAccountId(),
-              change.getId(),
-              newPatchSet.getId()));
+              notes.getChangeId(),
+              psId));
     }
 
     private void newPatchSet() throws IOException {
-      PatchSet.Id id =
-          ChangeUtil.nextPatchSetId(allRefs, change.currentPatchSetId());
-      newPatchSet = new PatchSet(id);
-      newPatchSet.setCreatedOn(TimeUtil.nowTs());
-      newPatchSet.setUploader(user.getAccountId());
-      newPatchSet.setRevision(toRevId(newCommit));
-      newPatchSet.setGroups(groups);
-      if (rp.getPushCertificate() != null) {
-        newPatchSet.setPushCertificate(
-            rp.getPushCertificate().toTextWithSignature());
-      }
-      if (magicBranch != null && magicBranch.draft) {
-        newPatchSet.setDraft(true);
-      }
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      psId = ChangeUtil.nextPatchSetId(
+          allRefs, notes.getChange().currentPatchSetId());
       info = patchSetInfoFactory.get(
-          rp.getRevWalk(), newCommit, newPatchSet.getId());
+          rp.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(
           ObjectId.zeroId(),
-          newCommit,
-          newPatchSet.getRefName());
+          newCommitId,
+          psId.toRefName());
     }
 
-    CheckedFuture<PatchSet.Id, RestApiException> insertPatchSet()
+    void addOps(BatchUpdate bu, @Nullable Task progress)
         throws IOException {
-      rp.getRevWalk().parseBody(newCommit);
-
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<PatchSet.Id> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<PatchSet.Id>() {
-        @Override
-        public PatchSet.Id call() throws OrmException, IOException,
-            ResourceConflictException {
-          try {
-            if (magicBranch != null && magicBranch.edit) {
-              return upsertEdit();
-            } else if (caller == Thread.currentThread()) {
-              return insertPatchSet(db);
-            } else {
-              try (ReviewDb db = schemaFactory.open()) {
-                return insertPatchSet(db);
-              }
-            }
-          } catch (OrmException | IOException  e) {
-            log.error("Failed to insert patch set", e);
-            throw e;
-          } finally {
-            synchronized (replaceProgress) {
-              replaceProgress.update(1);
-            }
-          }
-        }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
-    }
-
-    private ChangeMessage newChangeMessage(ReviewDb db, ChangeKind changeKind,
-        Map<String, Short> approvals)
-        throws OrmException {
-      msg =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(), ChangeUtil
-              .messageUUID(db)), user.getAccountId(), newPatchSet.getCreatedOn(),
-              newPatchSet.getId());
-
-      msg.setMessage(renderMessageWithApprovals(newPatchSet.getPatchSetId(),
-          changeKindMessage(changeKind), approvals, scanLabels(db, approvals)));
-
-      return msg;
-    }
-
-    private String changeKindMessage(ChangeKind changeKind) {
-      switch (changeKind) {
-        case TRIVIAL_REBASE:
-        case NO_CHANGE:
-          return ": Patch Set " + priorPatchSet.get() + " was rebased";
-        case NO_CODE_CHANGE:
-          return ": Commit message was updated";
-        case REWORK:
-        default:
-          return null;
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(ReviewDb db,
-        Map<String, Short> approvals)
-        throws OrmException {
-      Map<String, PatchSetApproval> current = new HashMap<>();
-      // We optimize here and only retrieve current when approvals provided
-      if (!approvals.isEmpty()) {
-        for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-            db, changeCtl, priorPatchSet, user.getAccountId())) {
-          if (a.isSubmit()) {
-            continue;
-          }
-
-          LabelType lt = labelTypes.byLabel(a.getLabelId());
-          if (lt != null) {
-            current.put(lt.getName(), a);
-          }
-        }
-      }
-      return current;
-    }
-
-    PatchSet.Id upsertEdit() {
       if (cmd.getResult() == NOT_ATTEMPTED) {
+        // TODO(dborowitz): When does this happen? Only when an edit ref is
+        // involved?
         cmd.execute(rp);
       }
-      return newPatchSet.getId();
+      if (magicBranch != null && magicBranch.edit) {
+        return;
+      }
+      RevWalk rw = rp.getRevWalk();
+      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+      rw.parseBody(newCommit);
+
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      replaceOp = replaceOpFactory.create(requestScopePropagator,
+          projectControl, notes.getChange().getDest(), checkMergedInto,
+          priorPatchSet, priorCommit, psId, newCommit, info, groups,
+          magicBranch, rp.getPushCertificate());
+      bu.addOp(notes.getChangeId(), replaceOp);
+      if (progress != null) {
+        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+      }
     }
 
-    PatchSet.Id insertPatchSet(ReviewDb db) throws OrmException, IOException,
-        ResourceConflictException {
-      final Account.Id me = user.getAccountId();
-      final List<FooterLine> footerLines = newCommit.getFooterLines();
-      final MailRecipients recipients = new MailRecipients();
-      Map<String, Short> approvals = new HashMap<>();
-      ChangeUpdate update = updateFactory.create(
-          changeCtl, newPatchSet.getCreatedOn());
-      update.setPatchSetId(newPatchSet.getId());
-
-      if (magicBranch != null) {
-        recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
-        Set<String> hashtags = magicBranch.hashtags;
-        if (!hashtags.isEmpty()) {
-          ChangeNotes notes = changeCtl.getNotes().load();
-          hashtags.addAll(notes.getHashtags());
-          update.setHashtags(hashtags);
-        }
+    void insertPatchSetWithoutBatchUpdate()
+        throws IOException, UpdateException, RestApiException {
+      try (BatchUpdate bu = batchUpdateFactory.create(db,
+            projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter()) {
+        bu.setRepository(repo, rp.getRevWalk(), ins);
+        bu.setRequestId(receiveId);
+        addOps(bu, replaceProgress);
+        bu.execute();
       }
-      recipients.add(getRecipientsFromFooters(accountResolver, newPatchSet, footerLines));
-      recipients.remove(me);
+    }
 
-      db.changes().beginTransaction(change.getId());
-      ChangeKind changeKind = ChangeKind.REWORK;
-      try {
-        change = db.changes().get(change.getId());
-        if (change == null || change.getStatus().isClosed()) {
-          reject(inputCommand, "change is closed");
-          return null;
-        }
-
-        if (newPatchSet.getGroups() == null) {
-          newPatchSet.setGroups(GroupCollector.getCurrentGroups(db, change));
-        }
-        db.patchSets().insert(Collections.singleton(newPatchSet));
-
-        if (checkMergedInto) {
-          final Ref mergedInto = findMergedInto(change.getDest().get(), newCommit);
-          mergedIntoRef = mergedInto != null ? mergedInto.getName() : null;
-        }
-
-        ChangeData cd = changeDataFactory.create(db, changeCtl);
-        MailRecipients oldRecipients =
-            getRecipientsFromReviewers(cd.reviewers());
-        approvalCopier.copy(db, changeCtl, newPatchSet);
-        approvalsUtil.addReviewers(db, update, labelTypes, change, newPatchSet,
-            info, recipients.getReviewers(), oldRecipients.getAll());
-        approvalsUtil.addApprovals(db, update, labelTypes, newPatchSet,
-            changeCtl, approvals);
-        recipients.add(oldRecipients);
-
-        RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-        changeKind = changeKindCache.getChangeKind(
-            projectControl.getProjectState(), repo, priorCommit, newCommit);
-
-        cmUtil.addChangeMessage(db, update, newChangeMessage(db, changeKind,
-            approvals));
-
-        if (mergedIntoRef == null) {
-          // Change should be new, so it can go through review again.
-          //
-          change =
-              db.changes().atomicUpdate(change.getId(), new AtomicUpdate<Change>() {
-                @Override
-                public Change update(Change change) {
-                  if (change.getStatus().isClosed()) {
-                    return null;
-                  }
-
-                  if (!change.currentPatchSetId().equals(priorPatchSet)) {
-                    return change;
-                  }
-
-                  if (magicBranch != null && magicBranch.topic != null) {
-                    change.setTopic(magicBranch.topic);
-                  }
-                  if (change.getStatus() == Change.Status.DRAFT && newPatchSet.isDraft()) {
-                    // Leave in draft status.
-                  } else {
-                    change.setStatus(Change.Status.NEW);
-                  }
-                  change.setCurrentPatchSet(info);
-
-                  final List<String> idList = newCommit.getFooterLines(CHANGE_ID);
-                  if (idList.isEmpty()) {
-                    change.setKey(new Change.Key("I" + newCommit.name()));
-                  } else {
-                    change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
-                  }
-
-                  ChangeUtil.updated(change);
-                  return change;
-                }
-              });
-          if (change == null) {
-            db.patchSets().delete(Collections.singleton(newPatchSet));
-            db.changeMessages().delete(Collections.singleton(msg));
-            reject(inputCommand, "change is closed");
-            return null;
-          }
-        }
-
-        db.commit();
-      } finally {
-        db.rollback();
-      }
-      update.commit();
-
-      if (mergedIntoRef != null) {
-        // Change was already submitted to a branch, close it.
-        //
-        markChangeMergedByPush(db, this, changeCtl);
-      }
-
-      if (cmd.getResult() == NOT_ATTEMPTED) {
-        cmd.execute(rp);
-      }
-      indexer.index(db, change);
-      if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-        sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
-          @Override
-          public void run() {
-            try {
-              ReplacePatchSetSender cm =
-                  replacePatchSetFactory.create(change.getId());
-              cm.setFrom(me);
-              cm.setPatchSet(newPatchSet, info);
-              cm.setChangeMessage(msg);
-              cm.addReviewers(recipients.getReviewers());
-              cm.addExtraCC(recipients.getCcOnly());
-              cm.send();
-            } catch (Exception e) {
-              log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
-            }
-            if (mergedIntoRef != null) {
-              sendMergedEmail(ReplaceRequest.this);
-            }
-          }
-
-          @Override
-          public String toString() {
-            return "send-email newpatchset";
-          }
-        }));
-      }
-
-      gitRefUpdated.fire(project.getNameKey(), newPatchSet.getRefName(),
-          ObjectId.zeroId(), newCommit);
-      hooks.doPatchsetCreatedHook(change, newPatchSet, db);
-      if (mergedIntoRef != null) {
-        hooks.doChangeMergedHook(
-            change, user.getAccount(), newPatchSet, db, newCommit.getName());
-      }
-
-      if (!approvals.isEmpty()) {
-        hooks.doCommentAddedHook(change, user.getAccount(), newPatchSet,
-            null, approvals, db);
-      }
-
-      if (magicBranch != null && magicBranch.submit) {
-        submit(changeCtl, newPatchSet);
-      }
-
-      return newPatchSet.getId();
+    String getRejectMessage() {
+      return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
   }
 
   private class UpdateGroupsRequest {
     private final PatchSet.Id psId;
     private final RevCommit commit;
-    Set<String> groups;
+    List<String> groups = ImmutableList.of();
 
     UpdateGroupsRequest(Ref ref, RevCommit commit) {
       this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
       this.commit = commit;
     }
 
-    private void updateGroups(ReviewDb db) throws OrmException, IOException {
-      PatchSet ps = db.patchSets().atomicUpdate(psId,
-          new AtomicUpdate<PatchSet>() {
-            @Override
-            public PatchSet update(PatchSet ps) {
-              List<String> oldGroups = ps.getGroups();
-              if (oldGroups == null) {
-                if (groups == null) {
-                  return null;
-                }
-              } else if (Sets.newHashSet(oldGroups).equals(groups)) {
-                return null;
-              }
-              ps.setGroups(groups);
-              return ps;
+    private void addOps(BatchUpdate bu) {
+      bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+          List<String> oldGroups = ps.getGroups();
+          if (oldGroups == null) {
+            if (groups == null) {
+              return false;
             }
-          });
-      if (ps != null) {
-        Change change = db.changes().get(psId.getParentKey());
-        if (change != null) {
-          indexer.index(db, change);
+          } else if (sameGroups(oldGroups, groups)) {
+            return false;
+          }
+          psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+          return true;
         }
-      }
+      });
     }
 
-    CheckedFuture<Void, RestApiException> updateGroups() {
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<Void> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<Void>() {
-        @Override
-        public Void call() throws OrmException, IOException {
-          if (caller == Thread.currentThread()) {
-            updateGroups(db);
-          } else {
-            try (ReviewDb db = schemaFactory.open()) {
-              updateGroups(db);
-            }
-          }
-          return null;
-        }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
+    private boolean sameGroups(List<String> a, List<String> b) {
+      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
     }
   }
 
@@ -2506,7 +2433,7 @@
       return false;
     }
     for (int i = 0; i < a.getParentCount(); i++) {
-      if (a.getParent(i) != b.getParent(i)) {
+      if (!a.getParent(i).equals(b.getParent(i))) {
         return false;
       }
     }
@@ -2537,31 +2464,19 @@
     }
   }
 
-  private Ref findMergedInto(final String first, final RevCommit commit) {
-    try {
-      final Map<String, Ref> all = repo.getRefDatabase().getRefs(ALL);
-      Ref firstRef = all.get(first);
-      if (firstRef != null && isMergedInto(commit, firstRef)) {
-        return firstRef;
-      }
-      for (Ref ref : all.values()) {
-        if (isHead(ref)) {
-          if (isMergedInto(commit, ref)) {
-            return ref;
-          }
-        }
-      }
-      return null;
-    } catch (IOException e) {
-      log.warn("Can't check for already submitted change", e);
-      return null;
-    }
-  }
+  private boolean validRefOperation(ReceiveCommand cmd) {
+    RefOperationValidators refValidators =
+        refValidatorsFactory.create(getProject(), user, cmd);
 
-  private boolean isMergedInto(final RevCommit commit, final Ref ref)
-      throws IOException {
-    final RevWalk rw = rp.getRevWalk();
-    return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
+    try {
+      messages.addAll(refValidators.validateForRefOperation());
+    } catch (RefOperationValidationException e) {
+      messages.addAll(Lists.newArrayList(e.getMessages()));
+      reject(cmd, e.getMessage());
+      return false;
+    }
+
+    return true;
   }
 
   private void validateNewCommits(RefControl ctl, ReceiveCommand cmd) {
@@ -2574,11 +2489,13 @@
         && !RefNames.REFS_CONFIG.equals(ctl.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
             || NEW_PATCHSET.matcher(cmd.getRefName()).matches())) {
+      logDebug("Short-circuiting new commit validation");
       return;
     }
 
-    boolean defaultName = Strings.isNullOrEmpty(user.getAccount().getFullName());
-    final RevWalk walk = rp.getRevWalk();
+    boolean defaultName =
+        Strings.isNullOrEmpty(user.getAccount().getFullName());
+    RevWalk walk = rp.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
     try {
@@ -2589,10 +2506,12 @@
       SetMultimap<ObjectId, Ref> existing = changeRefsById();
       walk.markStart((RevCommit)parsedObject);
       markHeadsAsUninteresting(walk, cmd.getRefName());
+      int i = 0;
       for (RevCommit c; (c = walk.next()) != null;) {
+        i++;
         if (existing.keySet().contains(c)) {
           continue;
-        } else if (!validCommit(ctl, cmd, c)) {
+        } else if (!validCommit(walk, ctl, cmd, c)) {
           break;
         }
 
@@ -2607,25 +2526,28 @@
               accountCache.evict(a.getId());
             }
           } catch (OrmException e) {
-            log.warn("Cannot default full_name", e);
+            logWarn("Cannot default full_name", e);
           } finally {
             defaultName = false;
           }
         }
       }
+      logDebug("Validated {} new commits", i);
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
-      log.error("Invalid pack upload; one or more objects weren't sent", err);
+      logError("Invalid pack upload; one or more objects weren't sent", err);
     }
   }
 
-  private boolean validCommit(final RefControl ctl, final ReceiveCommand cmd,
-      final RevCommit c) {
+  private boolean validCommit(RevWalk rw, RefControl ctl, ReceiveCommand cmd,
+      ObjectId id) throws IOException {
 
-    if (validCommits.contains(c)) {
+    if (validCommits.contains(id)) {
       return true;
     }
 
+    RevCommit c = rw.parseCommit(id);
+    rw.parseBody(c);
     CommitReceivedEvent receiveEvent =
         new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
     CommitValidators commitValidators =
@@ -2635,20 +2557,34 @@
       messages.addAll(commitValidators.validateForReceiveCommits(
           receiveEvent, rejectCommits));
     } catch (CommitValidationException e) {
+      logDebug("Commit validation failed on {}", c.name());
       messages.addAll(e.getMessages());
       reject(cmd, e.getMessage());
       return false;
     }
-    validCommits.add(c);
+    validCommits.add(c.copy());
     return true;
   }
 
   private void autoCloseChanges(final ReceiveCommand cmd) {
-    final RevWalk rw = rp.getRevWalk();
-    try {
+    logDebug("Starting auto-closing of changes");
+    String refName = cmd.getRefName();
+    checkState(!MagicBranch.isMagicBranch(refName),
+        "shouldn't be auto-closing changes on magic branch %s", refName);
+    RevWalk rw = rp.getRevWalk();
+    // TODO(dborowitz): Combine this BatchUpdate with the main one in
+    // insertChangesAndPatchSets.
+    try (BatchUpdate bu = batchUpdateFactory.create(db,
+          projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter()) {
+      bu.setRepository(repo, rp.getRevWalk(), ins)
+          .updateChangesInParallel();
+      bu.setRequestId(receiveId);
+      // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+
       RevCommit newTip = rw.parseCommit(cmd.getNewId());
       Branch.NameKey branch =
-          new Branch.NameKey(project.getNameKey(), cmd.getRefName());
+          new Branch.NameKey(project.getNameKey(), refName);
 
       rw.reset();
       rw.markStart(newTip);
@@ -2656,194 +2592,131 @@
         rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
       }
 
-      final SetMultimap<ObjectId, Ref> byCommit = changeRefsById();
-      Map<Change.Key, Change> byKey = null;
-      final List<ReplaceRequest> toClose = new ArrayList<>();
-      for (RevCommit c; (c = rw.next()) != null;) {
+      SetMultimap<ObjectId, Ref> byCommit = changeRefsById();
+      Map<Change.Key, ChangeNotes> byKey = null;
+      List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+
+      int existingPatchSets = 0;
+      int newPatchSets = 0;
+      COMMIT: for (RevCommit c; (c = rw.next()) != null;) {
         rw.parseBody(c);
 
         for (Ref ref : byCommit.get(c.copy())) {
-          Change.Key closedChange =
-              closeChange(cmd, PatchSet.Id.fromRef(ref.getName()), c);
-          closeProgress.update(1);
-          if (closedChange != null) {
-            if (byKey == null) {
-              byKey = openChangesByBranch(branch);
-            }
-            byKey.remove(closedChange);
-          }
+          existingPatchSets++;
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          bu.addOp(
+              psId.getParentKey(),
+              mergedByPushOpFactory.create(
+                  requestScopePropagator, psId, refName));
+          continue COMMIT;
         }
 
-        for (final String changeId : c.getFooterLines(CHANGE_ID)) {
+        for (String changeId : c.getFooterLines(CHANGE_ID)) {
           if (byKey == null) {
             byKey = openChangesByBranch(branch);
           }
 
-          final Change onto = byKey.get(new Change.Key(changeId.trim()));
+          ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
           if (onto != null) {
-            final ReplaceRequest req =
-                new ReplaceRequest(onto.getId(), c, cmd, false);
-            req.change = onto;
-            toClose.add(req);
-            break;
+            newPatchSets++;
+            // Hold onto this until we're done with the walk, as the call to
+            // req.validate below calls isMergedInto which resets the walk.
+            ReplaceRequest req =
+                new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+            req.notes = onto;
+            replaceAndClose.add(req);
+            continue COMMIT;
           }
         }
       }
 
-      for (final ReplaceRequest req : toClose) {
-        final PatchSet.Id psi = req.validate(true)
-            ? req.insertPatchSet().checkedGet()
-            : null;
-        if (psi != null) {
-          closeChange(req.inputCommand, psi, req.newCommit);
-          closeProgress.update(1);
+      for (final ReplaceRequest req : replaceAndClose) {
+        Change.Id id = req.notes.getChangeId();
+        if (!req.validate(true)) {
+          logDebug("Not closing {} because validation failed", id);
+          continue;
         }
+        req.addOps(bu, null);
+        bu.addOp(
+            id,
+            mergedByPushOpFactory.create(
+                    requestScopePropagator, req.psId, refName)
+                .setPatchSetProvider(new Provider<PatchSet>() {
+                  @Override
+                  public PatchSet get() {
+                    return req.replaceOp.getPatchSet();
+                  }
+                }));
+        bu.addOp(id, new ChangeProgressOp(closeProgress));
       }
+
+      logDebug("Auto-closing {} changes with existing patch sets and {} with"
+          + " new patch sets", existingPatchSets, newPatchSets);
+      bu.execute();
     } catch (RestApiException e) {
-      log.error("Can't insert patchset", e);
-    } catch (IOException | OrmException e) {
-      log.error("Can't scan for changes to close", e);
+      logError("Can't insert patchset", e);
+    } catch (IOException | OrmException | UpdateException e) {
+      logError("Can't scan for changes to close", e);
     }
   }
 
-  private Change.Key closeChange(final ReceiveCommand cmd, final PatchSet.Id psi,
-      final RevCommit commit) throws OrmException, IOException {
-    final String refName = cmd.getRefName();
-    final Change.Id cid = psi.getParentKey();
-
-    final Change change = db.changes().get(cid);
-    final PatchSet ps = db.patchSets().get(psi);
-    if (change == null || ps == null) {
-      log.warn(project.getName() + " " + psi + " is missing");
-      return null;
-    }
-
-    if (change.getStatus() == Change.Status.MERGED ||
-        change.getStatus() == Change.Status.ABANDONED ||
-        !change.getDest().get().equals(refName)) {
-      // If it's already merged or the commit is not aimed for
-      // this change's destination, don't make further updates.
-      //
-      return null;
-    }
-
-    ReplaceRequest result = new ReplaceRequest(cid, commit, cmd, false);
-    result.change = change;
-    result.changeCtl = projectControl.controlFor(change);
-    result.newPatchSet = ps;
-    result.info = patchSetInfoFactory.get(rp.getRevWalk(), commit, psi);
-    result.mergedIntoRef = refName;
-    markChangeMergedByPush(db, result, result.changeCtl);
-    hooks.doChangeMergedHook(
-        change, user.getAccount(), result.newPatchSet, db, commit.getName());
-    sendMergedEmail(result);
-    return change.getKey();
-  }
-
-  private Map<Change.Key, Change> openChangesByBranch(Branch.NameKey branch)
-      throws OrmException {
-    final Map<Change.Key, Change> r = new HashMap<>();
+  private Map<Change.Key, ChangeNotes> openChangesByBranch(
+      Branch.NameKey branch) throws OrmException {
+    Map<Change.Key, ChangeNotes> r = new HashMap<>();
     for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      r.put(cd.change().getKey(), cd.change());
+      r.put(cd.change().getKey(), cd.notes());
     }
     return r;
   }
 
-  private void markChangeMergedByPush(ReviewDb db, final ReplaceRequest result,
-      ChangeControl control) throws OrmException, IOException {
-    Change.Id id = result.change.getId();
-    db.changes().beginTransaction(id);
-    Change change;
-
-    ChangeUpdate update;
-    try {
-      change = db.changes().atomicUpdate(id, new AtomicUpdate<Change>() {
-          @Override
-          public Change update(Change change) {
-            if (change.getStatus().isOpen()) {
-              change.setCurrentPatchSet(result.info);
-              change.setStatus(Change.Status.MERGED);
-              ChangeUtil.updated(change);
-            }
-            return change;
-          }
-        });
-      String mergedIntoRef = result.mergedIntoRef;
-
-      StringBuilder msgBuf = new StringBuilder();
-      msgBuf.append("Change has been successfully pushed");
-      if (!mergedIntoRef.equals(change.getDest().get())) {
-        msgBuf.append(" into ");
-        if (mergedIntoRef.startsWith(Constants.R_HEADS)) {
-          msgBuf.append("branch ");
-          msgBuf.append(Repository.shortenRefName(mergedIntoRef));
-        } else {
-          msgBuf.append(mergedIntoRef);
-        }
-      }
-      msgBuf.append(".");
-      ChangeMessage msg = new ChangeMessage(
-          new ChangeMessage.Key(id, ChangeUtil.messageUUID(db)),
-          user.getAccountId(), change.getLastUpdatedOn(),
-          result.info.getKey());
-      msg.setMessage(msgBuf.toString());
-
-      update = updateFactory.create(control, change.getLastUpdatedOn());
-
-      cmUtil.addChangeMessage(db, update, msg);
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-    indexer.index(db, change);
-    update.commit();
-  }
-
-  private void sendMergedEmail(final ReplaceRequest result) {
-    final Change.Id id = result.change.getId();
-    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          final MergedSender cm = mergedSenderFactory.create(id);
-          cm.setFrom(user.getAccountId());
-          cm.setPatchSet(result.newPatchSet, result.info);
-          cm.send();
-        } catch (Exception e) {
-          final PatchSet.Id psi = result.newPatchSet.getId();
-          log.error("Cannot send email for submitted patch set " + psi, e);
-        }
-      }
-
-      @Override
-      public String toString() {
-        return "send-email merged";
-      }
-    }));
-  }
-
-  private static RevId toRevId(final RevCommit src) {
-    return new RevId(src.getId().name());
-  }
-
-  private void reject(final ReceiveCommand cmd) {
+  private void reject(ReceiveCommand cmd) {
     reject(cmd, "prohibited by Gerrit");
   }
 
-  private void reject(final ReceiveCommand cmd, final String why) {
+  private void reject(ReceiveCommand cmd, String why) {
     cmd.setResult(REJECTED_OTHER_REASON, why);
     commandProgress.update(1);
   }
 
-  private static boolean isHead(final Ref ref) {
-    return ref.getName().startsWith(Constants.R_HEADS);
-  }
-
-  private static boolean isHead(final ReceiveCommand cmd) {
+  private static boolean isHead(ReceiveCommand cmd) {
     return cmd.getRefName().startsWith(Constants.R_HEADS);
   }
 
-  private static boolean isConfig(final ReceiveCommand cmd) {
+  private static boolean isConfig(ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(receiveId + msg, args);
+    }
+  }
+
+  private void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      if (t != null) {
+        log.warn(receiveId + msg, t);
+      } else {
+        log.warn(receiveId + msg);
+      }
+    }
+  }
+
+  private void logWarn(String msg) {
+    logWarn(msg, null);
+  }
+
+  private void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(receiveId + msg, t);
+      } else {
+        log.error(receiveId + msg);
+      }
+    }
+  }
+
+  private void logError(String msg) {
+    logError(msg, null);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
index 43a4d4b..51c2a80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.MagicBranch;
@@ -47,14 +46,12 @@
   private static final Logger log = LoggerFactory
       .getLogger(ReceiveCommitsAdvertiseRefsHook.class);
 
-  private final ReviewDb db;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
 
-  public ReceiveCommitsAdvertiseRefsHook(ReviewDb db,
+  public ReceiveCommitsAdvertiseRefsHook(
       Provider<InternalChangeQuery> queryProvider,
       Project.NameKey projectName) {
-    this.db = db;
     this.queryProvider = queryProvider;
     this.projectName = projectName;
   }
@@ -92,22 +89,15 @@
 
   private Set<ObjectId> advertiseOpenChanges() {
     // Advertise some recent open changes, in case a commit is based on one.
-    final int limit = 32;
+    int limit = 32;
     try {
-      Set<PatchSet.Id> toGet = Sets.newHashSetWithExpectedSize(limit);
+      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
       for (ChangeData cd : queryProvider.get()
           .enforceVisibility(true)
           .setLimit(limit)
           .byProjectOpen(projectName)) {
-        PatchSet.Id id = cd.change().currentPatchSetId();
-        if (id != null) {
-          toGet.add(id);
-        }
-      }
-
-      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(toGet.size());
-      for (PatchSet ps : db.patchSets().get(toGet)) {
-        if (ps.getRevision() != null && ps.getRevision().get() != null) {
+        PatchSet ps = cd.currentPatchSet();
+        if (ps != null) {
           r.add(ObjectId.fromString(ps.getRevision().get()));
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
index ec0bdaf..ee229d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
@@ -32,5 +32,5 @@
    * @param project project for which the ReceivePack is created
    * @param receivePack the ReceivePack instance which is being initialized
    */
-  public void init(Project.NameKey project, ReceivePack receivePack);
+  void init(Project.NameKey project, ReceivePack receivePack);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
new file mode 100644
index 0000000..562db08
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.base.Optional;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+import java.io.IOException;
+
+/**
+ * Simple short-lived cache of individual refs read from a repo.
+ * <p>
+ * Within a single request that is known to read a small bounded number of refs,
+ * this class can be used to ensure a consistent view of one ref, and avoid
+ * multiple system calls to read refs multiple times.
+ * <p>
+ * <strong>Note:</strong> Implementations of this class are only appropriate
+ * for short-term caching, and do not support invalidation. It is also not
+ * threadsafe.
+ */
+public interface RefCache {
+  /**
+   * Get the possibly-cached value of a ref.
+   *
+   * @param refName name of the ref.
+   * @return value of the ref; absent if the ref does not exist in the repo.
+   *     Never null, and never present with a value of {@link
+   *     ObjectId#zeroId()}.
+   */
+  Optional<ObjectId> get(String refName) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
index 06314db..00c9a7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
@@ -83,13 +83,8 @@
         continue;
       }
 
-      try {
-        MetaDataUpdate md = metaDataUpdateFactory.create(projectName);
-        try {
-          rename(md);
-        } finally {
-          md.close();
-        }
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
+        rename(md);
       } catch (RepositoryNotFoundException noProject) {
         continue;
       } catch (ConfigInvalidException | IOException err) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
new file mode 100644
index 0000000..7754813
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -0,0 +1,488 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalCopier;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
+import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.util.Providers;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+
+public class ReplaceOp extends BatchUpdate.Op {
+  public interface Factory {
+    ReplaceOp create(
+        RequestScopePropagator requestScopePropagator,
+        ProjectControl projectControl,
+        Branch.NameKey dest,
+        boolean checkMergedInto,
+        @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+        @Assisted("priorCommit") RevCommit priorCommit,
+        @Assisted("patchSetId") PatchSet.Id patchSetId,
+        @Assisted("commit") RevCommit commit,
+        PatchSetInfo info,
+        List<String> groups,
+        @Nullable MagicBranchInput magicBranch,
+        @Nullable PushCertificate pushCertificate);
+  }
+
+  private static final Logger log =
+      LoggerFactory.getLogger(ReplaceOp.class);
+
+  private static final String CHANGE_IS_CLOSED = "change is closed";
+
+  private final AccountResolver accountResolver;
+  private final ApprovalCopier approvalCopier;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ChangeMessagesUtil cmUtil;
+  private final ExecutorService sendEmailExecutor;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final PatchSetUtil psUtil;
+  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+
+  private final RequestScopePropagator requestScopePropagator;
+  private final ProjectControl projectControl;
+  private final Branch.NameKey dest;
+  private final boolean checkMergedInto;
+  private final PatchSet.Id priorPatchSetId;
+  private final RevCommit priorCommit;
+  private final PatchSet.Id patchSetId;
+  private final RevCommit commit;
+  private final PatchSetInfo info;
+  private final MagicBranchInput magicBranch;
+  private final PushCertificate pushCertificate;
+  private List<String> groups = ImmutableList.of();
+
+  private final Map<String, Short> approvals = new HashMap<>();
+  private final MailRecipients recipients = new MailRecipients();
+  private Change change;
+  private PatchSet newPatchSet;
+  private ChangeKind changeKind;
+  private ChangeMessage msg;
+  private String rejectMessage;
+  private MergedByPushOp mergedByPushOp;
+
+  @AssistedInject
+  ReplaceOp(AccountResolver accountResolver,
+      ApprovalCopier approvalCopier,
+      ApprovalsUtil approvalsUtil,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeData.Factory changeDataFactory,
+      ChangeKindCache changeKindCache,
+      ChangeMessagesUtil cmUtil,
+      GitReferenceUpdated gitRefUpdated,
+      RevisionCreated revisionCreated,
+      CommentAdded commentAdded,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      PatchSetUtil psUtil,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      @Assisted RequestScopePropagator requestScopePropagator,
+      @Assisted ProjectControl projectControl,
+      @Assisted Branch.NameKey dest,
+      @Assisted boolean checkMergedInto,
+      @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+      @Assisted("priorCommit") RevCommit priorCommit,
+      @Assisted("patchSetId") PatchSet.Id patchSetId,
+      @Assisted("commit") RevCommit commit,
+      @Assisted PatchSetInfo info,
+      @Assisted List<String> groups,
+      @Assisted @Nullable MagicBranchInput magicBranch,
+      @Assisted @Nullable PushCertificate pushCertificate) {
+    this.accountResolver = accountResolver;
+    this.approvalCopier = approvalCopier;
+    this.approvalsUtil = approvalsUtil;
+    this.changeControlFactory = changeControlFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.changeKindCache = changeKindCache;
+    this.cmUtil = cmUtil;
+    this.gitRefUpdated = gitRefUpdated;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.psUtil = psUtil;
+    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.sendEmailExecutor = sendEmailExecutor;
+
+    this.requestScopePropagator = requestScopePropagator;
+    this.projectControl = projectControl;
+    this.dest = dest;
+    this.checkMergedInto = checkMergedInto;
+    this.priorPatchSetId = priorPatchSetId;
+    this.priorCommit = priorCommit;
+    this.patchSetId = patchSetId;
+    this.commit = commit;
+    this.info = info;
+    this.groups = groups;
+    this.magicBranch = magicBranch;
+    this.pushCertificate = pushCertificate;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws Exception {
+    changeKind = changeKindCache.getChangeKind(projectControl.getProjectState(),
+        ctx.getRepository(), priorCommit, commit);
+
+    if (checkMergedInto) {
+      Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
+      if (mergedInto != null) {
+        mergedByPushOp = mergedByPushOpFactory.create(
+            requestScopePropagator, patchSetId, mergedInto.getName());
+      }
+    }
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws OrmException, IOException {
+    change = ctx.getChange();
+    if (change == null || change.getStatus().isClosed()) {
+      rejectMessage = CHANGE_IS_CLOSED;
+      return false;
+    }
+    if (groups.isEmpty()) {
+      PatchSet prevPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+      groups = prevPs != null
+          ? prevPs.getGroups()
+          : ImmutableList.<String> of();
+    }
+
+    ChangeUpdate update = ctx.getUpdate(patchSetId);
+    update.setSubjectForCommit("Create patch set " + patchSetId.get());
+
+    String reviewMessage = null;
+    if (magicBranch != null) {
+      recipients.add(magicBranch.getMailRecipients());
+      reviewMessage = magicBranch.message;
+      approvals.putAll(magicBranch.labels);
+      Set<String> hashtags = magicBranch.hashtags;
+      if (hashtags != null && !hashtags.isEmpty()) {
+        hashtags.addAll(ctx.getNotes().getHashtags());
+        update.setHashtags(hashtags);
+      }
+      if (magicBranch.topic != null
+          && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
+        update.setTopic(magicBranch.topic);
+      }
+    }
+
+    boolean draft = magicBranch != null && magicBranch.draft;
+    if (change.getStatus() == Change.Status.DRAFT && !draft) {
+      update.setStatus(Change.Status.NEW);
+    }
+    newPatchSet = psUtil.insert(
+        ctx.getDb(), ctx.getRevWalk(), update, patchSetId, commit, draft, groups,
+        pushCertificate != null
+          ? pushCertificate.toTextWithSignature()
+          : null);
+
+    recipients.add(getRecipientsFromFooters(
+        ctx.getDb(), accountResolver, draft, commit.getFooterLines()));
+    recipients.remove(ctx.getAccountId());
+    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getControl());
+    MailRecipients oldRecipients =
+        getRecipientsFromReviewers(cd.reviewers());
+    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet);
+    approvalsUtil.addReviewers(ctx.getDb(), update,
+        projectControl.getLabelTypes(), change, newPatchSet, info,
+        recipients.getReviewers(), oldRecipients.getAll());
+    approvalsUtil.addApprovals(ctx.getDb(), update,
+        projectControl.getLabelTypes(), newPatchSet, ctx.getControl(),
+        approvals);
+    recipients.add(oldRecipients);
+
+    String approvalMessage = ApprovalsUtil.renderMessageWithApprovals(
+        patchSetId.get(), approvals, scanLabels(ctx, approvals));
+    String kindMessage = changeKindMessage(changeKind);
+    StringBuilder message = new StringBuilder(approvalMessage);
+    if (!Strings.isNullOrEmpty(kindMessage)) {
+      message.append(kindMessage);
+    } else {
+      message.append('.');
+    }
+    if (!Strings.isNullOrEmpty(reviewMessage)) {
+      message.append("\n").append(reviewMessage);
+    }
+    msg = new ChangeMessage(
+        new ChangeMessage.Key(change.getId(),
+            ChangeUtil.messageUUID(ctx.getDb())),
+        ctx.getAccountId(), ctx.getWhen(), patchSetId);
+    msg.setMessage(message.toString());
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    if (mergedByPushOp == null) {
+      resetChange(ctx, msg);
+    } else {
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet))
+          .updateChange(ctx);
+    }
+
+    return true;
+  }
+
+  private String changeKindMessage(ChangeKind changeKind) {
+    switch (changeKind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
+      case NO_CODE_CHANGE:
+        return ": Commit message was updated.";
+      case REWORK:
+      default:
+        return null;
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx,
+      Map<String, Short> approvals) throws OrmException {
+    Map<String, PatchSetApproval> current = new HashMap<>();
+    // We optimize here and only retrieve current when approvals provided
+    if (!approvals.isEmpty()) {
+      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getDb(),
+          ctx.getControl(), priorPatchSetId,
+          ctx.getAccountId())) {
+        if (a.isLegacySubmit()) {
+          continue;
+        }
+
+        LabelType lt = projectControl.getLabelTypes().byLabel(a.getLabelId());
+        if (lt != null) {
+          current.put(lt.getName(), a);
+        }
+      }
+    }
+    return current;
+  }
+
+  private void resetChange(ChangeContext ctx, ChangeMessage msg)
+      throws OrmException {
+    Change change = ctx.getChange();
+    if (change.getStatus().isClosed()) {
+      ctx.getDb().patchSets().delete(Collections.singleton(newPatchSet));
+      ctx.getDb().changeMessages().delete(Collections.singleton(msg));
+      rejectMessage = CHANGE_IS_CLOSED;
+      return;
+    }
+
+    if (!change.currentPatchSetId().equals(priorPatchSetId)) {
+      return;
+    }
+
+    if (magicBranch != null && magicBranch.topic != null) {
+      change.setTopic(magicBranch.topic);
+    }
+    if (change.getStatus() == Change.Status.DRAFT && newPatchSet.isDraft()) {
+      // Leave in draft status.
+    } else {
+      change.setStatus(Change.Status.NEW);
+    }
+    change.setCurrentPatchSet(info);
+
+    List<String> idList = commit.getFooterLines(CHANGE_ID);
+    if (idList.isEmpty()) {
+      change.setKey(new Change.Key("I" + commit.name()));
+    } else {
+      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
+    }
+  }
+
+  @Override
+  public void postUpdate(final Context ctx) throws Exception {
+    // Normally the ref updated hook is fired by BatchUpdate, but ReplaceOp is
+    // special because its ref is actually updated by ReceiveCommits, so from
+    // BatchUpdate's perspective there is no ref update. Thus we have to fire it
+    // manually.
+    final Account account = ctx.getAccount();
+    gitRefUpdated.fire(ctx.getProject(), newPatchSet.getRefName(),
+        ObjectId.zeroId(), commit, account);
+
+    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
+      Runnable sender = new Runnable() {
+        @Override
+        public void run() {
+          try {
+            ReplacePatchSetSender cm = replacePatchSetFactory.create(
+                projectControl.getProject().getNameKey(), change.getId());
+            cm.setFrom(account.getId());
+            cm.setPatchSet(newPatchSet, info);
+            cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
+            if (magicBranch != null && magicBranch.notify != null) {
+              cm.setNotify(magicBranch.notify);
+            }
+            cm.addReviewers(recipients.getReviewers());
+            cm.addExtraCC(recipients.getCcOnly());
+            cm.send();
+          } catch (Exception e) {
+            log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
+          }
+        }
+
+        @Override
+        public String toString() {
+          return "send-email newpatchset";
+        }
+      };
+
+      if (requestScopePropagator != null) {
+        sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+      } else {
+        sender.run();
+      }
+    }
+
+    NotifyHandling notify = magicBranch != null && magicBranch.notify != null
+        ? magicBranch.notify
+        : NotifyHandling.ALL;
+    revisionCreated.fire(change, newPatchSet, ctx.getAccount(),
+        ctx.getWhen(), notify);
+    try {
+      fireCommentAddedEvent(ctx);
+    } catch (Exception e) {
+      log.warn("comment-added event invocation failed", e);
+    }
+    if (mergedByPushOp != null) {
+      mergedByPushOp.postUpdate(ctx);
+    }
+  }
+
+  private void fireCommentAddedEvent(final Context ctx)
+      throws NoSuchChangeException, OrmException {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    /* For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     */
+    ChangeControl changeControl = changeControlFactory.controlFor(
+        ctx.getDb(), change, ctx.getUser());
+    List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
+    Map<String, Short> allApprovals = new HashMap<>();
+    Map<String, Short> oldApprovals = new HashMap<>();
+    for (LabelType lt : labels) {
+      allApprovals.put(lt.getName(), (short) 0);
+      oldApprovals.put(lt.getName(), null);
+    }
+    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+      if (entry.getValue() != 0) {
+        allApprovals.put(entry.getKey(), entry.getValue());
+        oldApprovals.put(entry.getKey(), (short) 0);
+      }
+    }
+
+    commentAdded.fire(change, newPatchSet,
+        ctx.getAccount(), null,
+        allApprovals, oldApprovals, ctx.getWhen());
+  }
+
+  public PatchSet getPatchSet() {
+    return newPatchSet;
+  }
+
+  public String getRejectMessage() {
+    return rejectMessage;
+  }
+
+  private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
+    try {
+      RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
+
+      Ref firstRef = refDatabase.exactRef(first);
+      if (firstRef != null
+          && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
+        return firstRef;
+      }
+
+      for (Ref ref : refDatabase.getRefs(Constants.R_HEADS).values()) {
+        if (isMergedInto(ctx.getRevWalk(), commit, ref)) {
+          return ref;
+        }
+      }
+      return null;
+    } catch (IOException e) {
+      log.warn("Can't check for already submitted change", e);
+      return null;
+    }
+  }
+
+  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref)
+      throws IOException {
+    return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
new file mode 100644
index 0000000..1dfa51e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.base.Optional;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** {@link RefCache} backed directly by a repository. */
+public class RepoRefCache implements RefCache {
+  private final RefDatabase refdb;
+  private final Map<String, Optional<ObjectId>> ids;
+
+  public RepoRefCache(Repository repo) {
+    this.refdb = repo.getRefDatabase();
+    this.ids = new HashMap<>();
+  }
+
+  @Override
+  public Optional<ObjectId> get(String refName) throws IOException {
+    Optional<ObjectId> id = ids.get(refName);
+    if (id != null) {
+      return id;
+    }
+    Ref ref = refdb.exactRef(refName);
+    id = ref != null
+        ? Optional.of(ref.getObjectId())
+        : Optional.<ObjectId>absent();
+    ids.put(refName, id);
+    return id;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java
deleted file mode 100644
index faf3776..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.gerrit.server.git.SearchingChangeCacheImpl.ID_CACHE;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-
-@Singleton
-public class ScanningChangeCacheImpl implements ChangeCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(ScanningChangeCacheImpl.class);
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ChangeCache.class).to(ScanningChangeCacheImpl.class);
-        cache(ID_CACHE,
-            Project.NameKey.class,
-            new TypeLiteral<List<Change>>() {})
-          .maximumWeight(0)
-          .loader(Loader.class);
-      }
-    };
-  }
-
-  private final LoadingCache<Project.NameKey, List<Change>> cache;
-
-  @Inject
-  ScanningChangeCacheImpl(
-      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<Change>> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public List<Change> get(Project.NameKey name) {
-    try {
-      return cache.get(name);
-    } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + name, e);
-      return Collections.emptyList();
-    }
-  }
-
-  static class Loader extends CacheLoader<Project.NameKey, List<Change>> {
-    private final GitRepositoryManager repoManager;
-    private final OneOffRequestContext requestContext;
-
-    @Inject
-    Loader(GitRepositoryManager repoManager,
-        OneOffRequestContext requestContext) {
-      this.repoManager = repoManager;
-      this.requestContext = requestContext;
-    }
-
-    @Override
-    public List<Change> load(Project.NameKey key) throws Exception {
-      try (Repository repo = repoManager.openRepository(key);
-          ManualRequestContext ctx = requestContext.open()) {
-        return scan(repo, ctx.getReviewDbProvider().get());
-      }
-    }
-
-  }
-
-  public static List<Change> scan(Repository repo, ReviewDb db)
-      throws OrmException, IOException {
-    Map<String, Ref> refs =
-        repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES);
-    Set<Change.Id> ids = new LinkedHashSet<>();
-    for (Ref r : refs.values()) {
-      Change.Id id = Change.Id.fromRef(r.getName());
-      if (id != null) {
-        ids.add(id);
-      }
-    }
-    List<Change> changes = new ArrayList<>(ids.size());
-    // A batch size of N may overload get(Iterable), so use something smaller,
-    // but still >1.
-    for (List<Change.Id> batch : Iterables.partition(ids, 30)) {
-      Iterables.addAll(changes, db.changes().get(batch));
-    }
-    return changes;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 233a892..54ec249 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -14,65 +14,117 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import com.google.inject.util.Providers;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
 
 @Singleton
-public class SearchingChangeCacheImpl
-    implements ChangeCache, GitReferenceUpdatedListener {
+public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
   private static final Logger log =
       LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
   static final String ID_CACHE = "changes";
 
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ChangeCache.class).to(SearchingChangeCacheImpl.class);
+  public static class Module extends CacheModule {
+    private final boolean slave;
+
+    public Module() {
+      this(false);
+    }
+
+    public Module(boolean slave) {
+      this.slave = slave;
+    }
+
+    @Override
+    protected void configure() {
+      if (slave) {
+        bind(SearchingChangeCacheImpl.class)
+            .toProvider(Providers.<SearchingChangeCacheImpl> of(null));
+      } else {
         cache(ID_CACHE,
             Project.NameKey.class,
-            new TypeLiteral<List<Change>>() {})
+            new TypeLiteral<List<CachedChange>>() {})
           .maximumWeight(0)
           .loader(Loader.class);
+
+        bind(SearchingChangeCacheImpl.class);
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+            .to(SearchingChangeCacheImpl.class);
       }
-    };
+    }
   }
 
-  private final LoadingCache<Project.NameKey, List<Change>> cache;
+  @AutoValue
+  abstract static class CachedChange {
+    // Subset of fields in ChangeData, specifically fields needed to serve
+    // VisibleRefFilter without touching the database. More can be added as
+    // necessary.
+    abstract Change change();
+    @Nullable abstract ReviewerSet reviewers();
+  }
+
+  private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
+  private final ChangeData.Factory changeDataFactory;
 
   @Inject
   SearchingChangeCacheImpl(
-      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<Change>> cache) {
+      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<CachedChange>> cache,
+      ChangeData.Factory changeDataFactory) {
     this.cache = cache;
+    this.changeDataFactory = changeDataFactory;
   }
 
-  @Override
-  public List<Change> get(Project.NameKey name) {
+  /**
+   * Read changes for the project from the secondary index.
+   * <p>
+   * Returned changes only include the {@code Change} object (with id, branch)
+   * and the reviewers. Additional stored fields are not loaded from the index.
+   *
+   * @param db database handle to populate missing change data (probably
+   *        unused).
+   * @param project project to read.
+   * @return list of known changes; empty if no changes.
+   */
+  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey project) {
     try {
-      return cache.get(name);
+      List<CachedChange> cached = cache.get(project);
+      List<ChangeData> cds = new ArrayList<>(cached.size());
+      for (CachedChange cc : cached) {
+        ChangeData cd = changeDataFactory.create(db, cc.change());
+        cd.setReviewers(cc.reviewers());
+        cds.add(cd);
+      }
+      return Collections.unmodifiableList(cds);
     } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + name, e);
+      log.warn("Cannot fetch changes for " + project, e);
       return Collections.emptyList();
     }
   }
@@ -84,7 +136,7 @@
     }
   }
 
-  static class Loader extends CacheLoader<Project.NameKey, List<Change>> {
+  static class Loader extends CacheLoader<Project.NameKey, List<CachedChange>> {
     private final OneOffRequestContext requestContext;
     private final Provider<InternalChangeQuery> queryProvider;
 
@@ -96,10 +148,19 @@
     }
 
     @Override
-    public List<Change> load(Project.NameKey key) throws Exception {
+    public List<CachedChange> load(Project.NameKey key) throws Exception {
       try (AutoCloseable ctx = requestContext.open()) {
-        return Collections.unmodifiableList(
-            ChangeData.asChanges(queryProvider.get().byProject(key)));
+        List<ChangeData> cds = queryProvider.get()
+            .setRequestedFields(ImmutableSet.of(
+                ChangeField.CHANGE.getName(),
+                ChangeField.REVIEWER.getName()))
+            .byProject(key);
+        List<CachedChange> result = new ArrayList<>(cds.size());
+        for (ChangeData cd : cds) {
+          result.add(new AutoValue_SearchingChangeCacheImpl_CachedChange(
+              cd.change(), cd.getReviewers()));
+        }
+        return Collections.unmodifiableList(result);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
index d7e8446..5a3b4ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 /** Indicates the gitlink's update cannot be processed at this time. */
-class SubmoduleException extends Exception {
+public class SubmoduleException extends Exception {
   private static final long serialVersionUID = 1L;
 
   SubmoduleException(final String msg) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 7951fd3..470ea84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -15,22 +15,26 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
+import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
+import com.google.gerrit.server.git.BatchUpdate.Listener;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
@@ -38,337 +42,586 @@
 import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
 import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.BlobBasedConfig;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.RefSpec;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 public class SubmoduleOp {
+
+  /**
+   * Only used for branches without code review changes
+   */
+  public class GitlinkOp extends BatchUpdate.RepoOnlyOp {
+    private final Branch.NameKey branch;
+
+    GitlinkOp(Branch.NameKey branch) {
+      this.branch = branch;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      CodeReviewCommit c = composeGitlinksCommit(branch);
+      if (c != null) {
+        ctx.addRefUpdate(new ReceiveCommand(c.getParent(0), c, branch.get()));
+        addBranchTip(branch, c);
+      }
+    }
+  }
+
+  public interface Factory {
+    SubmoduleOp create(
+        Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
+  }
+
   private static final Logger log = LoggerFactory.getLogger(SubmoduleOp.class);
-  private static final String GIT_MODULES = ".gitmodules";
 
-  private final Provider<String> urlProvider;
+  private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Set<Branch.NameKey> updatedSubscribers;
-  private final Account account;
-  private final ChangeHooks changeHooks;
-  private final SubmoduleSectionParser.Factory subSecParserFactory;
-  private final boolean verboseSuperProject;
+  private final ProjectCache projectCache;
+  private final ProjectState.Factory projectStateFactory;
+  private final VerboseSuperprojectUpdate verboseSuperProject;
+  private final boolean enableSuperProjectSubscriptions;
+  private final MergeOpRepoManager orm;
+  private final Map<Branch.NameKey, GitModules> branchGitModules;
 
-  @Inject
+  // always update-to-current branch tips during submit process
+  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
+  // branches for all the submitting changes
+  private final Set<Branch.NameKey> updatedBranches;
+  // branches which in either a submodule or a superproject
+  private final Set<Branch.NameKey> affectedBranches;
+  // sorted version of affectedBranches
+  private final ImmutableSet<Branch.NameKey> sortedBranches;
+  // map of superproject branch and its submodule subscriptions
+  private final Multimap<Branch.NameKey, SubmoduleSubscription> targets;
+  // map of superproject and its branches which has submodule subscriptions
+  private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
+
+  @AssistedInject
   public SubmoduleOp(
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      GitModules.Factory gitmodulesFactory,
       @GerritPersonIdent PersonIdent myIdent,
       @GerritServerConfig Config cfg,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      @Nullable Account account,
-      ChangeHooks changeHooks,
-      SubmoduleSectionParser.Factory subSecParserFactory) {
-    this.urlProvider = urlProvider;
+      ProjectCache projectCache,
+      ProjectState.Factory projectStateFactory,
+      @Assisted Set<Branch.NameKey> updatedBranches,
+      @Assisted MergeOpRepoManager orm) throws SubmoduleException {
+    this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.account = account;
-    this.changeHooks = changeHooks;
-    this.subSecParserFactory = subSecParserFactory;
-    this.verboseSuperProject = cfg.getBoolean("submodule",
-        "verboseSuperprojectUpdate", true);
-
-    updatedSubscribers = new HashSet<>();
+    this.projectCache = projectCache;
+    this.projectStateFactory = projectStateFactory;
+    this.verboseSuperProject =
+        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate",
+            VerboseSuperprojectUpdate.TRUE);
+    this.enableSuperProjectSubscriptions = cfg.getBoolean("submodule",
+        "enableSuperProjectSubscriptions", true);
+    this.orm = orm;
+    this.updatedBranches = updatedBranches;
+    this.targets = HashMultimap.create();
+    this.affectedBranches = new HashSet<>();
+    this.branchTips = new HashMap<>();
+    this.branchGitModules = new HashMap<>();
+    this.branchesByProject = HashMultimap.create();
+    this.sortedBranches = calculateSubscriptionMap();
   }
 
-  void updateSubmoduleSubscriptions(ReviewDb db, Set<Branch.NameKey> branches)
+  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap()
       throws SubmoduleException {
-    for (Branch.NameKey branch : branches) {
-      updateSubmoduleSubscriptions(db, branch);
+    if (!enableSuperProjectSubscriptions) {
+      logDebug("Updating superprojects disabled");
+      return null;
     }
+
+    logDebug("Calculating superprojects - submodules map");
+    LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
+    for (Branch.NameKey updatedBranch : updatedBranches) {
+      if (allVisited.contains(updatedBranch)) {
+        continue;
+      }
+
+      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(),
+          allVisited);
+    }
+
+    // Since the searchForSuperprojects will add all branches (related or
+    // unrelated) and ensure the superproject's branches get added first before
+    // a submodule branch. Need remove all unrelated branches and reverse
+    // the order.
+    allVisited.retainAll(affectedBranches);
+    reverse(allVisited);
+    return ImmutableSet.copyOf(allVisited);
   }
 
-  void updateSubmoduleSubscriptions(ReviewDb db, Branch.NameKey destBranch)
+  private void searchForSuperprojects(Branch.NameKey current,
+      LinkedHashSet<Branch.NameKey> currentVisited,
+      LinkedHashSet<Branch.NameKey> allVisited)
       throws SubmoduleException {
-    if (urlProvider.get() == null) {
-      logAndThrowSubmoduleException("Cannot establish canonical web url used "
-          + "to access gerrit. It should be provided in gerrit.config file.");
+    logDebug("Now processing " + current);
+
+    if (currentVisited.contains(current)) {
+      throw new SubmoduleException(
+          "Branch level circular subscriptions detected:  " +
+              printCircularPath(currentVisited, current));
     }
-    try (Repository repo = repoManager.openRepository(
-            destBranch.getParentKey());
-        RevWalk rw = new RevWalk(repo)) {
 
-      ObjectId id = repo.resolve(destBranch.get());
-      if (id == null) {
-        logAndThrowSubmoduleException(
-            "Cannot resolve submodule destination branch " + destBranch);
-      }
-      RevCommit commit = rw.parseCommit(id);
-
-      Set<SubmoduleSubscription> oldSubscriptions =
-          Sets.newHashSet(db.submoduleSubscriptions()
-              .bySuperProject(destBranch));
-
-      Set<SubmoduleSubscription> newSubscriptions;
-      TreeWalk tw = TreeWalk.forPath(repo, GIT_MODULES, commit.getTree());
-      if (tw != null
-          && (FileMode.REGULAR_FILE.equals(tw.getRawMode(0)) ||
-              FileMode.EXECUTABLE_FILE.equals(tw.getRawMode(0)))) {
-        BlobBasedConfig bbc =
-            new BlobBasedConfig(null, repo, commit, GIT_MODULES);
-
-        String thisServer = new URI(urlProvider.get()).getHost();
-
-        newSubscriptions = subSecParserFactory.create(bbc, thisServer,
-            destBranch).parseAllSections();
-      } else {
-        newSubscriptions = Collections.emptySet();
-      }
-
-      Set<SubmoduleSubscription> alreadySubscribeds = new HashSet<>();
-      for (SubmoduleSubscription s : newSubscriptions) {
-        if (oldSubscriptions.contains(s)) {
-          alreadySubscribeds.add(s);
-        }
-      }
-
-      oldSubscriptions.removeAll(newSubscriptions);
-      newSubscriptions.removeAll(alreadySubscribeds);
-
-      if (!oldSubscriptions.isEmpty()) {
-        db.submoduleSubscriptions().delete(oldSubscriptions);
-      }
-      if (!newSubscriptions.isEmpty()) {
-        db.submoduleSubscriptions().insert(newSubscriptions);
-      }
-
-    } catch (OrmException e) {
-      logAndThrowSubmoduleException(
-          "Database problem at update of subscriptions table from "
-              + GIT_MODULES + " file.", e);
-    } catch (ConfigInvalidException e) {
-      logAndThrowSubmoduleException(
-          "Problem at update of subscriptions table: " + GIT_MODULES
-              + " config file is invalid.", e);
-    } catch (IOException e) {
-      logAndThrowSubmoduleException(
-          "Problem at update of subscriptions table from " + GIT_MODULES + ".",
-          e);
-    } catch (URISyntaxException e) {
-      logAndThrowSubmoduleException(
-          "Incorrect gerrit canonical web url provided in gerrit.config file.",
-          e);
+    if (allVisited.contains(current)) {
+      return;
     }
-  }
 
-  protected void updateSuperProjects(ReviewDb db,
-      Collection<Branch.NameKey> updatedBranches) throws SubmoduleException {
+    currentVisited.add(current);
     try {
-      // These (repo/branch) will be updated later with all the given
-      // individual submodule subscriptions
-      Multimap<Branch.NameKey, SubmoduleSubscription> targets =
-          HashMultimap.create();
+      Collection<SubmoduleSubscription> subscriptions =
+          superProjectSubscriptionsForSubmoduleBranch(current);
+      for (SubmoduleSubscription sub : subscriptions) {
+        Branch.NameKey superBranch = sub.getSuperProject();
+        searchForSuperprojects(superBranch, currentVisited, allVisited);
+        targets.put(superBranch, sub);
+        branchesByProject.put(superBranch.getParentKey(), superBranch);
+        affectedBranches.add(superBranch);
+        affectedBranches.add(sub.getSubmodule());
+      }
+    } catch (IOException e) {
+      throw new SubmoduleException("Cannot find superprojects for " + current,
+          e);
+    }
+    currentVisited.remove(current);
+    allVisited.add(current);
+  }
 
-      for (Branch.NameKey updatedBranch : updatedBranches) {
-        for (SubmoduleSubscription sub : db.submoduleSubscriptions()
-            .bySubmodule(updatedBranch)) {
-          targets.put(sub.getSuperProject(), sub);
+  private static <T> void reverse(LinkedHashSet<T> set) {
+    if (set == null) {
+      return;
+    }
+
+    Deque<T> q = new ArrayDeque<>(set);
+    set.clear();
+
+    while (!q.isEmpty()) {
+      set.add(q.removeLast());
+    }
+  }
+
+  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+
+  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src,
+      SubscribeSection s) throws IOException {
+    Collection<Branch.NameKey> ret = new HashSet<>();
+    logDebug("Inspecting SubscribeSection " + s);
+    for (RefSpec r : s.getMatchingRefSpecs()) {
+      logDebug("Inspecting [matching] ref " + r);
+      if (!r.matchSource(src.get())) {
+        continue;
+      }
+      if (r.isWildcard()) {
+        // refs/heads/*[:refs/somewhere/*]
+        ret.add(new Branch.NameKey(s.getProject(),
+            r.expandFromSource(src.get()).getDestination()));
+      } else {
+        // e.g. refs/heads/master[:refs/heads/stable]
+        String dest = r.getDestination();
+        if (dest == null) {
+          dest = r.getSource();
+        }
+        ret.add(new Branch.NameKey(s.getProject(), dest));
+      }
+    }
+
+    for (RefSpec r : s.getMultiMatchRefSpecs()) {
+      logDebug("Inspecting [all] ref " + r);
+      if (!r.matchSource(src.get())) {
+        continue;
+      }
+      OpenRepo or;
+      try {
+        or = orm.openRepo(s.getProject());
+      } catch (NoSuchProjectException e) {
+        // A project listed a non existent project to be allowed
+        // to subscribe to it. Allow this for now, i.e. no exception is
+        // thrown.
+        continue;
+      }
+
+      for (Ref ref : or.repo.getRefDatabase().getRefs(
+          RefNames.REFS_HEADS).values()) {
+        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+          continue;
+        }
+        Branch.NameKey b = new Branch.NameKey(s.getProject(), ref.getName());
+        if (!ret.contains(b)) {
+          ret.add(b);
         }
       }
-      updatedSubscribers.addAll(updatedBranches);
-      // Update subscribers.
-      for (Branch.NameKey dest : targets.keySet()) {
+    }
+    logDebug("Returning possible branches: " + ret +
+        "for project " + s.getProject());
+    return ret;
+  }
+
+  public Collection<SubmoduleSubscription>
+      superProjectSubscriptionsForSubmoduleBranch(Branch.NameKey srcBranch)
+      throws IOException {
+    logDebug("Calculating possible superprojects for " + srcBranch);
+    Collection<SubmoduleSubscription> ret = new ArrayList<>();
+    Project.NameKey srcProject = srcBranch.getParentKey();
+    ProjectConfig cfg = projectCache.get(srcProject).getConfig();
+    for (SubscribeSection s : projectStateFactory.create(cfg)
+        .getSubscribeSections(srcBranch)) {
+      logDebug("Checking subscribe section " + s);
+      Collection<Branch.NameKey> branches =
+          getDestinationBranches(srcBranch, s);
+      for (Branch.NameKey targetBranch : branches) {
+        Project.NameKey targetProject = targetBranch.getParentKey();
         try {
-          if (!updatedSubscribers.add(dest)) {
-            log.error("Possible circular subscription involving " + dest);
-          } else {
-            updateGitlinks(db, dest, targets.get(dest));
+          OpenRepo or = orm.openRepo(targetProject);
+          ObjectId id = or.repo.resolve(targetBranch.get());
+          if (id == null) {
+            logDebug("The branch " + targetBranch + " doesn't exist.");
+            continue;
           }
-        } catch (SubmoduleException e) {
-          log.warn("Cannot update gitlinks for " + dest, e);
+        } catch (NoSuchProjectException e) {
+          logDebug("The project " + targetProject + " doesn't exist");
+          continue;
+        }
+
+        GitModules m = branchGitModules.get(targetBranch);
+        if (m == null) {
+          m = gitmodulesFactory.create(targetBranch, orm);
+          branchGitModules.put(targetBranch, m);
+        }
+        ret.addAll(m.subscribedTo(srcBranch));
+      }
+    }
+    logDebug("Calculated superprojects for " + srcBranch + " are " + ret);
+    return ret;
+  }
+
+  public void updateSuperProjects() throws SubmoduleException {
+    ImmutableSet<Project.NameKey> projects = getProjectsInOrder();
+    if (projects == null) {
+      return;
+    }
+
+    LinkedHashSet<Project.NameKey> superProjects = new LinkedHashSet<>();
+    try {
+      for (Project.NameKey project : projects) {
+        // only need superprojects
+        if (branchesByProject.containsKey(project)) {
+          superProjects.add(project);
+          // get a new BatchUpdate for the super project
+          OpenRepo or = orm.openRepo(project);
+          for (Branch.NameKey branch : branchesByProject.get(project)) {
+            addOp(or.getUpdate(), branch);
+          }
         }
       }
-    } catch (OrmException e) {
-      logAndThrowSubmoduleException("Cannot read subscription records", e);
+      BatchUpdate.execute(orm.batchUpdates(superProjects), Listener.NONE,
+          orm.getSubmissionId());
+    } catch (RestApiException | UpdateException | IOException |
+        NoSuchProjectException e) {
+      throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
   /**
-   * Update the submodules in one branch of one repository.
-   *
-   * @param subscriber the branch of the repository which should be changed.
-   * @param updates submodule updates which should be updated to.
-   * @throws SubmoduleException
+   * Create a separate gitlink commit
    */
-  private void updateGitlinks(ReviewDb db, Branch.NameKey subscriber,
-      Collection<SubmoduleSubscription> updates) throws SubmoduleException {
-    PersonIdent author = null;
-    StringBuilder msgbuf = new StringBuilder("Updated git submodules\n\n");
-    boolean sameAuthorForAll = true;
+  public CodeReviewCommit composeGitlinksCommit(final Branch.NameKey subscriber)
+      throws IOException, SubmoduleException {
+    OpenRepo or;
+    try {
+      or = orm.openRepo(subscriber.getParentKey());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new SubmoduleException("Cannot access superproject", e);
+    }
 
-    try (Repository pdb = repoManager.openRepository(subscriber.getParentKey())) {
-      if (pdb.getRef(subscriber.get()) == null) {
+    CodeReviewCommit currentCommit;
+    if (branchTips.containsKey(subscriber)) {
+      currentCommit = branchTips.get(subscriber);
+    } else {
+      Ref r = or.repo.exactRef(subscriber.get());
+      if (r == null) {
         throw new SubmoduleException(
             "The branch was probably deleted from the subscriber repository");
       }
+      currentCommit = or.rw.parseCommit(r.getObjectId());
+    }
 
-      DirCache dc = readTree(pdb, pdb.getRef(subscriber.get()));
-      DirCacheEditor ed = dc.editor();
-
-      for (SubmoduleSubscription s : updates) {
-        try (Repository subrepo = repoManager.openRepository(
-            s.getSubmodule().getParentKey());
-            RevWalk rw = CodeReviewCommit.newRevWalk(subrepo)) {
-          Ref ref = subrepo.getRefDatabase().exactRef(s.getSubmodule().get());
-          if (ref == null) {
-            ed.add(new DeletePath(s.getPath()));
-            continue;
-          }
-
-          final ObjectId updateTo = ref.getObjectId();
-          RevCommit newCommit = rw.parseCommit(updateTo);
-
-          if (author == null) {
-            author = newCommit.getAuthorIdent();
-          } else if (!author.equals(newCommit.getAuthorIdent())) {
-            sameAuthorForAll = false;
-          }
-
-          DirCacheEntry dce = dc.getEntry(s.getPath());
-          ObjectId oldId;
-          if (dce != null) {
-            if (!dce.getFileMode().equals(FileMode.GITLINK)) {
-              log.error("Requested to update gitlink " + s.getPath() + " in "
-                  + s.getSubmodule().getParentKey().get() + " but entry "
-                  + "doesn't have gitlink file mode.");
-              continue;
-            }
-            oldId = dce.getObjectId();
-          } else {
-            // This submodule did not exist before. We do not want to add
-            // the full submodule history to the commit message, so omit it.
-            oldId = updateTo;
-          }
-
-          ed.add(new PathEdit(s.getPath()) {
-            @Override
-            public void apply(DirCacheEntry ent) {
-              ent.setFileMode(FileMode.GITLINK);
-              ent.setObjectId(updateTo);
-            }
-          });
-          if (verboseSuperProject) {
-            msgbuf.append("Project: " + s.getSubmodule().getParentKey().get());
-            msgbuf.append(" " + s.getSubmodule().getShortName());
-            msgbuf.append(" " + updateTo.getName());
-            msgbuf.append("\n\n");
-
-            try {
-              rw.markStart(newCommit);
-              rw.markUninteresting(rw.parseCommit(oldId));
-              for (RevCommit c : rw) {
-                msgbuf.append(c.getFullMessage() + "\n\n");
-              }
-            } catch (IOException e) {
-              logAndThrowSubmoduleException("Could not perform a revwalk to "
-                  + "create superproject commit message", e);
-            }
-          }
+    StringBuilder msgbuf = new StringBuilder("");
+    PersonIdent author = null;
+    DirCache dc = readTree(or.rw, currentCommit);
+    DirCacheEditor ed = dc.editor();
+    for (SubmoduleSubscription s : targets.get(subscriber)) {
+      RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
+      if (newCommit != null) {
+        if (author == null) {
+          author = newCommit.getAuthorIdent();
+        } else if (!author.equals(newCommit.getAuthorIdent())) {
+          author = myIdent;
         }
       }
-      ed.finish();
-
-      if (!sameAuthorForAll || author == null) {
-        author = myIdent;
-      }
-
-      ObjectInserter oi = pdb.newObjectInserter();
-      ObjectId tree = dc.writeTree(oi);
-
-      ObjectId currentCommitId =
-          pdb.getRef(subscriber.get()).getObjectId();
-
-      CommitBuilder commit = new CommitBuilder();
-      commit.setTreeId(tree);
-      commit.setParentIds(new ObjectId[] {currentCommitId});
-      commit.setAuthor(author);
-      commit.setCommitter(myIdent);
-      commit.setMessage(msgbuf.toString());
-      oi.insert(commit);
-      oi.flush();
-
-      ObjectId commitId = oi.idFor(Constants.OBJ_COMMIT, commit.build());
-
-      final RefUpdate rfu = pdb.updateRef(subscriber.get());
-      rfu.setForceUpdate(false);
-      rfu.setNewObjectId(commitId);
-      rfu.setExpectedOldObjectId(currentCommitId);
-      rfu.setRefLogMessage("Submit to " + subscriber.getParentKey().get(), true);
-
-      switch (rfu.update()) {
-        case NEW:
-        case FAST_FORWARD:
-          gitRefUpdated.fire(subscriber.getParentKey(), rfu);
-          changeHooks.doRefUpdatedHook(subscriber, rfu, account);
-          // TODO since this is performed "in the background" no mail will be
-          // sent to inform users about the updated branch
-          break;
-
-        default:
-          throw new IOException(rfu.getResult().name());
-      }
-      // Recursive call: update subscribers of the subscriber
-      updateSuperProjects(db, Sets.newHashSet(subscriber));
-    } catch (IOException e) {
-      throw new SubmoduleException("Cannot update gitlinks for "
-          + subscriber.get(), e);
     }
-  }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
 
-  private static DirCache readTree(final Repository pdb, final Ref branch)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
-    try (RevWalk rw = new RevWalk(pdb)) {
-      final DirCache dc = DirCache.newInCore();
-      final DirCacheBuilder b = dc.builder();
-      b.addTree(new byte[0], // no prefix path
-          DirCacheEntry.STAGE_0, // standard stage
-          pdb.newObjectReader(), rw.parseTree(branch.getObjectId()));
-      b.finish();
-      return dc;
+    // Gitlinks are already in the branch, return null
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return null;
     }
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentId(currentCommit);
+    StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      commitMsg.append(msgbuf);
+    }
+    commit.setMessage(commitMsg.toString());
+    commit.setAuthor(author);
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    return or.rw.parseCommit(id);
   }
 
-  private static void logAndThrowSubmoduleException(final String errorMsg,
-      final Exception e) throws SubmoduleException {
-    log.error(errorMsg, e);
-    throw new SubmoduleException(errorMsg, e);
+  /**
+   * Amend an existing commit with gitlink updates
+   */
+  public CodeReviewCommit composeGitlinksCommit(
+      final Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+      throws IOException, SubmoduleException {
+    OpenRepo or;
+    try {
+      or = orm.openRepo(subscriber.getParentKey());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new SubmoduleException("Cannot access superproject", e);
+    }
+
+    StringBuilder msgbuf = new StringBuilder("");
+    DirCache dc = readTree(or.rw, currentCommit);
+    DirCacheEditor ed = dc.editor();
+    for (SubmoduleSubscription s : targets.get(subscriber)) {
+      updateSubmodule(dc, ed, msgbuf, s);
+    }
+    ed.finish();
+    ObjectId newTreeId = dc.writeTree(or.ins);
+
+    // Gitlinks are already updated, just return the commit
+    if (newTreeId.equals(currentCommit.getTree())) {
+      return currentCommit;
+    }
+    or.rw.parseBody(currentCommit);
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(newTreeId);
+    commit.setParentIds(currentCommit.getParents());
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      //TODO:czhen handle cherrypick footer
+      commit.setMessage(currentCommit.getFullMessage()
+          + "\n\n* submodules:\n" + msgbuf.toString());
+    } else {
+      commit.setMessage(currentCommit.getFullMessage());
+    }
+    commit.setAuthor(currentCommit.getAuthorIdent());
+    commit.setCommitter(myIdent);
+    ObjectId id = or.ins.insert(commit);
+    CodeReviewCommit newCommit = or.rw.parseCommit(id);
+    newCommit.copyFrom(currentCommit);
+    return newCommit;
   }
 
-  private static void logAndThrowSubmoduleException(final String errorMsg)
+  private RevCommit updateSubmodule(DirCache dc, DirCacheEditor ed,
+      StringBuilder msgbuf, final SubmoduleSubscription s)
+      throws SubmoduleException, IOException {
+    OpenRepo subOr;
+    try {
+      subOr = orm.openRepo(s.getSubmodule().getParentKey());
+    } catch (NoSuchProjectException | IOException e) {
+      throw new SubmoduleException("Cannot access submodule", e);
+    }
+
+    DirCacheEntry dce = dc.getEntry(s.getPath());
+    RevCommit oldCommit = null;
+    if (dce != null) {
+      if (!dce.getFileMode().equals(FileMode.GITLINK)) {
+        String errMsg = "Requested to update gitlink " + s.getPath() + " in "
+            + s.getSubmodule().getParentKey().get() + " but entry "
+            + "doesn't have gitlink file mode.";
+        throw new SubmoduleException(errMsg);
+      }
+      oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+    }
+
+    final RevCommit newCommit;
+    if (branchTips.containsKey(s.getSubmodule())) {
+      newCommit = branchTips.get(s.getSubmodule());
+    } else {
+      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().get());
+      if (ref == null) {
+        ed.add(new DeletePath(s.getPath()));
+        return null;
+      }
+      newCommit = subOr.rw.parseCommit(ref.getObjectId());
+    }
+
+    if (Objects.equals(newCommit, oldCommit)) {
+      // gitlink have already been updated for this submodule
+      return null;
+    }
+    ed.add(new PathEdit(s.getPath()) {
+      @Override
+      public void apply(DirCacheEntry ent) {
+        ent.setFileMode(FileMode.GITLINK);
+        ent.setObjectId(newCommit.getId());
+      }
+    });
+
+    if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+      createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
+    }
+    subOr.rw.parseBody(newCommit);
+    return newCommit;
+  }
+
+  private void createSubmoduleCommitMsg(StringBuilder msgbuf,
+      SubmoduleSubscription s, OpenRepo subOr, RevCommit newCommit, RevCommit oldCommit)
       throws SubmoduleException {
-    log.error(errorMsg);
-    throw new SubmoduleException(errorMsg);
+    msgbuf.append("* Update " + s.getPath());
+    msgbuf.append(" from branch '" + s.getSubmodule().getShortName() + "'");
+
+    // newly created submodule gitlink, do not append whole history
+    if (oldCommit == null) {
+      return;
+    }
+
+    try {
+      subOr.rw.resetRetain(subOr.canMergeFlag);
+      subOr.rw.markStart(newCommit);
+      subOr.rw.markUninteresting(oldCommit);
+      for (RevCommit c : subOr.rw) {
+        subOr.rw.parseBody(c);
+        if (verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY) {
+          msgbuf.append("\n  - " + c.getShortMessage());
+        } else if (verboseSuperProject == VerboseSuperprojectUpdate.TRUE) {
+          msgbuf.append("\n  - " + c.getFullMessage().replace("\n", "\n    "));
+        }
+      }
+    } catch (IOException e) {
+      throw new SubmoduleException("Could not perform a revwalk to "
+          + "create superproject commit message", e);
+    }
+  }
+
+  private static DirCache readTree(RevWalk rw, ObjectId base)
+      throws IOException {
+    final DirCache dc = DirCache.newInCore();
+    final DirCacheBuilder b = dc.builder();
+    b.addTree(new byte[0], // no prefix path
+        DirCacheEntry.STAGE_0, // standard stage
+        rw.getObjectReader(), rw.parseTree(base));
+    b.finish();
+    return dc;
+  }
+
+  public ImmutableSet<Project.NameKey> getProjectsInOrder()
+      throws SubmoduleException {
+    LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
+    for (Project.NameKey project : branchesByProject.keySet()) {
+      addAllSubmoduleProjects(project, new LinkedHashSet<Project.NameKey>(), projects);
+    }
+
+    for (Branch.NameKey branch : updatedBranches) {
+      projects.add(branch.getParentKey());
+    }
+    return ImmutableSet.copyOf(projects);
+  }
+
+  private void addAllSubmoduleProjects(Project.NameKey project,
+      LinkedHashSet<Project.NameKey> current,
+      LinkedHashSet<Project.NameKey> projects)
+      throws SubmoduleException {
+    if (current.contains(project)) {
+      throw new SubmoduleException(
+          "Project level circular subscriptions detected:  " +
+              printCircularPath(current, project));
+    }
+
+    if (projects.contains(project)) {
+      return;
+    }
+
+    current.add(project);
+    Set<Project.NameKey> subprojects = new HashSet<>();
+    for (Branch.NameKey branch : branchesByProject.get(project)) {
+      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+      for (SubmoduleSubscription s : subscriptions) {
+        subprojects.add(s.getSubmodule().getParentKey());
+      }
+    }
+
+    for (Project.NameKey p : subprojects) {
+      addAllSubmoduleProjects(p, current, projects);
+    }
+
+    current.remove(project);
+    projects.add(project);
+  }
+
+  public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
+    LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
+    if (sortedBranches != null) {
+      branches.addAll(sortedBranches);
+    }
+    branches.addAll(updatedBranches);
+    return ImmutableSet.copyOf(branches);
+  }
+
+  public boolean hasSubscription(Branch.NameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+    branchTips.put(branch, tip);
+  }
+
+  public void addOp(BatchUpdate bu, Branch.NameKey branch) {
+    bu.addRepoOnlyOp(new GitlinkOp(branch));
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(orm.getSubmissionId() + msg, args);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
index 3cbac3b..d77c7e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
@@ -28,7 +28,7 @@
 
 public class TabFile {
   public interface Parser {
-    public String parse(String str);
+    String parse(String str);
   }
 
   public static Parser TRIM = new Parser() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
index 8be0a10..bc805c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
@@ -67,9 +67,8 @@
     long local = p.getMaxObjectSizeLimit();
     if (global > 0 && local > 0) {
       return Math.min(global, local);
-    } else {
-      // zero means "no limit", in this case the max is more limiting
-      return Math.max(global, local);
     }
+    // zero means "no limit", in this case the max is more limiting
+    return Math.max(global, local);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
new file mode 100644
index 0000000..d4fcbcd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.storage.pack.PackStatistics;
+import org.eclipse.jgit.transport.PostUploadHook;
+
+@Singleton
+public class UploadPackMetricsHook implements PostUploadHook {
+  enum Operation {
+    CLONE,
+    FETCH;
+  }
+
+  private final Counter1<Operation> requestCount;
+  private final Timer1<Operation> counting;
+  private final Timer1<Operation> compressing;
+  private final Timer1<Operation> writing;
+  private final Histogram1<Operation> packBytes;
+
+  @Inject
+  UploadPackMetricsHook(MetricMaker metricMaker) {
+    Field<Operation> operation = Field.ofEnum(Operation.class, "operation");
+    requestCount = metricMaker.newCounter(
+        "git/upload-pack/request_count",
+        new Description("Total number of git-upload-pack requests")
+          .setRate()
+          .setUnit("requests"),
+        operation);
+
+    counting = metricMaker.newTimer(
+        "git/upload-pack/phase_counting",
+        new Description("Time spent in the 'Counting...' phase")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    compressing = metricMaker.newTimer(
+        "git/upload-pack/phase_compressing",
+        new Description("Time spent in the 'Compressing...' phase")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    writing = metricMaker.newTimer(
+        "git/upload-pack/phase_writing",
+        new Description("Time spent transferring bytes to client")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    packBytes = metricMaker.newHistogram(
+        "git/upload-pack/pack_bytes",
+        new Description("Distribution of sizes of packs sent to clients")
+          .setCumulative()
+          .setUnit(Units.BYTES),
+        operation);
+  }
+
+  @Override
+  public void onPostUpload(PackStatistics stats) {
+    Operation op = Operation.FETCH;
+    if (stats.getUninterestingObjects() == null
+        || stats.getUninterestingObjects().isEmpty()) {
+      op = Operation.CLONE;
+    }
+
+    requestCount.increment(op);
+    counting.record(op, stats.getTimeCounting(), MILLISECONDS);
+    compressing.record(op, stats.getTimeCompressing(), MILLISECONDS);
+    writing.record(op, stats.getTimeWriting(), MILLISECONDS);
+    packBytes.record(op, stats.getTotalBytes());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
index a1c5b8a..a09466d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
@@ -16,9 +16,19 @@
 
 public class UserConfigSections {
 
+  /** The general user preferences. */
+  public static final String GENERAL = "general";
+
   /** The my menu user preferences. */
   public static final String MY = "my";
 
+  public static final String KEY_URL = "url";
+  public static final String KEY_TARGET = "target";
+  public static final String KEY_ID = "id";
+  public static final String URL_ALIAS = "urlAlias";
+  public static final String KEY_MATCH = "match";
+  public static final String KEY_TOKEN = "token";
+
   /** The edit user preferences. */
   public static final String EDIT = "edit";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index e7b98d8..c959443 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.Lists;
 
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
@@ -50,6 +49,7 @@
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 
@@ -78,7 +78,7 @@
     }
   }
 
-  private RevCommit revision;
+  protected RevCommit revision;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
   protected DirCache newTree;
@@ -137,12 +137,36 @@
    */
   public void load(Repository db, ObjectId id) throws IOException,
       ConfigInvalidException {
-    reader = db.newObjectReader();
+    try (RevWalk walk = new RevWalk(db)) {
+      load(walk, id);
+    }
+  }
+
+  /**
+   * Load a specific version from an open walk.
+   * <p>
+   * This method is primarily useful for applying updates to a specific revision
+   * that was shown to an end-user in the user interface. If there are conflicts
+   * with another user's concurrent changes, these will be automatically
+   * detected at commit time.
+   * <p>
+   * The caller retains ownership of the walk and is responsible for closing
+   * it. However, this instance does not hold a reference to the walk or the
+   * repository after the call completes, allowing the application to retain
+   * this object for long periods of time.
+   *
+   * @param walk open walk to access to access.
+   * @param id revision to load.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  public void load(RevWalk walk, ObjectId id) throws IOException,
+     ConfigInvalidException {
+    this.reader = walk.getObjectReader();
     try {
       revision = id != null ? new RevWalk(reader).parseCommit(id) : null;
       onLoad();
     } finally {
-      reader.close();
       reader = null;
     }
   }
@@ -263,12 +287,8 @@
           return;
         }
 
-        // Reuse tree from parent commit unless there are contents in newTree or
-        // there is no tree for a parent commit.
-        ObjectId res = newTree.getEntryCount() != 0 || srcTree == null
-            ? newTree.writeTree(inserter) : srcTree.copy();
-        if (res.equals(srcTree) && !update.allowEmpty()
-            && (commit.getTreeId() == null)) {
+        ObjectId res = newTree.writeTree(inserter);
+        if (res.equals(srcTree) && !update.allowEmpty() && (commit.getTreeId() == null)) {
           // If there are no changes to the content, don't create the commit.
           return;
         }
@@ -323,6 +343,15 @@
           case FORCED:
             update.fireGitRefUpdatedEvent(ru);
             return;
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NEW:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
           default:
             throw new IOException("Cannot delete " + ru.getName() + " in "
                 + db.getDirectory() + ": " + ru.getResult());
@@ -391,6 +420,14 @@
             revision = rw.parseCommit(ru.getNewObjectId());
             update.fireGitRefUpdatedEvent(ru);
             return revision;
+          case FORCED:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
           default:
             throw new IOException("Cannot update " + ru.getName() + " in "
                 + db.getDirectory() + ": " + ru.getResult());
@@ -440,9 +477,8 @@
       ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
       return obj.getCachedBytes(Integer.MAX_VALUE);
 
-    } else {
-      return new byte[] {};
     }
+    return new byte[] {};
   }
 
   protected ObjectId getObjectId(String fileName) throws IOException {
@@ -462,7 +498,7 @@
     TreeWalk tw = new TreeWalk(reader);
     tw.addTree(revision.getTree());
     tw.setRecursive(recursive);
-    List<PathInfo> paths = Lists.newArrayList();
+    List<PathInfo> paths = new ArrayList<>();
     while (tw.next()) {
       paths.add(new PathInfo(tw));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 46638f0..c339d70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -14,21 +14,29 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.SymbolicRef;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
@@ -49,17 +57,26 @@
       LoggerFactory.getLogger(VisibleRefFilter.class);
 
   private final TagCache tagCache;
-  private final ChangeCache changeCache;
+  private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final SearchingChangeCacheImpl changeCache;
   private final Repository db;
   private final Project.NameKey projectName;
   private final ProjectControl projectCtl;
   private final ReviewDb reviewDb;
   private final boolean showMetadata;
+  private String userEditPrefix;
+  private Set<Change.Id> visibleChanges;
 
-  public VisibleRefFilter(TagCache tagCache, ChangeCache changeCache,
-      Repository db, ProjectControl projectControl, ReviewDb reviewDb,
+  public VisibleRefFilter(
+      TagCache tagCache,
+      ChangeNotes.Factory changeNotesFactory,
+      @Nullable SearchingChangeCacheImpl changeCache,
+      Repository db,
+      ProjectControl projectControl,
+      ReviewDb reviewDb,
       boolean showMetadata) {
     this.tagCache = tagCache;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
     this.db = db;
     this.projectName = projectControl.getProject().getNameKey();
@@ -69,67 +86,67 @@
   }
 
   public Map<String, Ref> filter(Map<String, Ref> refs, boolean filterTagsSeparately) {
-    if (projectCtl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
-      Map<String, Ref> r = Maps.newHashMap(refs);
-      if (!projectCtl.controlForRef(RefNames.REFS_CONFIG).isVisible()) {
-        r.remove(RefNames.REFS_CONFIG);
-      }
-      return r;
+    if (projectCtl.getProjectState().isAllUsers()) {
+      refs = addUsersSelfSymref(refs);
     }
 
-    Account.Id currAccountId;
-    boolean canViewMetadata;
+    if (projectCtl.allRefsAreVisible(ImmutableSet.of(REFS_CONFIG))) {
+      return fastHideRefsMetaConfig(refs);
+    }
+
+    Account.Id userId;
+    boolean viewMetadata;
     if (projectCtl.getUser().isIdentifiedUser()) {
       IdentifiedUser user = projectCtl.getUser().asIdentifiedUser();
-      currAccountId = user.getAccountId();
-      canViewMetadata = user.getCapabilities().canAccessDatabase();
+      userId = user.getAccountId();
+      viewMetadata = user.getCapabilities().canAccessDatabase();
+      userEditPrefix = RefNames.refsEditPrefix(userId);
     } else {
-      currAccountId = null;
-      canViewMetadata = false;
+      userId = null;
+      viewMetadata = false;
     }
 
-    Set<Change.Id> visibleChanges = visibleChanges();
     Map<String, Ref> result = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
 
     for (Ref ref : refs.values()) {
+      String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
-      if (ref.getName().startsWith(RefNames.REFS_CACHE_AUTOMERGE)) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE)
+          || (!showMetadata && isMetadata(name))) {
         continue;
-      } else if ((accountId = Account.Id.fromRef(ref.getName())) != null) {
-        // Reference related to an account is visible only for the current
-        // account.
-        //
-        // TODO(dborowitz): If a ref matches an account and a change, verify
-        // both (to exclude e.g. edits on changes that the user has lost access
-        // to).
-        if (showMetadata
-            && (canViewMetadata || accountId.equals(currAccountId))) {
-          result.put(ref.getName(), ref);
+      } else if (RefNames.isRefsEdit(name)) {
+        // Edits are visible only to the owning user, if change is visible.
+        if (viewMetadata || visibleEdit(name)) {
+          result.put(name, ref);
         }
-
-      } else if ((changeId = Change.Id.fromRef(ref.getName())) != null) {
-        // Reference related to a change is visible if the change is visible.
-        //
-        if (showMetadata
-            && (canViewMetadata || visibleChanges.contains(changeId))) {
-          result.put(ref.getName(), ref);
+      } else if ((changeId = Change.Id.fromRef(name)) != null) {
+        // Change ref is visible only if the change is visible.
+        if (viewMetadata || visible(changeId)) {
+          result.put(name, ref);
         }
-
+      } else if ((accountId = Account.Id.fromRef(name)) != null) {
+        // Account ref is visible only to corresponding account.
+        if (viewMetadata || (accountId.equals(userId)
+            && projectCtl.controlForRef(name).isVisible())) {
+          result.put(name, ref);
+        }
       } else if (isTag(ref)) {
         // If its a tag, consider it later.
-        //
         if (ref.getObjectId() != null) {
           deferredTags.add(ref);
         }
-
+      } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
+        // Sequences are internal database implementation details.
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
       } else if (projectCtl.controlForRef(ref.getLeaf().getName()).isVisible()) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
-        //
-        result.put(ref.getName(), ref);
+        result.put(name, ref);
       }
     }
 
@@ -151,6 +168,28 @@
     return result;
   }
 
+  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
+    if (refs.containsKey(REFS_CONFIG)
+        && !projectCtl.controlForRef(REFS_CONFIG).isVisible()) {
+      Map<String, Ref> r = new HashMap<>(refs);
+      r.remove(REFS_CONFIG);
+      return r;
+    }
+    return refs;
+  }
+
+  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
+    if (projectCtl.getUser().isIdentifiedUser()) {
+      Ref r = refs.get(RefNames.refsUsers(projectCtl.getUser().getAccountId()));
+      if (r != null) {
+        SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
+        refs = new HashMap<>(refs);
+        refs.put(s.getName(), s);
+      }
+    }
+    return refs;
+  }
+
   @Override
   protected Map<String, Ref> getAdvertisedRefs(Repository repository,
       RevWalk revWalk) throws ServiceMayNotContinueException {
@@ -169,17 +208,36 @@
     return filter(refs, false);
   }
 
-  private Set<Change.Id> visibleChanges() {
-    if (!showMetadata) {
-      return Collections.emptySet();
+  private boolean visible(Change.Id changeId) {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChanges = visibleChangesByScan();
+      } else {
+        visibleChanges = visibleChangesBySearch();
+      }
     }
+    return visibleChanges.contains(changeId);
+  }
 
-    final Project project = projectCtl.getProject();
+  private boolean visibleEdit(String name) {
+    if (userEditPrefix != null && name.startsWith(userEditPrefix)) {
+      Change.Id id = Change.Id.fromEditRefPart(name);
+      if (id != null) {
+        return visible(id);
+      }
+    }
+    return false;
+  }
+
+  private Set<Change.Id> visibleChangesBySearch() {
+    Project project = projectCtl.getProject();
     try {
-      final Set<Change.Id> visibleChanges = new HashSet<>();
-      for (Change change : changeCache.get(project.getNameKey())) {
-        if (projectCtl.controlFor(change).isVisible(reviewDb)) {
-          visibleChanges.add(change.getId());
+      Set<Change.Id> visibleChanges = new HashSet<>();
+      for (ChangeData cd : changeCache.getChangeData(
+          reviewDb, project.getNameKey())) {
+        if (projectCtl.controlForIndexedChange(cd.change())
+            .isVisible(reviewDb, cd)) {
+          visibleChanges.add(cd.getId());
         }
       }
       return visibleChanges;
@@ -190,6 +248,27 @@
     }
   }
 
+  private Set<Change.Id> visibleChangesByScan() {
+    Project.NameKey project = projectCtl.getProject().getNameKey();
+    try {
+      Set<Change.Id> visibleChanges = new HashSet<>();
+      for (ChangeNotes cn : changeNotesFactory.scan(db, reviewDb, project)) {
+        if (projectCtl.controlFor(cn).isVisible(reviewDb)) {
+          visibleChanges.add(cn.getChangeId());
+        }
+      }
+      return visibleChanges;
+    } catch (IOException | OrmException e) {
+      log.error("Cannot load changes for project " + project
+          + ", assuming no changes are visible", e);
+      return Collections.emptySet();
+    }
+  }
+
+  private static boolean isMetadata(String name) {
+    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+  }
+
   private static boolean isTag(Ref ref) {
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
index 7d82d32..a0f729a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Project;
@@ -39,6 +38,7 @@
 import java.util.concurrent.Delayed;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadFactory;
@@ -128,7 +128,7 @@
   }
 
   public <T> List<T> getTaskInfos(TaskInfoFactory<T> factory) {
-    List<T> taskInfos = Lists.newArrayList();
+    List<T> taskInfos = new ArrayList<>();
     for (Executor exe : queues) {
       for (Task<?> task : exe.getTasks()) {
         taskInfos.add(factory.getTaskInfo(task));
@@ -146,14 +146,22 @@
         if (result != null) {
           // Don't return the task if we have a duplicate. Lie instead.
           return null;
-        } else {
-          result = t;
         }
+        result = t;
       }
     }
     return result;
   }
 
+  public Executor getExecutor(String queueName) {
+    for (Executor e : queues) {
+      if (e.queueName.equals(queueName)) {
+        return e;
+      }
+    }
+    return null;
+  }
+
   private void stop() {
     for (final Executor p : queues) {
       p.shutdown();
@@ -172,8 +180,9 @@
   /** An isolated queue. */
   public class Executor extends ScheduledThreadPoolExecutor {
     private final ConcurrentHashMap<Integer, Task<?>> all;
+    private final String queueName;
 
-    Executor(final int corePoolSize, final String prefix) {
+    Executor(int corePoolSize, final String prefix) {
       super(corePoolSize, new ThreadFactory() {
         private final ThreadFactory parent = Executors.defaultThreadFactory();
         private final AtomicInteger tid = new AtomicInteger(1);
@@ -192,6 +201,7 @@
           0.75f, // load factor
           corePoolSize + 4 // concurrency level
           );
+      queueName = prefix;
     }
 
     public void unregisterWorkQueue() {
@@ -242,10 +252,27 @@
     }
   }
 
-  /** Runnable needing to know it was canceled. */
+  /**
+   * Runnable needing to know it was canceled.
+   * Note that cancel is called only in case the task is not in
+   * progress already.
+   */
   public interface CancelableRunnable extends Runnable {
     /** Notifies the runnable it was canceled. */
-    public void cancel();
+    void cancel();
+  }
+
+  /**
+   * Base interface handles the case when task was canceled before
+   * actual execution and in case it was started cancel method is
+   * not called yet the task itself will be destroyed anyway (it
+   * will result in resource opening errors).
+   * This interface gives a chance to implementing classes for
+   * handling such scenario and act accordingly.
+   */
+  public interface CanceledWhileRunning extends CancelableRunnable {
+    /** Notifies the runnable it was canceled during execution. **/
+    void setCanceledWhileRunning();
   }
 
   /** A wrapper around a scheduled Runnable, as maintained in the queue. */
@@ -261,7 +288,7 @@
      * <li>{@link #DONE}: finished executing, if not periodic.</li>
      * </ol>
      */
-    public static enum State {
+    public enum State {
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
       // running before ready, ready before sleeping.
@@ -302,15 +329,18 @@
       final long delay = getDelay(TimeUnit.MILLISECONDS);
       if (delay <= 0) {
         return State.READY;
-      } else {
-        return State.SLEEPING;
       }
+      return State.SLEEPING;
     }
 
     public Date getStartTime() {
       return startTime;
     }
 
+    public String getQueueName() {
+      return executor.queueName;
+    }
+
     @Override
     public boolean cancel(boolean mayInterruptIfRunning) {
       if (task.cancel(mayInterruptIfRunning)) {
@@ -320,17 +350,28 @@
         // as running and allow it to clean up. This ensures we do
         // not invoke cancel twice.
         //
-        if (runnable instanceof CancelableRunnable
-            && running.compareAndSet(false, true)) {
-          ((CancelableRunnable) runnable).cancel();
+        if (runnable instanceof CancelableRunnable) {
+          if (running.compareAndSet(false, true)) {
+            ((CancelableRunnable) runnable).cancel();
+          } else if (runnable instanceof CanceledWhileRunning) {
+            ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
+          }
         }
+        if (runnable instanceof Future<?>) {
+          // Creating new futures eventually passes through
+          // AbstractExecutorService#schedule, which will convert the Guava
+          // Future to a Runnable, thereby making it impossible for the
+          // cancellation to propagate from ScheduledThreadPool's task back to
+          // the Guava future, so kludge it here.
+          ((Future<?>) runnable).cancel(mayInterruptIfRunning);
+        }
+
         executor.remove(this);
         executor.purge();
         return true;
 
-      } else {
-        return false;
       }
+      return false;
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index ecac2b35..31da05c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -14,135 +14,85 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CommitMergeStatus;
-import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 public class CherryPick extends SubmitStrategy {
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final Map<Change.Id, CodeReviewCommit> newCommits;
 
-  CherryPick(SubmitStrategy.Arguments args,
-      PatchSetInfoFactory patchSetInfoFactory) {
+  CherryPick(SubmitStrategy.Arguments args) {
     super(args);
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.newCommits = new HashMap<>();
   }
 
   @Override
-  protected MergeTip _run(CodeReviewCommit branchTip,
+  public List<SubmitStrategyOp> buildOps(
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    MergeTip mergeTip = new MergeTip(branchTip, toMerge);
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
-    try (BatchUpdate u = args.newBatchUpdate(TimeUtil.nowTs())) {
-      while (!sorted.isEmpty()) {
-        CodeReviewCommit n = sorted.remove(0);
-        Change.Id cid = n.change().getId();
-        if (first && branchTip == null) {
-          u.addOp(cid, new CherryPickUnbornRootOp(mergeTip, n));
-        } else if (n.getParentCount() == 0) {
-          u.addOp(cid, new CherryPickRootOp(n));
-        } else if (n.getParentCount() == 1) {
-          u.addOp(cid, new CherryPickOneOp(mergeTip, n));
-        } else {
-          u.addOp(cid, new CherryPickMultipleParentsOp(mergeTip, n));
-        }
-        first = false;
+    while (!sorted.isEmpty()) {
+      CodeReviewCommit n = sorted.remove(0);
+      if (first && args.mergeTip.getInitialTip() == null) {
+        ops.add(new FastForwardOp(args, n));
+      } else if (n.getParentCount() == 0) {
+        ops.add(new CherryPickRootOp(n));
+      } else if (n.getParentCount() == 1) {
+        ops.add(new CherryPickOneOp(n));
+      } else {
+        ops.add(new CherryPickMultipleParentsOp(n));
       }
-      u.execute();
-    } catch (UpdateException | RestApiException e) {
-      throw new IntegrationException(
-          "Cannot cherry-pick onto " + args.destBranch);
+      first = false;
     }
-    // TODO(dborowitz): When BatchUpdate is hoisted out of CherryPick,
-    // SubmitStrategy should probably no longer return MergeTip, instead just
-    // mutating a single shared MergeTip passed in from the caller.
-    return mergeTip;
+    return ops;
   }
 
-  private static class CherryPickUnbornRootOp extends BatchUpdate.Op {
-    private final MergeTip mergeTip;
-    private final CodeReviewCommit toMerge;
-
-    private CherryPickUnbornRootOp(MergeTip mergeTip,
-        CodeReviewCommit toMerge) {
-      this.mergeTip = mergeTip;
-      this.toMerge = toMerge;
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) {
-      // The branch is unborn. Take fast-forward resolution to create the
-      // branch.
-      mergeTip.moveTipTo(toMerge, toMerge);
-      toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-    }
-  }
-
-  private static class CherryPickRootOp extends BatchUpdate.Op {
-    private final CodeReviewCommit toMerge;
-
+  private class CherryPickRootOp extends SubmitStrategyOp {
     private CherryPickRootOp(CodeReviewCommit toMerge) {
-      this.toMerge = toMerge;
+      super(CherryPick.this.args, toMerge);
     }
 
     @Override
-    public void updateRepo(RepoContext ctx) {
+    public void updateRepoImpl(RepoContext ctx) {
       // Refuse to merge a root commit into an existing branch, we cannot obtain
       // a delta for the cherry-pick to apply.
       toMerge.setStatusCode(CommitMergeStatus.CANNOT_CHERRY_PICK_ROOT);
     }
   }
 
-  private class CherryPickOneOp extends BatchUpdate.Op {
-    private final MergeTip mergeTip;
-    private final CodeReviewCommit toMerge;
-
+  private class CherryPickOneOp extends SubmitStrategyOp {
     private PatchSet.Id psId;
     private CodeReviewCommit newCommit;
     private PatchSetInfo patchSetInfo;
 
-    private CherryPickOneOp(MergeTip mergeTip, CodeReviewCommit n) {
-      this.mergeTip = mergeTip;
-      this.toMerge = n;
+    private CherryPickOneOp(CodeReviewCommit toMerge) {
+      super(CherryPick.this.args, toMerge);
     }
 
     @Override
-    public void updateRepo(RepoContext ctx) throws IOException {
+    protected void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, IOException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
@@ -153,71 +103,67 @@
           args.mergeUtil.createCherryPickCommitMessage(toMerge);
 
       PersonIdent committer = args.caller.newCommitterIdent(
-          ctx.getWhen(), args.serverIdent.get().getTimeZone());
+          ctx.getWhen(), args.serverIdent.getTimeZone());
       try {
         newCommit = args.mergeUtil.createCherryPickFromCommit(
-            args.repo, args.inserter, mergeTip.getCurrentTip(), toMerge,
+            args.repo, args.inserter, args.mergeTip.getCurrentTip(), toMerge,
             committer, cherryPickCmtMsg, args.rw);
-        mergeTip.moveTipTo(newCommit, newCommit);
-        ctx.addRefUpdate(
-            new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
-        patchSetInfo =
-            patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
         // cherry-pick as many commits as possible.
         toMerge.setStatusCode(CommitMergeStatus.PATH_CONFLICT);
+        return;
       } catch (MergeIdenticalTreeException mie) {
-        toMerge.setStatusCode(CommitMergeStatus.ALREADY_MERGED);
+        toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
+        return;
       }
+      // Initial copy doesn't have new patch set ID since change hasn't been
+      // updated yet.
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setPatchsetId(psId);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_PICK);
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commits.put(newCommit);
+
+      ctx.addRefUpdate(
+          new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
+      patchSetInfo =
+          args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException,
-         NoSuchChangeException {
-      if (newCommit == null) {
-        // Merge conflict; don't update change.
-        return;
+    public PatchSet updateChangeImpl(ChangeContext ctx) throws OrmException,
+         NoSuchChangeException, IOException {
+      if (newCommit == null
+          && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
+        return null;
       }
-      PatchSet ps = new PatchSet(psId);
-      ps.setCreatedOn(ctx.getWhen());
-      ps.setUploader(args.caller.getAccountId());
-      ps.setRevision(new RevId(newCommit.getId().getName()));
+      checkNotNull(newCommit,
+          "no new commit produced by CherryPick of %s, expected to fail fast",
+          toMerge.change().getId());
+      PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+      PatchSet newPs = args.psUtil.insert(ctx.getDb(), ctx.getRevWalk(),
+          ctx.getUpdate(psId), psId, newCommit, false,
+          prevPs != null ? prevPs.getGroups() : ImmutableList.<String> of(),
+          null);
+      ctx.getChange().setCurrentPatchSet(patchSetInfo);
 
-      Change c = toMerge.change();
-      ps.setGroups(GroupCollector.getCurrentGroups(args.db, c));
-      args.db.patchSets().insert(Collections.singleton(ps));
-      c.setCurrentPatchSet(patchSetInfo);
-      args.db.changes().update(Collections.singletonList(c));
+      // Don't copy approvals, as this is already taken care of by
+      // SubmitStrategyOp.
 
-      List<PatchSetApproval> approvals = Lists.newArrayList();
-      for (PatchSetApproval a : args.approvalsUtil.byPatchSet(
-          args.db, toMerge.getControl(), toMerge.getPatchsetId())) {
-        approvals.add(new PatchSetApproval(ps.getId(), a));
-      }
-      args.db.patchSetApprovals().insert(approvals);
-
-      newCommit.copyFrom(toMerge);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_PICK);
-      newCommit.setControl(
-          args.changeControlFactory.controlFor(toMerge.change(), args.caller));
-      newCommits.put(c.getId(), newCommit);
-      setRefLogIdent();
+      newCommit.setControl(ctx.getControl());
+      return newPs;
     }
   }
 
-  private class CherryPickMultipleParentsOp extends BatchUpdate.Op {
-    private final MergeTip mergeTip;
-    private final CodeReviewCommit toMerge;
-
-    private CherryPickMultipleParentsOp(MergeTip mergeTip,
-        CodeReviewCommit toMerge) {
-      this.mergeTip = mergeTip;
-      this.toMerge = toMerge;
+  private class CherryPickMultipleParentsOp extends SubmitStrategyOp {
+    private CherryPickMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(CherryPick.this.args, toMerge);
     }
 
     @Override
-    public void updateRepo(RepoContext ctx)
+    public void updateRepoImpl(RepoContext ctx)
         throws IntegrationException, IOException {
       if (args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)) {
         // One or more dependencies were not met. The status was already marked
@@ -229,32 +175,25 @@
       // with that merge present and replaced by an equivalent merge with a
       // different first parent. So instead behave as though MERGE_IF_NECESSARY
       // was configured.
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)) {
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
+          !args.submoduleOp.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent myIdent =
-            new PersonIdent(args.serverIdent.get(), ctx.getWhen());
+        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
         CodeReviewCommit result = args.mergeUtil.mergeOneCommit(myIdent,
-            myIdent, args.repo, args.rw, args.inserter,
-            args.canMergeFlag, args.destBranch, mergeTip.getCurrentTip(),
-            toMerge);
+            myIdent, args.repo, args.rw, args.inserter, args.destBranch,
+            mergeTip.getCurrentTip(), toMerge);
+        result = amendGitlink(result);
         mergeTip.moveTipTo(result, toMerge);
+        args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+            mergeTip.getCurrentTip(), args.alreadyAccepted);
       }
-      RevCommit initialTip = mergeTip.getInitialTip();
-      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          mergeTip.getCurrentTip(), initialTip == null
-              ? ImmutableSet.<RevCommit> of() : ImmutableSet.of(initialTip));
-      setRefLogIdent();
     }
   }
 
-  @Override
-  public Map<Change.Id, CodeReviewCommit> getNewCommits() {
-    return newCommits;
-  }
-
-  @Override
-  public boolean dryRun(CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
     return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo,
         mergeTip, args.rw, toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
new file mode 100644
index 0000000..bb9d359
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+/**
+ * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by
+ * {@link SubmitStrategy} implementations.
+ */
+public enum CommitMergeStatus {
+  CLEAN_MERGE("Change has been successfully merged"),
+
+  CLEAN_PICK("Change has been successfully cherry-picked"),
+
+  CLEAN_REBASE("Change has been successfully rebased"),
+
+  ALREADY_MERGED(""),
+
+  PATH_CONFLICT("Change could not be merged due to a path conflict.\n"
+                  + "\n"
+                  + "Please rebase the change locally and upload the rebased commit for review."),
+
+  REBASE_MERGE_CONFLICT(
+      "Change could not be merged due to a conflict.\n"
+          + "\n"
+          + "Please rebase the change locally and upload the rebased commit for review."),
+
+  SKIPPED_IDENTICAL_TREE(
+      "Marking change merged without cherry-picking to branch, as the resulting commit would be empty."),
+
+  MISSING_DEPENDENCY(""),
+
+  MANUAL_RECURSIVE_MERGE("The change requires a local merge to resolve.\n"
+                       + "\n"
+                       + "Please merge (or rebase) the change locally and upload the resolution for review."),
+
+  CANNOT_CHERRY_PICK_ROOT("Cannot cherry-pick an initial commit onto an existing branch.\n"
+                  + "\n"
+                  + "Please merge the change locally and upload the merge commit for review."),
+
+  CANNOT_REBASE_ROOT("Cannot rebase an initial commit onto an existing branch.\n"
+                   + "\n"
+                   + "Please merge the change locally and upload the merge commit for review."),
+
+  NOT_FAST_FORWARD("Project policy requires all submissions to be a fast-forward.\n"
+                  + "\n"
+                  + "Please rebase the change locally and upload again for review.");
+
+  private String message;
+
+  CommitMergeStatus(String message) {
+    this.message = message;
+  }
+
+  public String getMessage() {
+    return message;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 69943c7..66eb40e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CommitMergeStatus;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeTip;
 
-import org.eclipse.jgit.revwalk.RevCommit;
-
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -31,37 +28,36 @@
   }
 
   @Override
-  protected MergeTip _run(final CodeReviewCommit branchTip,
-      final Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    MergeTip mergeTip = new MergeTip(branchTip, toMerge);
-    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(
-        args.mergeSorter, toMerge);
-    final CodeReviewCommit newMergeTipCommit =
-        args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
-    mergeTip.moveTipTo(newMergeTipCommit, newMergeTipCommit);
-
+  public List<SubmitStrategyOp> buildOps(
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+    List<CodeReviewCommit> sorted =
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    CodeReviewCommit newTipCommit = args.mergeUtil.getFirstFastForward(
+            args.mergeTip.getInitialTip(), args.rw, sorted);
+    if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
+      ops.add(new FastForwardOp(args, newTipCommit));
+    }
     while (!sorted.isEmpty()) {
-      final CodeReviewCommit n = sorted.remove(0);
-      n.setStatusCode(CommitMergeStatus.NOT_FAST_FORWARD);
+      ops.add(new NotFastForwardOp(sorted.remove(0)));
+    }
+    return ops;
+  }
+
+  private class NotFastForwardOp extends SubmitStrategyOp {
+    private NotFastForwardOp(CodeReviewCommit toMerge) {
+      super(FastForwardOnly.this.args, toMerge);
     }
 
-    RevCommit initialTip = mergeTip.getInitialTip();
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-        mergeTip.getCurrentTip(), initialTip == null
-            ? ImmutableSet.<RevCommit> of() : ImmutableSet.of(initialTip));
-    setRefLogIdent();
-
-    return mergeTip;
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      toMerge.setStatusCode(CommitMergeStatus.NOT_FAST_FORWARD);
+    }
   }
 
-  @Override
-  public boolean retryOnLockFailure() {
-    return false;
-  }
-
-  @Override
-  public boolean dryRun(CodeReviewCommit mergeTip,
-      CodeReviewCommit toMerge) throws IntegrationException {
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      throws IntegrationException {
     return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw,
         toMerge);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
new file mode 100644
index 0000000..bb58540
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+
+class FastForwardOp extends SubmitStrategyOp {
+  FastForwardOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    super(args, toMerge);
+  }
+
+  @Override
+  protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
+    args.mergeTip.moveTipTo(toMerge, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
new file mode 100644
index 0000000..12f5993
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.server.git.CodeReviewCommit;
+
+/**
+ * Operation for a change that is implicitly integrated by integrating another
+ * commit.
+ * <p>
+ * Updates the change status and message based on {@link
+ * CodeReviewCommit#getStatusCode()}, but does not touch the repository.
+ */
+class ImplicitIntegrateOp extends SubmitStrategyOp {
+  ImplicitIntegrateOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    super(args, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index bd10970..dfa13dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -14,14 +14,10 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeTip;
 
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -31,41 +27,26 @@
   }
 
   @Override
-  protected MergeTip _run(CodeReviewCommit branchTip,
+  public List<SubmitStrategyOp> buildOps(
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-  List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    MergeTip mergeTip;
-    if (branchTip == null) {
+    List<CodeReviewCommit> sorted =
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
       // create the branch.
-      mergeTip = new MergeTip(sorted.get(0), toMerge);
-      sorted.remove(0);
-    } else {
-      mergeTip = new MergeTip(branchTip, toMerge);
+      CodeReviewCommit first = sorted.remove(0);
+      ops.add(new FastForwardOp(args, first));
     }
     while (!sorted.isEmpty()) {
-      CodeReviewCommit mergedFrom = sorted.remove(0);
-      PersonIdent serverIdent = args.serverIdent.get();
-      PersonIdent caller = args.caller.newCommitterIdent(
-          serverIdent.getWhen(), serverIdent.getTimeZone());
-      CodeReviewCommit newTip =
-          args.mergeUtil.mergeOneCommit(caller, serverIdent,
-              args.repo, args.rw, args.inserter, args.canMergeFlag,
-              args.destBranch, mergeTip.getCurrentTip(), mergedFrom);
-      mergeTip.moveTipTo(newTip, mergedFrom);
+      CodeReviewCommit n = sorted.remove(0);
+      ops.add(new MergeOneOp(args, n));
     }
-
-    RevCommit initialTip = mergeTip.getInitialTip();
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-        mergeTip.getCurrentTip(), initialTip == null
-            ? ImmutableSet.<RevCommit> of() : ImmutableSet.of(initialTip));
-    setRefLogIdent();
-
-    return mergeTip;
+    return ops;
   }
 
-  @Override
-  public boolean dryRun(CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
     return args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip,
         toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index 46a62ea..5b2e213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -14,14 +14,10 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeTip;
 
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -31,45 +27,32 @@
   }
 
   @Override
-  protected MergeTip _run(CodeReviewCommit branchTip,
+  public List<SubmitStrategyOp> buildOps(
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
     List<CodeReviewCommit> sorted =
         args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    MergeTip mergeTip;
-    if (branchTip == null) {
-      // The branch is unborn. Take a fast-forward resolution to
-      // create the branch.
-      mergeTip = new MergeTip(sorted.get(0), toMerge);
-      branchTip = sorted.remove(0);
-    } else {
-      mergeTip = new MergeTip(branchTip, toMerge);
-      branchTip =
-          args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+
+    if (args.mergeTip.getInitialTip() == null || !args.submoduleOp
+        .hasSubscription(args.destBranch)) {
+      CodeReviewCommit firstFastForward = args.mergeUtil.getFirstFastForward(
+          args.mergeTip.getInitialTip(), args.rw, sorted);
+      if (firstFastForward != null &&
+          !firstFastForward.equals(args.mergeTip.getInitialTip())) {
+        ops.add(new FastForwardOp(args, firstFastForward));
+      }
     }
-    mergeTip.moveTipTo(branchTip, branchTip);
 
     // For every other commit do a pair-wise merge.
     while (!sorted.isEmpty()) {
-      CodeReviewCommit mergedFrom = sorted.remove(0);
-      PersonIdent serverIdent = args.serverIdent.get();
-      PersonIdent caller = args.caller.newCommitterIdent(
-          serverIdent.getWhen(), serverIdent.getTimeZone());
-      branchTip =
-          args.mergeUtil.mergeOneCommit(caller, serverIdent,
-              args.repo, args.rw, args.inserter, args.canMergeFlag,
-              args.destBranch, branchTip, mergedFrom);
-      mergeTip.moveTipTo(branchTip, mergedFrom);
+      CodeReviewCommit n = sorted.remove(0);
+      ops.add(new MergeOneOp(args, n));
     }
-    RevCommit initialTip = mergeTip.getInitialTip();
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag, branchTip,
-        initialTip == null ? ImmutableSet.<RevCommit> of()
-            : ImmutableSet.of(initialTip));
-    setRefLogIdent();
-    return mergeTip;
+    return ops;
   }
 
-  @Override
-  public boolean dryRun(CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
     return args.mergeUtil.canFastForward(
           args.mergeSorter, mergeTip, args.rw, toMerge)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
new file mode 100644
index 0000000..b1590bf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+
+import org.eclipse.jgit.lib.PersonIdent;
+
+import java.io.IOException;
+
+class MergeOneOp extends SubmitStrategyOp {
+  MergeOneOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
+    super(args, toMerge);
+  }
+
+  @Override
+  public void updateRepoImpl(RepoContext ctx)
+      throws IntegrationException, IOException {
+    PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(
+        ctx.getWhen(), ctx.getTimeZone());
+    if (args.mergeTip.getCurrentTip() == null) {
+      throw new IllegalStateException("cannot merge commit " + toMerge.name()
+          + " onto a null tip; expected at least one fast-forward prior to"
+          + " this operation");
+    }
+    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+    // When hoisting BatchUpdate into MergeOp, we will need to teach
+    // BatchUpdate how to produce CodeReviewRevWalks.
+    CodeReviewCommit merged =
+        args.mergeUtil.mergeOneCommit(caller, args.serverIdent,
+            ctx.getRepository(), args.rw, ctx.getInserter(), args.destBranch,
+            args.mergeTip.getCurrentTip(), toMerge);
+    args.mergeTip.moveTipTo(amendGitlink(merged), toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
index 0bb669b..f8772d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
@@ -14,62 +14,43 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CommitMergeStatus;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 public class RebaseIfNecessary extends SubmitStrategy {
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final RebaseChangeOp.Factory rebaseFactory;
-  private final Map<Change.Id, CodeReviewCommit> newCommits;
 
-  RebaseIfNecessary(SubmitStrategy.Arguments args,
-      PatchSetInfoFactory patchSetInfoFactory,
-      RebaseChangeOp.Factory rebaseFactory) {
+  RebaseIfNecessary(SubmitStrategy.Arguments args) {
     super(args);
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.rebaseFactory = rebaseFactory;
-    this.newCommits = new HashMap<>();
-  }
-
-  private PersonIdent getSubmitterIdent() {
-    PersonIdent serverIdent = args.serverIdent.get();
-    return args.caller.newCommitterIdent(
-        serverIdent.getWhen(), serverIdent.getTimeZone());
   }
 
   @Override
-  protected MergeTip _run(final CodeReviewCommit branchTip,
-      final Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    MergeTip mergeTip = new MergeTip(branchTip, toMerge);
-    List<CodeReviewCommit> sorted = sort(toMerge, branchTip);
+  public List<SubmitStrategyOp> buildOps(
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+    List<CodeReviewCommit> sorted = sort(toMerge, args.mergeTip.getCurrentTip());
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
 
     for (CodeReviewCommit c : sorted) {
       if (c.getParentCount() > 1) {
@@ -84,93 +65,143 @@
 
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
-
-      if (mergeTip.getCurrentTip() == null) {
-        // The branch is unborn. Take a fast-forward resolution to
-        // create the branch.
-        //
-        n.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-        mergeTip.moveTipTo(n, n);
-
+      if (first && args.mergeTip.getInitialTip() == null) {
+        ops.add(new FastForwardOp(args, n));
       } else if (n.getParentCount() == 0) {
-        // Refuse to merge a root commit into an existing branch,
-        // we cannot obtain a delta for the rebase to apply.
-        //
-        n.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
-
+        ops.add(new RebaseRootOp(n));
       } else if (n.getParentCount() == 1) {
-        if (args.mergeUtil.canFastForward(args.mergeSorter,
-            mergeTip.getCurrentTip(), args.rw, n)) {
-          n.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-          mergeTip.moveTipTo(n, n);
-
-        } else {
-          try {
-            PatchSet newPatchSet = rebase(n, mergeTip);
-            List<PatchSetApproval> approvals = Lists.newArrayList();
-            for (PatchSetApproval a : args.approvalsUtil.byPatchSet(args.db,
-                n.getControl(), n.getPatchsetId())) {
-              approvals.add(new PatchSetApproval(newPatchSet.getId(), a));
-            }
-            // rebaseChange.rebase() may already have copied some approvals,
-            // use upsert, not insert, to avoid constraint violation on database
-            args.db.patchSetApprovals().upsert(approvals);
-            CodeReviewCommit newTip = args.rw.parseCommit(
-                ObjectId.fromString(newPatchSet.getRevision().get()));
-            mergeTip.moveTipTo(newTip, newTip);
-            n.change().setCurrentPatchSet(
-                patchSetInfoFactory.get(args.rw, mergeTip.getCurrentTip(),
-                    newPatchSet.getId()));
-            mergeTip.getCurrentTip().copyFrom(n);
-            mergeTip.getCurrentTip().setControl(
-                args.changeControlFactory.controlFor(n.change(), args.caller));
-            mergeTip.getCurrentTip().setPatchsetId(newPatchSet.getId());
-            mergeTip.getCurrentTip().setStatusCode(
-                CommitMergeStatus.CLEAN_REBASE);
-            newCommits.put(newPatchSet.getId().getParentKey(),
-                mergeTip.getCurrentTip());
-            setRefLogIdent();
-          } catch (MergeConflictException e) {
-            n.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-            throw new IntegrationException(
-                "Cannot rebase " + n.name() + ": " + e.getMessage(), e);
-          } catch (NoSuchChangeException | OrmException | IOException
-              | RestApiException | UpdateException e) {
-            throw new IntegrationException("Cannot rebase " + n.name(), e);
-          }
-        }
-
-      } else if (n.getParentCount() > 1) {
-        // There are multiple parents, so this is a merge commit. We
-        // don't want to rebase the merge as clients can't easily
-        // rebase their history with that merge present and replaced
-        // by an equivalent merge with a different first parent. So
-        // instead behave as though MERGE_IF_NECESSARY was configured.
-        //
-        try {
-          if (args.rw.isMergedInto(mergeTip.getCurrentTip(), n)) {
-            mergeTip.moveTipTo(n, n);
-          } else {
-            PersonIdent myIdent = getSubmitterIdent();
-            mergeTip.moveTipTo(
-                args.mergeUtil.mergeOneCommit(myIdent, myIdent,
-                    args.repo, args.rw, args.inserter, args.canMergeFlag,
-                    args.destBranch, mergeTip.getCurrentTip(), n), n);
-          }
-          RevCommit initialTip = mergeTip.getInitialTip();
-          args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-              mergeTip.getCurrentTip(), initialTip == null ?
-                  ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
-          setRefLogIdent();
-        } catch (IOException e) {
-          throw new IntegrationException("Cannot merge " + n.name(), e);
-        }
+        ops.add(new RebaseOneOp(n));
+      } else {
+        ops.add(new RebaseMultipleParentsOp(n));
       }
+      first = false;
+    }
+    return ops;
+  }
 
-      args.alreadyAccepted.add(mergeTip.getCurrentTip());
+  private class RebaseRootOp extends SubmitStrategyOp {
+    private RebaseRootOp(CodeReviewCommit toMerge) {
+      super(RebaseIfNecessary.this.args, toMerge);
     }
 
-    return mergeTip;
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      // Refuse to merge a root commit into an existing branch, we cannot obtain
+      // a delta for the cherry-pick to apply.
+      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
+    }
+  }
+
+  private class RebaseOneOp extends SubmitStrategyOp {
+    private RebaseChangeOp rebaseOp;
+    private CodeReviewCommit newCommit;
+
+    private RebaseOneOp(CodeReviewCommit toMerge) {
+      super(RebaseIfNecessary.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, InvalidChangeOperationException,
+        RestApiException, IOException, OrmException {
+      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+      // When hoisting BatchUpdate into MergeOp, we will need to teach
+      // BatchUpdate how to produce CodeReviewRevWalks.
+      if (args.mergeUtil
+          .canFastForward(args.mergeSorter, args.mergeTip.getCurrentTip(),
+              args.rw, toMerge)) {
+        args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+        toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+        acceptMergeTip(args.mergeTip);
+        return;
+      }
+
+      // Stale read of patch set is ok; see comments in RebaseChangeOp.
+      PatchSet origPs = args.psUtil.get(
+          ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
+      rebaseOp = args.rebaseFactory.create(
+            toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+          .setFireRevisionCreated(false)
+          // Bypass approval copier since SubmitStrategyOp copy all approvals
+          // later anyway.
+          .setCopyApprovals(false)
+          .setValidatePolicy(CommitValidators.Policy.NONE)
+          .setCheckAddPatchSetPermission(false);
+      try {
+        rebaseOp.updateRepo(ctx);
+      } catch (MergeConflictException | NoSuchChangeException e) {
+        toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+        throw new IntegrationException(
+            "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+      }
+      newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
+      newCommit.setPatchsetId(rebaseOp.getPatchSetId());
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commits.put(args.mergeTip.getCurrentTip());
+      acceptMergeTip(args.mergeTip);
+    }
+
+    @Override
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws NoSuchChangeException, ResourceConflictException,
+        OrmException, IOException  {
+      if (rebaseOp == null) {
+        // Took the fast-forward option, nothing to do.
+        return null;
+      }
+
+      rebaseOp.updateChange(ctx);
+      ctx.getChange().setCurrentPatchSet(
+          args.patchSetInfoFactory.get(
+              args.rw, newCommit, rebaseOp.getPatchSetId()));
+      newCommit.setControl(ctx.getControl());
+      return rebaseOp.getPatchSet();
+    }
+
+    @Override
+    public void postUpdateImpl(Context ctx) throws OrmException {
+      if (rebaseOp != null) {
+        rebaseOp.postUpdate(ctx);
+      }
+    }
+  }
+
+  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
+    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(RebaseIfNecessary.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, IOException {
+      // There are multiple parents, so this is a merge commit. We don't want
+      // to rebase the merge as clients can't easily rebase their history with
+      // that merge present and replaced by an equivalent merge with a different
+      // first parent. So instead behave as though MERGE_IF_NECESSARY was
+      // configured.
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
+          !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
+      } else {
+        PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(
+            ctx.getWhen(), ctx.getTimeZone());
+        CodeReviewCommit newTip = args.mergeUtil.mergeOneCommit(
+            caller, caller, args.repo, args.rw,
+            args.inserter, args.destBranch, mergeTip.getCurrentTip(), toMerge);
+        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
+      }
+      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+          mergeTip.getCurrentTip(), args.alreadyAccepted);
+      acceptMergeTip(mergeTip);
+    }
+  }
+
+  private void acceptMergeTip(MergeTip mergeTip) {
+    args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
 
   private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort,
@@ -183,29 +214,8 @@
     }
   }
 
-  private PatchSet rebase(CodeReviewCommit n, MergeTip mergeTip)
-      throws RestApiException, UpdateException, OrmException {
-    RebaseChangeOp op = rebaseFactory.create(
-          n.getControl(),
-          args.db.patchSets().get(n.getPatchsetId()),
-          mergeTip.getCurrentTip().name())
-        .setCommitterIdent(getSubmitterIdent())
-        .setRunHooks(false)
-        .setValidatePolicy(CommitValidators.Policy.NONE);
-    try (BatchUpdate bu = args.newBatchUpdate(TimeUtil.nowTs())) {
-      bu.addOp(n.change().getId(), op);
-      bu.execute();
-    }
-    return op.getPatchSet();
-  }
-
-  @Override
-  public Map<Change.Id, CodeReviewCommit> getNewCommits() {
-    return newCommits;
-  }
-
-  @Override
-  public boolean dryRun(CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
new file mode 100644
index 0000000..c784379
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeSorter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Dry run of a submit strategy. */
+public class SubmitDryRun {
+  private static final Logger log = LoggerFactory.getLogger(SubmitDryRun.class);
+
+  static class Arguments {
+    final Repository repo;
+    final CodeReviewRevWalk rw;
+    final MergeUtil mergeUtil;
+    final MergeSorter mergeSorter;
+
+    Arguments(Repository repo,
+        CodeReviewRevWalk rw,
+        MergeUtil mergeUtil,
+        MergeSorter mergeSorter) {
+      this.repo = repo;
+      this.rw = rw;
+      this.mergeUtil = mergeUtil;
+      this.mergeSorter = mergeSorter;
+    }
+  }
+
+  public static Iterable<ObjectId> getAlreadyAccepted(Repository repo)
+      throws IOException {
+    return FluentIterable
+        .from(repo.getRefDatabase().getRefs(Constants.R_HEADS).values())
+        .append(repo.getRefDatabase().getRefs(Constants.R_TAGS).values())
+        .transform(new Function<Ref, ObjectId>() {
+          @Override
+          public ObjectId apply(Ref r) {
+            return r.getObjectId();
+          }
+        });
+  }
+
+  public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
+      throws IOException {
+    Set<RevCommit> accepted = new HashSet<>();
+    addCommits(getAlreadyAccepted(repo), rw, accepted);
+    return accepted;
+  }
+
+  public static void addCommits(Iterable<ObjectId> ids, RevWalk rw,
+      Collection<RevCommit> out) throws IOException {
+    for (ObjectId id : ids) {
+      RevObject obj = rw.parseAny(id);
+      if (obj instanceof RevCommit) {
+        out.add((RevCommit) obj);
+      }
+    }
+  }
+
+  private final ProjectCache projectCache;
+  private final MergeUtil.Factory mergeUtilFactory;
+
+  @Inject
+  SubmitDryRun(ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory) {
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+  }
+
+  public boolean run(SubmitType submitType, Repository repo,
+      CodeReviewRevWalk rw, Branch.NameKey destBranch, ObjectId tip,
+      ObjectId toMerge, Set<RevCommit> alreadyAccepted)
+      throws IntegrationException, NoSuchProjectException, IOException {
+    CodeReviewCommit tipCommit = rw.parseCommit(tip);
+    CodeReviewCommit toMergeCommit = rw.parseCommit(toMerge);
+    RevFlag canMerge = rw.newFlag("CAN_MERGE");
+    toMergeCommit.add(canMerge);
+    Arguments args = new Arguments(repo, rw,
+        mergeUtilFactory.create(getProject(destBranch)),
+        new MergeSorter(rw, alreadyAccepted, canMerge));
+
+    switch (submitType) {
+      case CHERRY_PICK:
+        return CherryPick.dryRun(args, tipCommit, toMergeCommit);
+      case FAST_FORWARD_ONLY:
+        return FastForwardOnly.dryRun(args, tipCommit, toMergeCommit);
+      case MERGE_ALWAYS:
+        return MergeAlways.dryRun(args, tipCommit, toMergeCommit);
+      case MERGE_IF_NECESSARY:
+        return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+      case REBASE_IF_NECESSARY:
+        return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+      default:
+        String errorMsg = "No submit strategy for: " + submitType;
+        log.error(errorMsg);
+        throw new IntegrationException(errorMsg);
+    }
+  }
+
+  private ProjectState getProject(Branch.NameKey branch)
+      throws NoSuchProjectException {
+    ProjectState p = projectCache.get(branch.getParentKey());
+    if (p == null) {
+      throw new NoSuchProjectException(branch.getParentKey());
+    }
+    return p;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index 5215f55..36de70e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -14,36 +14,54 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.EmailMerge;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.LabelNormalizer;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
 import com.google.gerrit.server.git.MergeSorter;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.inject.Provider;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Module;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
-import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Map;
+import java.util.List;
 import java.util.Set;
 
 /**
@@ -53,155 +71,182 @@
  * commits should be merged.
  */
 public abstract class SubmitStrategy {
+  public static Module module() {
+    return new FactoryModule() {
+      @Override
+      protected void configure() {
+        factory(SubmitStrategy.Arguments.Factory.class);
+      }
+    };
+  }
+
   static class Arguments {
-    protected final IdentifiedUser.GenericFactory identifiedUserFactory;
-    protected final Provider<PersonIdent> serverIdent;
-    protected final ReviewDb db;
-    protected final BatchUpdate.Factory batchUpdateFactory;
-    protected final ChangeControl.GenericFactory changeControlFactory;
+    interface Factory {
+      Arguments create(
+          SubmitType submitType,
+          Branch.NameKey destBranch,
+          CommitStatus commits,
+          CodeReviewRevWalk rw,
+          IdentifiedUser caller,
+          MergeTip mergeTip,
+          ObjectInserter inserter,
+          Repository repo,
+          RevFlag canMergeFlag,
+          ReviewDb db,
+          Set<RevCommit> alreadyAccepted,
+          RequestId submissionId,
+          NotifyHandling notifyHandling,
+          SubmoduleOp submoduleOp);
+    }
 
-    protected final Repository repo;
-    protected final CodeReviewRevWalk rw;
-    protected final ObjectInserter inserter;
-    protected final RevFlag canMergeFlag;
-    protected final Set<RevCommit> alreadyAccepted;
-    protected final Branch.NameKey destBranch;
-    protected final ApprovalsUtil approvalsUtil;
-    protected final MergeUtil mergeUtil;
-    protected final ChangeIndexer indexer;
-    protected final MergeSorter mergeSorter;
-    protected final IdentifiedUser caller;
+    final AccountCache accountCache;
+    final ApprovalsUtil approvalsUtil;
+    final BatchUpdate.Factory batchUpdateFactory;
+    final ChangeControl.GenericFactory changeControlFactory;
+    final ChangeMerged changeMerged;
+    final ChangeMessagesUtil cmUtil;
+    final EmailMerge.Factory mergedSenderFactory;
+    final GitRepositoryManager repoManager;
+    final LabelNormalizer labelNormalizer;
+    final PatchSetInfoFactory patchSetInfoFactory;
+    final PatchSetUtil psUtil;
+    final ProjectCache projectCache;
+    final PersonIdent serverIdent;
+    final RebaseChangeOp.Factory rebaseFactory;
+    final TagCache tagCache;
 
-    Arguments(IdentifiedUser.GenericFactory identifiedUserFactory,
-        Provider<PersonIdent> serverIdent, ReviewDb db,
+    final Branch.NameKey destBranch;
+    final CodeReviewRevWalk rw;
+    final CommitStatus commits;
+    final IdentifiedUser caller;
+    final MergeTip mergeTip;
+    final ObjectInserter inserter;
+    final Repository repo;
+    final RevFlag canMergeFlag;
+    final ReviewDb db;
+    final Set<RevCommit> alreadyAccepted;
+    final RequestId submissionId;
+    final SubmitType submitType;
+    final NotifyHandling notifyHandling;
+    final SubmoduleOp submoduleOp;
+
+    final ProjectState project;
+    final MergeSorter mergeSorter;
+    final MergeUtil mergeUtil;
+
+    @AssistedInject
+    Arguments(
+        AccountCache accountCache,
+        ApprovalsUtil approvalsUtil,
         BatchUpdate.Factory batchUpdateFactory,
-        ChangeControl.GenericFactory changeControlFactory, Repository repo,
-        CodeReviewRevWalk rw, ObjectInserter inserter, RevFlag canMergeFlag,
-        Set<RevCommit> alreadyAccepted, Branch.NameKey destBranch,
-        ApprovalsUtil approvalsUtil, MergeUtil mergeUtil,
-        ChangeIndexer indexer, IdentifiedUser caller) {
-      this.identifiedUserFactory = identifiedUserFactory;
-      this.serverIdent = serverIdent;
-      this.db = db;
+        ChangeControl.GenericFactory changeControlFactory,
+        ChangeMerged changeMerged,
+        ChangeMessagesUtil cmUtil,
+        EmailMerge.Factory mergedSenderFactory,
+        GitRepositoryManager repoManager,
+        LabelNormalizer labelNormalizer,
+        MergeUtil.Factory mergeUtilFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        PatchSetUtil psUtil,
+        @GerritPersonIdent PersonIdent serverIdent,
+        ProjectCache projectCache,
+        RebaseChangeOp.Factory rebaseFactory,
+        TagCache tagCache,
+        @Assisted Branch.NameKey destBranch,
+        @Assisted CommitStatus commits,
+        @Assisted CodeReviewRevWalk rw,
+        @Assisted IdentifiedUser caller,
+        @Assisted MergeTip mergeTip,
+        @Assisted ObjectInserter inserter,
+        @Assisted Repository repo,
+        @Assisted RevFlag canMergeFlag,
+        @Assisted ReviewDb db,
+        @Assisted Set<RevCommit> alreadyAccepted,
+        @Assisted RequestId submissionId,
+        @Assisted SubmitType submitType,
+        @Assisted NotifyHandling notifyHandling,
+        @Assisted SubmoduleOp submoduleOp) {
+      this.accountCache = accountCache;
+      this.approvalsUtil = approvalsUtil;
       this.batchUpdateFactory = batchUpdateFactory;
       this.changeControlFactory = changeControlFactory;
+      this.changeMerged = changeMerged;
+      this.mergedSenderFactory = mergedSenderFactory;
+      this.repoManager = repoManager;
+      this.cmUtil = cmUtil;
+      this.labelNormalizer = labelNormalizer;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.psUtil = psUtil;
+      this.projectCache = projectCache;
+      this.rebaseFactory = rebaseFactory;
+      this.tagCache = tagCache;
 
-      this.repo = repo;
-      this.rw = rw;
-      this.inserter = inserter;
-      this.canMergeFlag = canMergeFlag;
-      this.alreadyAccepted = alreadyAccepted;
+      this.serverIdent = serverIdent;
       this.destBranch = destBranch;
-      this.approvalsUtil = approvalsUtil;
-      this.mergeUtil = mergeUtil;
-      this.indexer = indexer;
-      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag);
+      this.commits = commits;
+      this.rw = rw;
       this.caller = caller;
-    }
+      this.mergeTip = mergeTip;
+      this.inserter = inserter;
+      this.repo = repo;
+      this.canMergeFlag = canMergeFlag;
+      this.db = db;
+      this.alreadyAccepted = alreadyAccepted;
+      this.submissionId = submissionId;
+      this.submitType = submitType;
+      this.notifyHandling = notifyHandling;
+      this.submoduleOp = submoduleOp;
 
-    BatchUpdate newBatchUpdate(Timestamp when) {
-      return batchUpdateFactory
-          .create(db, destBranch.getParentKey(), caller, when)
-          .setRepository(repo, rw, inserter);
+      this.project = checkNotNull(projectCache.get(destBranch.getParentKey()),
+            "project not found: %s", destBranch.getParentKey());
+      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag);
+      this.mergeUtil = mergeUtilFactory.create(project);
     }
   }
 
-  protected final Arguments args;
-
-  private PersonIdent refLogIdent;
+  final Arguments args;
 
   SubmitStrategy(Arguments args) {
-    this.args = args;
+    this.args = checkNotNull(args);
   }
 
   /**
-   * Runs this submit strategy.
+   * Add operations to a batch update that execute this submit strategy.
    * <p>
-   * If possible, the provided commits will be merged with this submit strategy.
+   * Guarantees exactly one op is added to the update for each change in the
+   * input set.
    *
-   * @param currentTip the mergeTip
-   * @param toMerge the list of submitted commits that should be merged using
-   *        this submit strategy. Implementations are responsible for ordering
-   *        of commits, and should not modify the input in place.
-   * @return the new merge tip.
-   * @throws IntegrationException
+   * @param bu batch update to add operations to.
+   * @param toMerge the set of submitted commits that should be merged using
+   *     this submit strategy. Implementations are responsible for ordering of
+   *     commits, and will not modify the input in place.
+   * @throws IntegrationException if an error occurred initializing the
+   *     operations (as opposed to an error during execution, which will be
+   *     reported only when the batch update executes the operations).
    */
-  public final MergeTip run(final CodeReviewCommit currentTip,
-      final Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    refLogIdent = null;
-    checkState(args.caller != null);
-    return _run(currentTip, toMerge);
-  }
+  public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    List<SubmitStrategyOp> ops = buildOps(toMerge);
+    Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
 
-  /** @see #run(CodeReviewCommit, Collection) */
-  protected abstract MergeTip _run(CodeReviewCommit currentTip,
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException;
+    for (SubmitStrategyOp op : ops) {
+      added.add(op.getCommit());
+    }
 
-  /**
-   * Checks whether the given commit can be merged.
-   * <p>
-   * Implementations must ensure that invoking this method modifies neither the
-   * git repository nor the Gerrit database.
-   *
-   * @param mergeTip the merge tip.
-   * @param toMerge the commit that should be checked.
-   * @return {@code true} if the given commit can be merged, otherwise
-   *         {@code false}
-   * @throws IntegrationException
-   */
-  public abstract boolean dryRun(CodeReviewCommit mergeTip,
-      CodeReviewCommit toMerge) throws IntegrationException;
+    // First add ops for any implicitly merged changes.
+    List<CodeReviewCommit> difference =
+        new ArrayList<>(Sets.difference(toMerge, added));
+    Collections.reverse(difference);
+    for (CodeReviewCommit c : difference) {
+      bu.addOp(c.change().getId(), new ImplicitIntegrateOp(args, c));
+    }
 
-  /**
-   * Returns the identity that should be used for reflog entries when updating
-   * the destination branch.
-   * <p>
-   * The reflog identity may only be set during {@link #run(CodeReviewCommit,
-   * Collection)}, and this method is invalid to call beforehand.
-   *
-   * @return the ref log identity, which may be {@code null}.
-   */
-  public final PersonIdent getRefLogIdent() {
-    return refLogIdent;
-  }
-
-  /**
-   * Returns all commits that have been newly created for the changes that are
-   * getting merged.
-   * <p>
-   * By default this method returns an empty map, but subclasses may override
-   * this method to provide any newly created commits.
-   * <p>
-   * This method may only be called after {@link #run(CodeReviewCommit,
-   * Collection)}.
-   *
-   * @return new commits created for changes that were merged.
-   */
-  public Map<Change.Id, CodeReviewCommit> getNewCommits() {
-    return Collections.emptyMap();
-  }
-
-  /**
-   * Returns whether a merge that failed with {@link Result#LOCK_FAILURE} should
-   * be retried.
-   * <p>
-   * May be overridden by subclasses.
-   *
-   * @return {@code true} if a merge that failed with
-   *         {@link Result#LOCK_FAILURE} should be retried, otherwise
-   *         {@code false}
-   */
-  public boolean retryOnLockFailure() {
-    return true;
-  }
-
-  /**
-   * Set the ref log identity if it wasn't set yet.
-   */
-  protected final void setRefLogIdent() {
-    if (refLogIdent == null) {
-      refLogIdent = args.identifiedUserFactory.create(
-          args.caller.getAccountId()).newRefLogIdent();
+    // Then ops for explicitly merged changes
+    for (SubmitStrategyOp op : ops) {
+      bu.addOp(op.getId(), op);
     }
   }
+
+  protected abstract List<SubmitStrategyOp> buildOps(
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index 0c8c2f0..6bb6fa6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -14,29 +14,21 @@
 
 package com.google.gerrit.server.git.strategy;
 
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
@@ -51,55 +43,26 @@
   private static final Logger log = LoggerFactory
       .getLogger(SubmitStrategyFactory.class);
 
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final Provider<PersonIdent> myIdent;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final RebaseChangeOp.Factory rebaseFactory;
-  private final ProjectCache projectCache;
-  private final ApprovalsUtil approvalsUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final ChangeIndexer indexer;
+  private final SubmitStrategy.Arguments.Factory argsFactory;
 
   @Inject
-  SubmitStrategyFactory(
-      final IdentifiedUser.GenericFactory identifiedUserFactory,
-      @GerritPersonIdent Provider<PersonIdent> myIdent,
-      final BatchUpdate.Factory batchUpdateFactory,
-      final ChangeControl.GenericFactory changeControlFactory,
-      final PatchSetInfoFactory patchSetInfoFactory,
-      final RebaseChangeOp.Factory rebaseFactory,
-      final ProjectCache projectCache,
-      final ApprovalsUtil approvalsUtil,
-      final MergeUtil.Factory mergeUtilFactory,
-      final ChangeIndexer indexer) {
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.myIdent = myIdent;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.changeControlFactory = changeControlFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.rebaseFactory = rebaseFactory;
-    this.projectCache = projectCache;
-    this.approvalsUtil = approvalsUtil;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.indexer = indexer;
+  SubmitStrategyFactory(SubmitStrategy.Arguments.Factory argsFactory) {
+    this.argsFactory = argsFactory;
   }
 
   public SubmitStrategy create(SubmitType submitType, ReviewDb db,
       Repository repo, CodeReviewRevWalk rw, ObjectInserter inserter,
       RevFlag canMergeFlag, Set<RevCommit> alreadyAccepted,
-      Branch.NameKey destBranch, IdentifiedUser caller)
-      throws IntegrationException, NoSuchProjectException {
-    ProjectState project = getProject(destBranch);
-    SubmitStrategy.Arguments args = new SubmitStrategy.Arguments(
-        identifiedUserFactory, myIdent, db, batchUpdateFactory,
-        changeControlFactory, repo, rw, inserter, canMergeFlag, alreadyAccepted,
-        destBranch,approvalsUtil, mergeUtilFactory.create(project), indexer,
-        caller);
+      Branch.NameKey destBranch, IdentifiedUser caller, MergeTip mergeTip,
+      CommitStatus commits, RequestId submissionId, NotifyHandling notifyHandling,
+      SubmoduleOp submoduleOp)
+      throws IntegrationException {
+    SubmitStrategy.Arguments args = argsFactory.create(submitType, destBranch,
+        commits, rw, caller, mergeTip, inserter, repo, canMergeFlag, db,
+        alreadyAccepted, submissionId, notifyHandling, submoduleOp);
     switch (submitType) {
       case CHERRY_PICK:
-        return new CherryPick(args, patchSetInfoFactory);
+        return new CherryPick(args);
       case FAST_FORWARD_ONLY:
         return new FastForwardOnly(args);
       case MERGE_ALWAYS:
@@ -107,20 +70,11 @@
       case MERGE_IF_NECESSARY:
         return new MergeIfNecessary(args);
       case REBASE_IF_NECESSARY:
-        return new RebaseIfNecessary(args, patchSetInfoFactory, rebaseFactory);
+        return new RebaseIfNecessary(args);
       default:
-        final String errorMsg = "No submit strategy for: " + submitType;
+        String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
         throw new IntegrationException(errorMsg);
     }
   }
-
-  private ProjectState getProject(Branch.NameKey branch)
-      throws NoSuchProjectException {
-    final ProjectState p = projectCache.get(branch.getParentKey());
-    if (p == null) {
-      throw new NoSuchProjectException(branch.getParentKey());
-    }
-    return p;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
new file mode 100644
index 0000000..eedfe70
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeOp.CommitStatus;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+public class SubmitStrategyListener extends BatchUpdate.Listener {
+  private final Collection<SubmitStrategy> strategies;
+  private final CommitStatus commits;
+  private final boolean failAfterRefUpdates;
+
+  public SubmitStrategyListener(SubmitInput input,
+      Collection<SubmitStrategy> strategies, CommitStatus commits) {
+    this.strategies = strategies;
+    this.commits = commits;
+    if (input instanceof TestSubmitInput) {
+      failAfterRefUpdates = ((TestSubmitInput) input).failAfterRefUpdates;
+    } else {
+      failAfterRefUpdates = false;
+    }
+  }
+
+  @Override
+  public void afterUpdateRepos() throws ResourceConflictException {
+    try {
+      markCleanMerges();
+      List<Change.Id> alreadyMerged = checkCommitStatus();
+      findUnmergedChanges(alreadyMerged);
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public void afterRefUpdates() throws ResourceConflictException {
+    if (failAfterRefUpdates) {
+      throw new ResourceConflictException("Failing after ref updates");
+    }
+  }
+
+  private void findUnmergedChanges(List<Change.Id> alreadyMerged)
+      throws ResourceConflictException, IntegrationException {
+    for (SubmitStrategy strategy : strategies) {
+      if (strategy instanceof CherryPick) {
+        // Might have picked a subset of changes, can't do this sanity check.
+        continue;
+      }
+      SubmitStrategy.Arguments args = strategy.args;
+      Set<Change.Id> unmerged = args.mergeUtil.findUnmergedChanges(
+          args.commits.getChangeIds(args.destBranch), args.rw,
+          args.canMergeFlag, args.mergeTip.getInitialTip(),
+          args.mergeTip.getCurrentTip(), alreadyMerged);
+      for (Change.Id id : unmerged) {
+        commits.problem(id,
+            "internal error: change not reachable from new branch tip");
+      }
+    }
+    commits.maybeFailVerbose();
+  }
+
+  private void markCleanMerges() throws IntegrationException {
+    for (SubmitStrategy strategy : strategies) {
+      SubmitStrategy.Arguments args = strategy.args;
+      RevCommit initialTip = args.mergeTip.getInitialTip();
+      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+          args.mergeTip.getCurrentTip(), initialTip == null ?
+              ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
+    }
+  }
+
+  private List<Change.Id> checkCommitStatus() throws ResourceConflictException {
+    List<Change.Id> alreadyMerged =
+        new ArrayList<>(commits.getChangeIds().size());
+    for (Change.Id id : commits.getChangeIds()) {
+      CodeReviewCommit commit = commits.get(id);
+      CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
+      if (s == null) {
+        commits.problem(id,
+            "internal error: change not processed by merge strategy");
+        continue;
+      }
+      switch (s) {
+        case CLEAN_MERGE:
+        case CLEAN_REBASE:
+        case CLEAN_PICK:
+        case SKIPPED_IDENTICAL_TREE:
+          break; // Merge strategy accepted this change.
+
+        case ALREADY_MERGED:
+          // Already an ancestor of tip.
+          alreadyMerged.add(commit.getPatchsetId().getParentKey());
+          break;
+
+        case PATH_CONFLICT:
+        case REBASE_MERGE_CONFLICT:
+        case MANUAL_RECURSIVE_MERGE:
+        case CANNOT_CHERRY_PICK_ROOT:
+        case CANNOT_REBASE_ROOT:
+        case NOT_FAST_FORWARD:
+          // TODO(dborowitz): Reformat these messages to be more appropriate for
+          // short problem descriptions.
+          commits.problem(id,
+              CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
+          break;
+
+        case MISSING_DEPENDENCY:
+          commits.problem(id, "depends on change that was not submitted");
+          break;
+
+        default:
+          commits.problem(id, "unspecified merge failure: " + s);
+          break;
+      }
+    }
+    commits.maybeFailVerbose();
+    return alreadyMerged;
+  }
+
+  @Override
+  public void afterUpdateChanges() throws ResourceConflictException {
+    commits.maybeFail("Error updating status");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
new file mode 100644
index 0000000..d62edb5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -0,0 +1,606 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.LabelNormalizer;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.SubmoduleException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+abstract class SubmitStrategyOp extends BatchUpdate.Op {
+  private static final Logger log =
+      LoggerFactory.getLogger(SubmitStrategyOp.class);
+
+  protected final SubmitStrategy.Arguments args;
+  protected final CodeReviewCommit toMerge;
+
+  private ReceiveCommand command;
+  private PatchSetApproval submitter;
+  private ObjectId mergeResultRev;
+  private PatchSet mergedPatchSet;
+  private Change updatedChange;
+  private CodeReviewCommit alreadyMerged;
+
+  protected SubmitStrategyOp(SubmitStrategy.Arguments args,
+      CodeReviewCommit toMerge) {
+    this.args = args;
+    this.toMerge = toMerge;
+  }
+
+  final Change.Id getId() {
+    return toMerge.change().getId();
+  }
+
+  final CodeReviewCommit getCommit() {
+    return toMerge;
+  }
+
+  protected final Branch.NameKey getDest() {
+    return toMerge.change().getDest();
+  }
+
+  protected final Project.NameKey getProject() {
+    return getDest().getParentKey();
+  }
+
+  @Override
+  public final void updateRepo(RepoContext ctx) throws Exception {
+    logDebug("{}#updateRepo for change {}", getClass().getSimpleName(),
+        toMerge.change().getId());
+    // Run the submit strategy implementation and record the merge tip state so
+    // we can create the ref update.
+    CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
+    alreadyMerged = getAlreadyMergedCommit(ctx);
+    if (alreadyMerged == null) {
+      updateRepoImpl(ctx);
+    } else {
+      logDebug("Already merged as {}", alreadyMerged.name());
+    }
+    CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
+
+    if (Objects.equals(tipBefore, tipAfter)) {
+      logDebug("Did not move tip", getClass().getSimpleName());
+      return;
+    } else if (tipAfter == null) {
+      logDebug("No merge tip, no update to perform");
+      return;
+    }
+    logDebug("Moved tip from {} to {}", tipBefore, tipAfter);
+
+    checkProjectConfig(ctx, tipAfter);
+
+    // Needed by postUpdate, at which point mergeTip will have advanced further,
+    // so it's easier to just snapshot the command.
+    command = new ReceiveCommand(
+        firstNonNull(tipBefore, ObjectId.zeroId()),
+        tipAfter,
+        getDest().get());
+    ctx.addRefUpdate(command);
+    args.submoduleOp.addBranchTip(getDest(), tipAfter);
+  }
+
+  private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
+      throws IntegrationException {
+    String refName = getDest().get();
+    if (RefNames.REFS_CONFIG.equals(refName)) {
+      logDebug("Loading new configuration from {}", RefNames.REFS_CONFIG);
+      try {
+        ProjectConfig cfg = new ProjectConfig(getProject());
+        cfg.load(ctx.getRevWalk(), commit);
+      } catch (Exception e) {
+        throw new IntegrationException("Submit would store invalid"
+            + " project configuration " + commit.name() + " for "
+            + getProject(), e);
+      }
+    }
+  }
+
+  private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx)
+      throws IOException {
+    CodeReviewCommit tip = args.mergeTip.getInitialTip();
+    if (tip == null) {
+      return null;
+    }
+    CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
+    Change.Id id = getId();
+
+    Collection<Ref> refs = ctx.getRepository().getRefDatabase()
+        .getRefs(id.toRefPrefix()).values();
+    List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
+    for (Ref ref : refs) {
+      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+      if (psId == null) {
+        continue;
+      }
+      try {
+        CodeReviewCommit c = rw.parseCommit(ref.getObjectId());
+        c.setPatchsetId(psId);
+        commits.add(c);
+      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+        continue; // Bogus ref, can't be merged into tip so we don't care.
+      }
+    }
+    Collections.sort(commits, ReviewDbUtil.intKeyOrdering().reverse()
+        .onResultOf(
+          new Function<CodeReviewCommit, PatchSet.Id>() {
+            @Override
+            public PatchSet.Id apply(CodeReviewCommit in) {
+              return in.getPatchsetId();
+            }
+          }));
+    CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
+    if (result == null) {
+      return null;
+    }
+
+    // Some patch set of this change is actually merged into the target
+    // branch, most likely because a previous run of MergeOp failed after
+    // updateRepo, during updateChange.
+    //
+    // Do the best we can to clean this up: mark the change as merged and set
+    // the current patch set. Don't touch the dest branch at all. This can
+    // lead to some odd situations like another change in the set merging in
+    // a different patch set of this change, but that's unavoidable at this
+    // point.  At least the change will end up in the right state.
+    //
+    // TODO(dborowitz): Consider deleting later junk patch set refs. They
+    // presumably don't have PatchSets pointing to them.
+    rw.parseBody(result);
+    result.add(args.canMergeFlag);
+    PatchSet.Id psId = result.getPatchsetId();
+    result.copyFrom(toMerge);
+    result.setPatchsetId(psId); // Got overwriten by copyFrom.
+    result.setStatusCode(CommitMergeStatus.ALREADY_MERGED);
+    args.commits.put(result);
+    return result;
+  }
+
+  @Override
+  public final boolean updateChange(ChangeContext ctx) throws Exception {
+    logDebug("{}#updateChange for change {}", getClass().getSimpleName(),
+        toMerge.change().getId());
+    toMerge.setControl(ctx.getControl()); // Update change and notes from ctx.
+    PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
+    PatchSet.Id newPsId;
+
+    if (alreadyMerged != null) {
+      alreadyMerged.setControl(ctx.getControl());
+      mergedPatchSet = getOrCreateAlreadyMergedPatchSet(ctx);
+      newPsId = mergedPatchSet.getId();
+    } else {
+      PatchSet newPatchSet = updateChangeImpl(ctx);
+      newPsId = checkNotNull(ctx.getChange().currentPatchSetId());
+      if (newPatchSet == null) {
+        checkState(oldPsId.equals(newPsId),
+            "patch set advanced from %s to %s but updateChangeImpl did not"
+            + " return new patch set instance", oldPsId, newPsId);
+        // Ok to use stale notes to get the old patch set, which didn't change
+        // during the submit strategy.
+        mergedPatchSet = checkNotNull(
+            args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
+            "missing old patch set %s", oldPsId);
+      } else {
+        PatchSet.Id n = newPatchSet.getId();
+        checkState(!n.equals(oldPsId) && n.equals(newPsId),
+            "current patch was %s and is now %s, but updateChangeImpl returned"
+            + " new patch set instance at %s", oldPsId, newPsId, n);
+        mergedPatchSet = newPatchSet;
+      }
+    }
+
+    Change c = ctx.getChange();
+    Change.Id id = c.getId();
+    CodeReviewCommit commit = args.commits.get(id);
+    checkNotNull(commit, "missing commit for change " + id);
+    CommitMergeStatus s = commit.getStatusCode();
+    checkNotNull(s,
+        "status not set for change " + id
+        + " expected to previously fail fast");
+    logDebug("Status of change {} ({}) on {}: {}", id, commit.name(),
+        c.getDest(), s);
+    setApproval(ctx, args.caller);
+
+    mergeResultRev = alreadyMerged == null
+        ? args.mergeTip.getMergeResults().get(commit)
+        // Our fixup code is not smart enough to find a merge commit
+        // corresponding to the merge result. This results in a different
+        // ChangeMergedEvent in the fixup case, but we'll just live with that.
+        : alreadyMerged;
+    try {
+      setMerged(ctx, message(ctx, commit, s));
+    } catch (OrmException err) {
+      String msg = "Error updating change status for " + id;
+      log.error(msg, err);
+      args.commits.logProblem(id, msg);
+      // It's possible this happened before updating anything in the db, but
+      // it's hard to know for sure, so just return true below to be safe.
+    }
+    updatedChange = c;
+    return true;
+  }
+
+  private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
+      throws IOException, OrmException {
+    PatchSet.Id psId = alreadyMerged.getPatchsetId();
+    logDebug("Fixing up already-merged patch set {}", psId);
+    PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+    ctx.getRevWalk().parseBody(alreadyMerged);
+    ctx.getChange().setCurrentPatchSet(psId,
+        alreadyMerged.getShortMessage(),
+        ctx.getChange().getOriginalSubject());
+    PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    if (existing != null) {
+      logDebug("Patch set row exists, only updating change");
+      return existing;
+    }
+    // No patch set for the already merged commit, although we know it came form
+    // a patch set ref. Fix up the database. Note that this uses the current
+    // user as the uploader, which is as good a guess as any.
+    List<String> groups = prevPs != null
+        ? prevPs.getGroups()
+        : GroupCollector.getDefaultGroups(alreadyMerged);
+    return args.psUtil.insert(ctx.getDb(), ctx.getRevWalk(),
+        ctx.getUpdate(psId), psId, alreadyMerged, false, groups, null);
+  }
+
+  private void setApproval(ChangeContext ctx, IdentifiedUser user)
+      throws OrmException {
+    Change.Id id = ctx.getChange().getId();
+    List<SubmitRecord> records = args.commits.getSubmitRecords(id);
+    PatchSet.Id oldPsId = toMerge.getPatchsetId();
+    PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
+
+    logDebug("Add approval for " + id);
+    ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
+    origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
+    LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
+
+    ChangeUpdate newPsUpdate = ctx.getUpdate(newPsId);
+    newPsUpdate.merge(args.submissionId, records);
+    // If the submit strategy created a new revision (rebase, cherry-pick), copy
+    // approvals as well.
+    if (!newPsId.equals(oldPsId)) {
+      saveApprovals(normalized, ctx, newPsUpdate, true);
+      submitter = convertPatchSet(newPsId).apply(submitter);
+    }
+  }
+
+  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
+      throws OrmException {
+    PatchSet.Id psId = update.getPatchSetId();
+    Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
+    for (PatchSetApproval psa : args.approvalsUtil.byPatchSet(
+        ctx.getDb(), ctx.getControl(), psId)) {
+      byKey.put(psa.getKey(), psa);
+    }
+
+    submitter = new PatchSetApproval(
+          new PatchSetApproval.Key(
+              psId,
+              ctx.getAccountId(),
+              LabelId.legacySubmit()),
+              (short) 1, ctx.getWhen());
+    byKey.put(submitter.getKey(), submitter);
+    submitter.setValue((short) 1);
+    submitter.setGranted(ctx.getWhen());
+
+    // Flatten out existing approvals for this patch set based upon the current
+    // permissions. Once the change is closed the approvals are not updated at
+    // presentation view time, except for zero votes used to indicate a reviewer
+    // was added. So we need to make sure votes are accurate now. This way if
+    // permissions get modified in the future, historical records stay accurate.
+    LabelNormalizer.Result normalized =
+        args.labelNormalizer.normalize(ctx.getControl(), byKey.values());
+    update.putApproval(submitter.getLabel(), submitter.getValue());
+    saveApprovals(normalized, ctx, update, false);
+    return normalized;
+  }
+
+  private void saveApprovals(LabelNormalizer.Result normalized,
+      ChangeContext ctx, ChangeUpdate update, boolean includeUnchanged)
+      throws OrmException {
+    PatchSet.Id psId = update.getPatchSetId();
+    ctx.getDb().patchSetApprovals().upsert(
+        convertPatchSet(normalized.getNormalized(), psId));
+    ctx.getDb().patchSetApprovals().upsert(
+        zero(convertPatchSet(normalized.deleted(), psId)));
+    for (PatchSetApproval psa : normalized.updated()) {
+      update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
+    }
+    for (PatchSetApproval psa : normalized.deleted()) {
+      update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+    }
+
+    // TODO(dborowitz): Don't use a label in NoteDb; just check when status
+    // change happened.
+    for (PatchSetApproval psa : normalized.unchanged()) {
+      if (includeUnchanged || psa.isLegacySubmit()) {
+        logDebug("Adding submit label " + psa);
+        update.putApprovalFor(
+            psa.getAccountId(), psa.getLabel(), psa.getValue());
+      }
+    }
+  }
+
+  private static Function<PatchSetApproval, PatchSetApproval>
+      convertPatchSet(final PatchSet.Id psId) {
+    return new Function<PatchSetApproval, PatchSetApproval>() {
+      @Override
+      public PatchSetApproval apply(PatchSetApproval in) {
+        if (in.getPatchSetId().equals(psId)) {
+          return in;
+        }
+        return new PatchSetApproval(psId, in);
+      }
+    };
+  }
+
+  private static Iterable<PatchSetApproval> convertPatchSet(
+      Iterable<PatchSetApproval> approvals, PatchSet.Id psId) {
+    return Iterables.transform(approvals, convertPatchSet(psId));
+  }
+
+  private static Iterable<PatchSetApproval> zero(
+      Iterable<PatchSetApproval> approvals) {
+    return Iterables.transform(approvals,
+        new Function<PatchSetApproval, PatchSetApproval>() {
+          @Override
+          public PatchSetApproval apply(PatchSetApproval in) {
+            PatchSetApproval copy = new PatchSetApproval(in.getPatchSetId(), in);
+            copy.setValue((short) 0);
+            return copy;
+          }
+        });
+  }
+
+  private String getByAccountName() {
+    checkNotNull(submitter,
+        "getByAccountName called before submitter populated");
+    Account account =
+        args.accountCache.get(submitter.getAccountId()).getAccount();
+    if (account != null && account.getFullName() != null) {
+      return " by " + account.getFullName();
+    }
+    return "";
+  }
+
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit,
+      CommitMergeStatus s) {
+    checkNotNull(s, "CommitMergeStatus may not be null");
+    String txt = s.getMessage();
+    if (s == CommitMergeStatus.CLEAN_MERGE) {
+      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+    } else if (s == CommitMergeStatus.CLEAN_REBASE
+        || s == CommitMergeStatus.CLEAN_PICK) {
+      return message(ctx, commit.getPatchsetId(),
+          txt + " as " + commit.name() + getByAccountName());
+    } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
+      return message(ctx, commit.getPatchsetId(), txt);
+    } else if (s == CommitMergeStatus.ALREADY_MERGED) {
+      // Best effort to mimic the message that would have happened had this
+      // succeeded the first time around.
+      switch (args.submitType) {
+        case FAST_FORWARD_ONLY:
+        case MERGE_ALWAYS:
+        case MERGE_IF_NECESSARY:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_MERGE);
+        case CHERRY_PICK:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_PICK);
+        case REBASE_IF_NECESSARY:
+          return message(ctx, commit, CommitMergeStatus.CLEAN_REBASE);
+        default:
+          throw new IllegalStateException("unexpected submit type "
+              + args.submitType.toString()
+              + " for change "
+              + commit.change().getId());
+      }
+    } else {
+      throw new IllegalStateException("unexpected status " + s
+          + " for change " + commit.change().getId()
+          + "; expected to previously fail fast");
+    }
+  }
+
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId,
+      String body) {
+    checkNotNull(psId);
+    String uuid;
+    try {
+      uuid = ChangeUtil.messageUUID(ctx.getDb());
+    } catch (OrmException e) {
+      return null;
+    }
+    ChangeMessage m = new ChangeMessage(
+        new ChangeMessage.Key(psId.getParentKey(), uuid),
+        ctx.getAccountId(), ctx.getWhen(), psId);
+    m.setMessage(body);
+    return m;
+  }
+
+  private void setMerged(ChangeContext ctx, ChangeMessage msg)
+      throws OrmException {
+    Change c = ctx.getChange();
+    ReviewDb db = ctx.getDb();
+    logDebug("Setting change {} merged", c.getId());
+    c.setStatus(Change.Status.MERGED);
+    c.setSubmissionId(args.submissionId.toStringForStorage());
+
+    // TODO(dborowitz): We need to be able to change the author of the message,
+    // which is not the user from the update context. addMergedMessage was able
+    // to do this in the past.
+    if (msg != null) {
+      args.cmUtil.addChangeMessage(db, ctx.getUpdate(msg.getPatchSetId()), msg);
+    }
+  }
+
+  @Override
+  public final void postUpdate(Context ctx) throws Exception {
+    postUpdateImpl(ctx);
+
+    if (command != null) {
+      args.tagCache.updateFastForward(
+          getProject(),
+          command.getRefName(),
+          command.getOldId(),
+          command.getNewId());
+      // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
+      // per project even if multiple changes to refs/meta/config are submitted.
+      if (RefNames.REFS_CONFIG.equals(getDest().get())) {
+        args.projectCache.evict(getProject());
+        ProjectState p = args.projectCache.get(getProject());
+        args.repoManager.setProjectDescription(
+            p.getProject().getNameKey(), p.getProject().getDescription());
+      }
+    }
+
+    // Assume the change must have been merged at this point, otherwise we would
+    // have failed fast in one of the other steps.
+    try {
+      args.mergedSenderFactory
+          .create(ctx.getProject(), getId(), submitter.getAccountId(),
+              args.notifyHandling)
+          .sendAsync();
+    } catch (Exception e) {
+      log.error("Cannot email merged notification for " + getId(), e);
+    }
+    if (mergeResultRev != null) {
+      args.changeMerged.fire(
+          updatedChange,
+          mergedPatchSet,
+          args.accountCache.get(submitter.getAccountId()).getAccount(),
+          args.mergeTip.getCurrentTip().name(),
+          ctx.getWhen());
+    }
+  }
+
+  /**
+   * @see #updateRepo(RepoContext)
+   * @param ctx
+   */
+  protected void updateRepoImpl(RepoContext ctx) throws Exception {
+  }
+
+  /**
+   * @see #updateChange(ChangeContext)
+   * @param ctx
+   * @return a new patch set if one was created by the submit strategy, or null
+   *     if not.
+   */
+  protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
+    return null;
+  }
+
+  /**
+   * @see #postUpdate(Context)
+   * @param ctx
+   */
+  protected void postUpdateImpl(Context ctx) throws Exception {
+  }
+
+  /**
+   * Amend the commit with gitlink update
+   * @param commit
+   */
+  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
+      throws IntegrationException {
+    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+      return commit;
+    }
+
+    // Modify the commit with gitlink update
+    try {
+      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+    } catch (SubmoduleException | IOException e) {
+      throw new IntegrationException(
+          "cannot update gitlink for the commit at branch: "
+              + args.destBranch);
+    }
+  }
+
+  protected final void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(this.args.submissionId + msg, args);
+    }
+  }
+
+  protected final void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      log.warn(args.submissionId + msg, t);
+    }
+  }
+
+  protected void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(args.submissionId + msg, t);
+      } else {
+        log.error(args.submissionId + msg);
+      }
+    }
+  }
+
+  protected void logError(String msg) {
+    logError(msg, null);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 029096e..91c6a14 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -23,7 +23,8 @@
   private static final long serialVersionUID = 1L;
   private final List<CommitValidationMessage> messages;
 
-  public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
+  public CommitValidationException(String reason,
+      List<CommitValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
@@ -41,4 +42,16 @@
   public List<CommitValidationMessage> getMessages() {
     return messages;
   }
+
+  /** @return the reason string along with all validation messages. */
+  public String getFullMessage() {
+    StringBuilder sb = new StringBuilder(getMessage());
+    if (!messages.isEmpty()) {
+      sb.append(':');
+      for (CommitValidationMessage msg : messages) {
+        sb.append("\n  ").append(msg.getMessage());
+      }
+    }
+    return sb.toString();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
index e98c49b..9d7b3e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -34,6 +34,6 @@
    * @return list of validation messages
    * @throws CommitValidationException if validation fails
    */
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+  List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 6ac5707..dc998d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -14,19 +14,20 @@
 
 package com.google.gerrit.server.git.validators;
 
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.CharMatcher;
-import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -44,7 +45,6 @@
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
@@ -61,12 +61,13 @@
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.regex.Pattern;
 
 public class CommitValidators {
   private static final Logger log = LoggerFactory
       .getLogger(CommitValidators.class);
 
-  public static enum Policy {
+  public enum Policy {
     /** Use {@link #validateForGerritCommits}. */
     GERRIT,
 
@@ -88,26 +89,27 @@
   private final String installCommitMsgHookCommand;
   private final SshInfo sshInfo;
   private final Repository repo;
-  private final ChangeHooks hooks;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final AllUsersName allUsers;
 
   @Inject
-  CommitValidators(@GerritPersonIdent final PersonIdent gerritIdent,
-      @CanonicalWebUrl @Nullable final String canonicalWebUrl,
-      @GerritServerConfig final Config config,
-      final DynamicSet<CommitValidationListener> commitValidationListeners,
-      final ChangeHooks hooks,
-      @Assisted final SshInfo sshInfo,
-      @Assisted final Repository repo, @Assisted final RefControl refControl) {
+  CommitValidators(@GerritPersonIdent PersonIdent gerritIdent,
+      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      @GerritServerConfig Config config,
+      DynamicSet<CommitValidationListener> commitValidationListeners,
+      AllUsersName allUsers,
+      @Assisted SshInfo sshInfo,
+      @Assisted Repository repo,
+      @Assisted RefControl refControl) {
     this.gerritIdent = gerritIdent;
-    this.refControl = refControl;
     this.canonicalWebUrl = canonicalWebUrl;
     this.installCommitMsgHookCommand =
         config.getString("gerrit", null, "installCommitMsgHookCommand");
+    this.commitValidationListeners = commitValidationListeners;
+    this.allUsers = allUsers;
     this.sshInfo = sshInfo;
     this.repo = repo;
-    this.hooks = hooks;
-    this.commitValidationListeners = commitValidationListeners;
+    this.refControl = refControl;
   }
 
   public List<CommitValidationMessage> validateForReceiveCommits(
@@ -128,7 +130,7 @@
       validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
           installCommitMsgHookCommand, sshInfo));
     }
-    validators.add(new ConfigValidator(refControl, repo));
+    validators.add(new ConfigValidator(refControl, repo, allUsers));
     validators.add(new BannedCommitsValidator(rejectCommits));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
 
@@ -162,9 +164,8 @@
       validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
           installCommitMsgHookCommand, sshInfo));
     }
-    validators.add(new ConfigValidator(refControl, repo));
+    validators.add(new ConfigValidator(refControl, repo, allUsers));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
-    validators.add(new ChangeHookValidator(refControl, hooks));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
 
@@ -173,6 +174,7 @@
         messages.addAll(commitValidator.onCommitReceived(receiveEvent));
       }
     } catch (CommitValidationException e) {
+      log.debug("CommitValidationException occurred: {}", e.getFullMessage(), e);
       // Keep the old messages (and their order) in case of an exception
       messages.addAll(e.getMessages());
       throw new CommitValidationException(e.getMessage(), messages);
@@ -181,6 +183,27 @@
   }
 
   public static class ChangeIdValidator implements CommitValidationListener {
+    private static final int SHA1_LENGTH = 7;
+    private static final String CHANGE_ID_PREFIX =
+        FooterConstants.CHANGE_ID.getName() + ":";
+    private static final String MISSING_CHANGE_ID_MSG =
+        "[%s] missing "
+        + FooterConstants.CHANGE_ID.getName()
+        + " in commit message footer";
+    private static final String MISSING_SUBJECT_MSG =
+        "[%s] missing subject; "
+        + FooterConstants.CHANGE_ID.getName()
+        + " must be in commit message footer";
+    private static final String MULTIPLE_CHANGE_ID_MSG =
+        "[%s] multiple "
+        + FooterConstants.CHANGE_ID.getName()
+        + " lines in commit message footer";
+    private static final String INVALID_CHANGE_ID_MSG =
+        "[%s] invalid "
+        + FooterConstants.CHANGE_ID.getName() +
+        " line format in commit message footer";
+    private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
+
     private final ProjectControl projectControl;
     private final String canonicalWebUrl;
     private final String installCommitMsgHookCommand;
@@ -199,37 +222,36 @@
     @Override
     public List<CommitValidationMessage> onCommitReceived(
         CommitReceivedEvent receiveEvent) throws CommitValidationException {
-      final List<String> idList = receiveEvent.commit.getFooterLines(
-          FooterConstants.CHANGE_ID);
-
+      RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new LinkedList<>();
+      List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+      String sha1 = commit.abbreviate(SHA1_LENGTH).name();
 
       if (idList.isEmpty()) {
+        String shortMsg = commit.getShortMessage();
+        if (shortMsg.startsWith(CHANGE_ID_PREFIX)
+            && CHANGE_ID.matcher(shortMsg.substring(
+                CHANGE_ID_PREFIX.length()).trim()).matches()) {
+          String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
+          throw new CommitValidationException(errMsg);
+        }
         if (projectControl.getProjectState().isRequireChangeID()) {
-          String shortMsg = receiveEvent.commit.getShortMessage();
-          String changeIdPrefix = FooterConstants.CHANGE_ID.getName() + ":";
-          if (shortMsg.startsWith(changeIdPrefix)
-              && shortMsg.substring(changeIdPrefix.length()).trim()
-                  .matches("^I[0-9a-f]{8,}.*$")) {
-            throw new CommitValidationException(
-                "missing subject; Change-Id must be in commit message footer");
-          } else {
-            String errMsg = "missing Change-Id in commit message footer";
-            messages.add(getMissingChangeIdErrorMsg(
-                errMsg, receiveEvent.commit));
-            throw new CommitValidationException(errMsg, messages);
-          }
+          String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
+          messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
+          throw new CommitValidationException(errMsg, messages);
         }
       } else if (idList.size() > 1) {
-        throw new CommitValidationException(
-            "multiple Change-Id lines in commit message footer", messages);
+        String errMsg = String.format(
+            MULTIPLE_CHANGE_ID_MSG, sha1);
+        throw new CommitValidationException(errMsg, messages);
       } else {
-        final String v = idList.get(idList.size() - 1).trim();
-        if (!v.matches("^I[0-9a-f]{8,}.*$")) {
-          final String errMsg =
-              "missing or invalid Change-Id line format in commit message footer";
+        String v = idList.get(idList.size() - 1).trim();
+        // Reject Change-Ids with wrong format and invalid placeholder ID from
+        // Egit (I0000000000000000000000000000000000000000).
+        if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
+          String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
           messages.add(
-              getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
+            getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
           throw new CommitValidationException(errMsg, messages);
         }
       }
@@ -238,24 +260,27 @@
 
     private CommitValidationMessage getMissingChangeIdErrorMsg(
         final String errMsg, final RevCommit c) {
-      final String changeId = "Change-Id:";
       StringBuilder sb = new StringBuilder();
       sb.append("ERROR: ").append(errMsg);
 
-      if (c.getFullMessage().indexOf(changeId) >= 0) {
+      if (c.getFullMessage().indexOf(CHANGE_ID_PREFIX) >= 0) {
         String[] lines = c.getFullMessage().trim().split("\n");
         String lastLine = lines.length > 0 ? lines[lines.length - 1] : "";
 
-        if (lastLine.indexOf(changeId) == -1) {
+        if (lastLine.indexOf(CHANGE_ID_PREFIX) == -1) {
           sb.append('\n');
           sb.append('\n');
-          sb.append("Hint: A potential Change-Id was found, but it was not in the ");
+          sb.append("Hint: A potential ");
+          sb.append(FooterConstants.CHANGE_ID.getName());
+          sb.append("Change-Id was found, but it was not in the ");
           sb.append("footer (last paragraph) of the commit message.");
         }
       }
       sb.append('\n');
       sb.append('\n');
-      sb.append("Hint: To automatically insert Change-Id, install the hook:\n");
+      sb.append("Hint: To automatically insert ");
+      sb.append(FooterConstants.CHANGE_ID.getName());
+      sb.append(", install the hook:\n");
       sb.append(getCommitMessageHookInstallationHint());
       sb.append('\n');
       sb.append("And then amend the commit:\n");
@@ -307,10 +332,13 @@
   public static class ConfigValidator implements CommitValidationListener {
     private final RefControl refControl;
     private final Repository repo;
+    private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo) {
+    public ConfigValidator(RefControl refControl, Repository repo,
+        AllUsersName allUsers) {
       this.refControl = refControl;
       this.repo = repo;
+      this.allUsers = allUsers;
     }
 
     @Override
@@ -332,15 +360,43 @@
             }
             throw new ConfigInvalidException("invalid project configuration");
           }
-        } catch (Exception e) {
+        } catch (ConfigInvalidException | IOException e) {
           log.error("User " + currentUser.getUserName()
-              + " tried to push invalid project configuration "
-              + receiveEvent.command.getNewId().name() + " for "
+              + " tried to push an invalid project configuration "
+              + receiveEvent.command.getNewId().name() + " for project "
               + receiveEvent.project.getName(), e);
           throw new CommitValidationException("invalid project configuration",
               messages);
         }
       }
+
+      if (allUsers.equals(
+              refControl.getProjectControl().getProject().getNameKey())
+          && RefNames.isRefsUsers(refControl.getRefName())) {
+        List<CommitValidationMessage> messages = new LinkedList<>();
+        Account.Id accountId = Account.Id.fromRef(refControl.getRefName());
+        if (accountId != null) {
+          try {
+            WatchConfig wc = new WatchConfig(accountId);
+            wc.load(repo, receiveEvent.command.getNewId());
+            if (!wc.getValidationErrors().isEmpty()) {
+              addError("Invalid project configuration:", messages);
+              for (ValidationError err : wc.getValidationErrors()) {
+                addError("  " + err.getMessage(), messages);
+              }
+              throw new ConfigInvalidException("invalid watch configuration");
+            }
+          } catch (IOException | ConfigInvalidException e) {
+            log.error("User " + currentUser.getUserName()
+                + " tried to push an invalid watch configuration "
+                + receiveEvent.command.getNewId().name() + " for account "
+                + accountId.get(), e);
+            throw new CommitValidationException("invalid watch configuration",
+                messages);
+          }
+        }
+      }
+
       return Collections.emptyList();
     }
   }
@@ -545,50 +601,6 @@
     }
   }
 
-  /** Reject commits that don't pass user-supplied ref-update hook. */
-  public static class ChangeHookValidator implements
-      CommitValidationListener {
-    private final RefControl refControl;
-    private final ChangeHooks hooks;
-
-    public ChangeHookValidator(RefControl refControl, ChangeHooks hooks) {
-      this.refControl = refControl;
-      this.hooks = hooks;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
-
-      if (refControl.getUser().isIdentifiedUser()) {
-        IdentifiedUser user = refControl.getUser().asIdentifiedUser();
-
-        String refname = receiveEvent.refName;
-        ObjectId old = ObjectId.zeroId();
-        if (receiveEvent.commit.getParentCount() > 0) {
-          old = receiveEvent.commit.getParent(0);
-        }
-        if (receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
-          /*
-           * If the ref-update hook tries to distinguish behavior between pushes to
-           * refs/heads/... and refs/for/..., make sure we send it the correct refname.
-           * Also, if this is targetting refs/for/, make sure we behave the same as
-           * what a push to refs/for/ would behave; in particular, setting oldrev to
-           * 0000000000000000000000000000000000000000.
-           */
-          refname = refname.replace(R_HEADS, "refs/for/refs/heads/");
-          old = ObjectId.zeroId();
-        }
-        HookResult result = hooks.doRefUpdateHook(receiveEvent.project, refname,
-            user.getAccount(), old, receiveEvent.commit);
-        if (result != null && result.getExitValue() != 0) {
-            throw new CommitValidationException(result.toString().trim());
-        }
-      }
-      return Collections.emptyList();
-    }
-  }
-
   private static CommitValidationMessage getInvalidEmailError(RevCommit c, String type,
       PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
     StringBuilder sb = new StringBuilder();
@@ -626,9 +638,8 @@
   private static String getGerritUrl(String canonicalWebUrl) {
     if (canonicalWebUrl != null) {
       return CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl);
-    } else {
-      return "http://" + getGerritHost(canonicalWebUrl);
     }
+    return "http://" + getGerritHost(canonicalWebUrl);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
index bfad0e3..018ec1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.gerrit.server.git.CommitMergeStatus;
 import com.google.gerrit.server.validators.ValidationException;
 
+/**
+ * Exception that occurs during a validation step before merging changes.
+ * <p>
+ * Used by {@link MergeValidationListener}s provided by plugins. Messages should
+ * be considered human-readable.
+ */
 public class MergeValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final CommitMergeStatus status;
 
-  public MergeValidationException(CommitMergeStatus status) {
-    super(status.toString());
-    this.status = status;
-  }
-
-  public CommitMergeStatus getStatus() {
-    return status;
+  public MergeValidationException(String msg) {
+    super(msg);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 5951e04..d89d3b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -41,7 +41,7 @@
    * @param caller the user who initiated the merge request
    * @throws MergeValidationException if the commit fails to validate
    */
-  public void onPreMerge(Repository repo,
+  void onPreMerge(Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
       Branch.NameKey destBranch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index d08095c..4bf1deb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
-import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CommitMergeStatus;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -37,6 +36,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
+import java.util.LinkedList;
 import java.util.List;
 
 public class MergeValidators {
@@ -61,7 +61,7 @@
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
-    List<MergeValidationListener> validators = Lists.newLinkedList();
+    List<MergeValidationListener> validators = new LinkedList<>();
 
     validators.add(new PluginMergeValidationListener(mergeValidationListeners));
     validators.add(projectConfigValidatorFactory.create());
@@ -74,6 +74,26 @@
 
   public static class ProjectConfigValidator implements
       MergeValidationListener {
+    private static final String INVALID_CONFIG =
+        "Change contains an invalid project configuration.";
+    private static final String PARENT_NOT_FOUND =
+        "Change contains an invalid project configuration:\n"
+        + "Parent project does not exist.";
+    private static final String PLUGIN_VALUE_NOT_EDITABLE =
+        "Change contains an invalid project configuration:\n"
+        + "One of the plugin configuration parameters is not editable.";
+    private static final String PLUGIN_VALUE_NOT_PERMITTED =
+        "Change contains an invalid project configuration:\n"
+        + "One of the plugin configuration parameters has a value that is not"
+        + " permitted.";
+    private static final String ROOT_NO_PARENT =
+        "Change contains an invalid project configuration:\n"
+        + "The root project cannot have a parent.";
+    private static final String SET_BY_ADMIN =
+        "Change contains a project configuration that changes the parent"
+        + " project.\n"
+        + "The change must be submitted by a Gerrit administrator.";
+
     private final AllProjectsName allProjectsName;
     private final ProjectCache projectCache;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -111,19 +131,16 @@
           if (oldParent == null) {
             // update of the 'All-Projects' project
             if (newParent != null) {
-              throw new MergeValidationException(CommitMergeStatus.
-                  INVALID_PROJECT_CONFIGURATION_ROOT_PROJECT_CANNOT_HAVE_PARENT);
+              throw new MergeValidationException(ROOT_NO_PARENT);
             }
           } else {
             if (!oldParent.equals(newParent)) {
               if (!caller.getCapabilities().canAdministrateServer()) {
-                throw new MergeValidationException(CommitMergeStatus.
-                    SETTING_PARENT_PROJECT_ONLY_ALLOWED_BY_ADMIN);
+                throw new MergeValidationException(SET_BY_ADMIN);
               }
 
               if (projectCache.get(newParent) == null) {
-                throw new MergeValidationException(CommitMergeStatus.
-                    INVALID_PROJECT_CONFIGURATION_PARENT_PROJECT_NOT_FOUND);
+                throw new MergeValidationException(PARENT_NOT_FOUND);
               }
             }
           }
@@ -139,19 +156,16 @@
 
             if ((value == null ? oldValue != null : !value.equals(oldValue)) &&
                 !configEntry.isEditable(destProject)) {
-              throw new MergeValidationException(CommitMergeStatus.
-                  INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_EDITABLE);
+              throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
-            if (ProjectConfigEntry.Type.LIST.equals(configEntry.getType())
+            if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
                 && value != null && !configEntry.getPermittedValues().contains(value)) {
-              throw new MergeValidationException(CommitMergeStatus.
-                  INVALID_PROJECT_CONFIGURATION_PLUGIN_VALUE_NOT_PERMITTED);
+              throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
             }
           }
         } catch (ConfigInvalidException | IOException e) {
-          throw new MergeValidationException(CommitMergeStatus.
-              INVALID_PROJECT_CONFIGURATION);
+          throw new MergeValidationException(INVALID_CONFIG);
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 6fd0f5c..580de95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -15,7 +15,6 @@
 
 import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
@@ -29,6 +28,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.List;
 
 public class RefOperationValidators {
@@ -41,7 +41,7 @@
   }
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
-    return new ReceiveCommand(update.getOldObjectId(), update.getNewObjectId(),
+    return new ReceiveCommand(update.getExpectedOldObjectId(), update.getNewObjectId(),
         update.getName(), type);
   }
 
@@ -63,7 +63,7 @@
   public List<ValidationMessage> validateForRefOperation()
     throws RefOperationValidationException {
 
-    List<ValidationMessage> messages = Lists.newArrayList();
+    List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
     try {
       for (RefOperationValidationListener listener : refOperationValidationListeners) {
@@ -97,4 +97,4 @@
       return input.isError();
     }
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java
index 159496b..be264b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationException.java
@@ -27,4 +27,4 @@
   public UploadValidationException(String message) {
     super(message);
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
index 2c032c4..c86e87a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
@@ -42,6 +42,7 @@
    * @param repository The repository
    * @param project The project
    * @param remoteHost Remote address/hostname of the user
+   * @param up the UploadPack instance being processed
    * @param wants The list of wanted objects. These may be RevObject or
    *        RevCommit if the processor parsed them. Implementors should not rely
    *        on the values being parsed.
@@ -51,8 +52,26 @@
    * @throws ValidationException to block the upload and send a message
    *         back to the end-user over the client's protocol connection.
    */
-  public void onPreUpload(Repository repository, Project project,
+  void onPreUpload(Repository repository, Project project,
       String remoteHost, UploadPack up, Collection<? extends ObjectId> wants,
       Collection<? extends ObjectId> haves)
       throws ValidationException;
+
+  /**
+   * Invoked before negotiation round is started.
+   *
+   * @param repository The repository
+   * @param project The project
+   * @param remoteHost Remote address/hostname of the user
+   * @param up the UploadPack instance being processed
+   * @param wants The list of wanted objects. These may be RevObject or
+   *        RevCommit if the processor parsed them. Implementors should not rely
+   *        on the values being parsed.
+   * @param cntOffered number of objects the client has offered.
+   * @throws ValidationException to block the upload and send a message back to
+   *         the end-user over the client's protocol connection.
+   */
+  void onBeginNegotiate(Repository repository, Project project,
+      String remoteHost, UploadPack up, Collection<? extends ObjectId> wants,
+      int cntOffered) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
index eb2e136..66cc0e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -69,6 +69,14 @@
   public void onBeginNegotiateRound(UploadPack up,
       Collection<? extends ObjectId> wants, int cntOffered)
       throws ServiceMayNotContinueException {
+    for (UploadValidationListener validator : uploadValidationListeners) {
+      try {
+        validator.onBeginNegotiate(repository, project, remoteHost, up, wants,
+            cntOffered);
+      } catch (ValidationException e) {
+        throw new UploadValidationException(e.getMessage());
+      }
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
index c274a37..2deb44a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -39,6 +38,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.util.HashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -98,8 +99,8 @@
     input = Input.init(input);
 
     GroupControl control = resource.getControl();
-    Map<AccountGroup.UUID, AccountGroupById> newIncludedGroups = Maps.newHashMap();
-    List<GroupInfo> result = Lists.newLinkedList();
+    Map<AccountGroup.UUID, AccountGroupById> newIncludedGroups = new HashMap<>();
+    List<GroupInfo> result = new LinkedList<>();
     Account.Id me = control.getUser().getAccountId();
 
     for (String includedGroup : input.groups) {
@@ -166,9 +167,6 @@
 
   @Singleton
   static class UpdateIncludedGroup implements RestModifyView<IncludedGroupResource, PutIncludedGroup.Input> {
-    static class Input {
-    }
-
     private final Provider<GetIncludedGroup> get;
 
     @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index 18f5ee2..bd74fff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -46,8 +45,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -113,7 +115,7 @@
   @Override
   public List<AccountInfo> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -123,11 +125,11 @@
     GroupControl control = resource.getControl();
 
     Set<Account.Id> newMemberIds = new HashSet<>();
-    for (String nameOrEmail : input.members) {
-      Account a = findAccount(nameOrEmail);
+    for (String nameOrEmailOrId : input.members) {
+      Account a = findAccount(nameOrEmailOrId);
       if (!a.isActive()) {
         throw new UnprocessableEntityException(String.format(
-            "Account Inactive: %s", nameOrEmail));
+            "Account Inactive: %s", nameOrEmailOrId));
       }
 
       if (!control.canAddMember()) {
@@ -140,10 +142,10 @@
     return toAccountInfoList(newMemberIds);
   }
 
-  private Account findAccount(String nameOrEmail) throws AuthException,
-      UnprocessableEntityException, OrmException {
+  Account findAccount(String nameOrEmailOrId) throws AuthException,
+      UnprocessableEntityException, OrmException, IOException {
     try {
-      return accounts.parse(nameOrEmail).getAccount();
+      return accounts.parse(nameOrEmailOrId).getAccount();
     } catch (UnprocessableEntityException e) {
       // might be because the account does not exist or because the account is
       // not visible
@@ -151,14 +153,21 @@
         case HTTP_LDAP:
         case CLIENT_SSL_CERT_LDAP:
         case LDAP:
-          if (accountResolver.find(nameOrEmail) == null) {
+          if (accountResolver.find(db.get(), nameOrEmailOrId) == null) {
             // account does not exist, try to create it
-            Account a = createAccountByLdap(nameOrEmail);
+            Account a = createAccountByLdap(nameOrEmailOrId);
             if (a != null) {
               return a;
             }
           }
           break;
+        case CUSTOM_EXTENSION:
+        case DEVELOPMENT_BECOME_ANY_ACCOUNT:
+        case HTTP:
+        case LDAP_BIND:
+        case OAUTH:
+        case OPENID:
+        case OPENID_SSO:
         default:
       }
       throw e;
@@ -166,8 +175,9 @@
   }
 
   public void addMembers(AccountGroup.Id groupId,
-      Collection<? extends Account.Id> newMemberIds) throws OrmException {
-    Map<Account.Id, AccountGroupMember> newAccountGroupMembers = Maps.newHashMap();
+      Collection<? extends Account.Id> newMemberIds)
+          throws OrmException, IOException {
+    Map<Account.Id, AccountGroupMember> newAccountGroupMembers = new HashMap<>();
     for (Account.Id accId : newMemberIds) {
       if (!newAccountGroupMembers.containsKey(accId)) {
         AccountGroupMember.Key key =
@@ -179,16 +189,17 @@
         }
       }
     }
-
-    auditService.dispatchAddAccountsToGroup(self.get().getAccountId(),
-        newAccountGroupMembers.values());
-    db.get().accountGroupMembers().insert(newAccountGroupMembers.values());
-    for (AccountGroupMember m : newAccountGroupMembers.values()) {
-      accountCache.evict(m.getAccountId());
+    if (!newAccountGroupMembers.isEmpty()) {
+      auditService.dispatchAddAccountsToGroup(self.get().getAccountId(),
+          newAccountGroupMembers.values());
+      db.get().accountGroupMembers().insert(newAccountGroupMembers.values());
+      for (AccountGroupMember m : newAccountGroupMembers.values()) {
+        accountCache.evict(m.getAccountId());
+      }
     }
   }
 
-  private Account createAccountByLdap(String user) {
+  private Account createAccountByLdap(String user) throws IOException {
     if (!user.matches(Account.USER_NAME_PATTERN)) {
       return null;
     }
@@ -205,7 +216,7 @@
 
   private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds)
       throws OrmException {
-    List<AccountInfo> result = Lists.newLinkedList();
+    List<AccountInfo> result = new LinkedList<>();
     AccountLoader loader = infoFactory.create(true);
     for (Account.Id accId : accountIds) {
       result.add(loader.get(accId));
@@ -229,7 +240,7 @@
     @Override
     public AccountInfo apply(GroupResource resource, PutMember.Input input)
         throws AuthException, MethodNotAllowedException,
-        ResourceNotFoundException, OrmException {
+        ResourceNotFoundException, OrmException, IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id;
       try {
@@ -246,9 +257,6 @@
 
   @Singleton
   static class UpdateMember implements RestModifyView<MemberResource, PutMember.Input> {
-    static class Input {
-    }
-
     private final GetMember get;
 
     @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
index 4e67c02..0fd4728 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
@@ -21,8 +21,10 @@
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -50,11 +52,16 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
 
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
 public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
-  public static interface Factory {
+  public interface Factory {
     CreateGroup create(@Assisted String name);
   }
 
@@ -93,10 +100,20 @@
     this.name = name;
   }
 
+  public CreateGroup addOption(ListGroupsOption o) {
+    json.addOption(o);
+    return this;
+  }
+
+  public CreateGroup addOptions(Collection<ListGroupsOption> o) {
+    json.addOptions(o);
+    return this;
+  }
+
   @Override
   public GroupInfo apply(TopLevelResource resource, GroupInput input)
-      throws BadRequestException, UnprocessableEntityException,
-      ResourceConflictException, OrmException {
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+      ResourceConflictException, OrmException, IOException {
     if (input == null) {
       input = new GroupInput();
     }
@@ -111,9 +128,22 @@
     args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll,
         defaultVisibleToAll);
     args.ownerGroupId = ownerId;
-    args.initialMembers = ownerId == null
-        ? Collections.singleton(self.get().getAccountId())
-        : Collections.<Account.Id> emptySet();
+    if (input.members != null && !input.members.isEmpty()) {
+      List<Account.Id> members = new ArrayList<>();
+      for (String nameOrEmailOrId : input.members) {
+        Account a = addMembers.findAccount(nameOrEmailOrId);
+        if (!a.isActive()) {
+          throw new UnprocessableEntityException(String.format(
+              "Account Inactive: %s", nameOrEmailOrId));
+        }
+        members.add(a.getId());
+      }
+      args.initialMembers = members;
+    } else {
+      args.initialMembers = ownerId == null
+          ? Collections.singleton(self.get().getAccountId())
+          : Collections.<Account.Id> emptySet();
+    }
 
     for (GroupCreationValidationListener l : groupCreationValidationListeners) {
       try {
@@ -136,7 +166,18 @@
   }
 
   private AccountGroup createGroup(CreateGroupArgs createGroupArgs)
-      throws OrmException, ResourceConflictException {
+      throws OrmException, ResourceConflictException, IOException {
+
+    // Do not allow creating groups with the same name as system groups
+    List<String> sysGroupNames = SystemGroupBackend.getNames();
+    for (String name : sysGroupNames) {
+      if (name.toLowerCase(Locale.US).equals(
+          createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
+        throw new ResourceConflictException("group '" + name
+            + "' already exists");
+      }
+    }
+
     AccountGroup.Id groupId = new AccountGroup.Id(db.nextAccountGroupId());
     AccountGroup.UUID uuid =
         GroupUUID.make(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
index bc8bff7..30b856a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.Lists;
 import com.google.gerrit.audit.GroupMemberAuditListener;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
@@ -37,6 +36,7 @@
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.LinkedList;
 import java.util.List;
 
 class DbGroupMemberAuditListener implements GroupMemberAuditListener {
@@ -49,7 +49,7 @@
   private final UniversalGroupBackend groupBackend;
 
   @Inject
-  public DbGroupMemberAuditListener(SchemaFactory<ReviewDb> schema,
+  DbGroupMemberAuditListener(SchemaFactory<ReviewDb> schema,
       AccountCache accountCache, GroupCache groupCache,
       UniversalGroupBackend groupBackend) {
     this.schema = schema;
@@ -61,7 +61,7 @@
   @Override
   public void onAddAccountsToGroup(Account.Id me,
       Collection<AccountGroupMember> added) {
-    List<AccountGroupMemberAudit> auditInserts = Lists.newLinkedList();
+    List<AccountGroupMemberAudit> auditInserts = new LinkedList<>();
     for (AccountGroupMember m : added) {
       AccountGroupMemberAudit audit =
           new AccountGroupMemberAudit(m, me, TimeUtil.nowTs());
@@ -79,8 +79,8 @@
   @Override
   public void onDeleteAccountsFromGroup(Account.Id me,
       Collection<AccountGroupMember> removed) {
-    List<AccountGroupMemberAudit> auditInserts = Lists.newLinkedList();
-    List<AccountGroupMemberAudit> auditUpdates = Lists.newLinkedList();
+    List<AccountGroupMemberAudit> auditInserts = new LinkedList<>();
+    List<AccountGroupMemberAudit> auditUpdates = new LinkedList<>();
     try (ReviewDb db = schema.open()) {
       for (AccountGroupMember m : removed) {
         AccountGroupMemberAudit audit = null;
@@ -131,7 +131,7 @@
   @Override
   public void onDeleteGroupsFromGroup(Account.Id me,
       Collection<AccountGroupById> removed) {
-    final List<AccountGroupByIdAud> auditUpdates = Lists.newLinkedList();
+    final List<AccountGroupByIdAud> auditUpdates = new LinkedList<>();
     try (ReviewDb db = schema.open()) {
       for (final AccountGroupById g : removed) {
         AccountGroupByIdAud audit = null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
index bde8fb7..da683a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -37,6 +35,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.util.HashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -71,7 +71,7 @@
 
     final GroupControl control = resource.getControl();
     final Map<AccountGroup.UUID, AccountGroupById> includedGroups = getIncludedGroups(internalGroup.getId());
-    final List<AccountGroupById> toRemove = Lists.newLinkedList();
+    final List<AccountGroupById> toRemove = new LinkedList<>();
 
     for (final String includedGroup : input.groups) {
       GroupDescription.Basic d = groupsCollection.parse(includedGroup);
@@ -100,8 +100,7 @@
 
   private Map<AccountGroup.UUID, AccountGroupById> getIncludedGroups(
       final AccountGroup.Id groupId) throws OrmException {
-    final Map<AccountGroup.UUID, AccountGroupById> groups =
-        Maps.newHashMap();
+    final Map<AccountGroup.UUID, AccountGroupById> groups = new HashMap<>();
     for (AccountGroupById g : db.get().accountGroupById().byGroup(groupId)) {
       groups.put(g.getIncludeUUID(), g);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index b14974b..e1a6921 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -36,6 +34,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -62,7 +63,7 @@
   @Override
   public Response<?> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -71,7 +72,7 @@
 
     final GroupControl control = resource.getControl();
     final Map<Account.Id, AccountGroupMember> members = getMembers(internalGroup.getId());
-    final List<AccountGroupMember> toRemove = Lists.newLinkedList();
+    final List<AccountGroupMember> toRemove = new LinkedList<>();
 
     for (final String nameOrEmail : input.members) {
       Account a = accounts.parse(nameOrEmail).getAccount();
@@ -102,7 +103,7 @@
 
   private Map<Account.Id, AccountGroupMember> getMembers(
       final AccountGroup.Id groupId) throws OrmException {
-    final Map<Account.Id, AccountGroupMember> members = Maps.newHashMap();
+    final Map<Account.Id, AccountGroupMember> members = new HashMap<>();
     for (final AccountGroupMember m : db.get().accountGroupMembers()
         .byGroup(groupId)) {
       members.put(m.getAccountId(), m);
@@ -125,7 +126,7 @@
     @Override
     public Response<?> apply(MemberResource resource, Input input)
         throws AuthException, MethodNotAllowedException,
-        UnprocessableEntityException, OrmException {
+        UnprocessableEntityException, OrmException, IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = resource.getMember().getAccountId().toString();
       return delete.get().apply(resource, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
index 34c2b76..f9ae694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
@@ -28,6 +29,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -45,16 +47,19 @@
   private final AccountLoader.Factory accountLoaderFactory;
   private final GroupCache groupCache;
   private final GroupJson groupJson;
+  private final GroupBackend groupBackend;
 
   @Inject
   public GetAuditLog(Provider<ReviewDb> db,
       AccountLoader.Factory accountLoaderFactory,
       GroupCache groupCache,
-      GroupJson groupJson) {
+      GroupJson groupJson,
+      GroupBackend groupBackend) {
     this.db = db;
     this.accountLoaderFactory = accountLoaderFactory;
     this.groupCache = groupCache;
     this.groupJson = groupJson;
+    this.groupBackend = groupBackend;
   }
 
   @Override
@@ -100,8 +105,11 @@
       if (includedGroup != null) {
         member = groupJson.format(GroupDescriptions.forAccountGroup(includedGroup));
       } else {
+        GroupDescription.Basic groupDescription =
+            groupBackend.get(includedGroupUUID);
         member = new GroupInfo();
         member.id = Url.encode(includedGroupUUID.get());
+        member.name = groupDescription.getName();
       }
 
       auditEvents.add(GroupAuditEventInfo.createAddGroupEvent(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java
new file mode 100644
index 0000000..d660db0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.inject.Inject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Efficiently builds a {@link GroupInfoCache}. */
+public class GroupInfoCache {
+  public interface Factory {
+    GroupInfoCache create();
+  }
+
+  private final GroupBackend groupBackend;
+  private final Map<AccountGroup.UUID, GroupDescription.Basic> out;
+
+  @Inject
+  GroupInfoCache(GroupBackend groupBackend) {
+    this.groupBackend = groupBackend;
+    this.out = new HashMap<>();
+  }
+
+  /**
+   * Indicate a group will be needed later on.
+   *
+   * @param uuid identity that will be needed in the future; may be null.
+   */
+  public void want(final AccountGroup.UUID uuid) {
+    if (uuid != null && !out.containsKey(uuid)) {
+      out.put(uuid, groupBackend.get(uuid));
+    }
+  }
+
+  public GroupDescription.Basic get(final AccountGroup.UUID uuid) {
+    want(uuid);
+    return out.get(uuid);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
index 4636c2f..6268d72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
@@ -69,7 +69,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if(!(user.isIdentifiedUser())) {
+    } else if (!(user.isIdentifiedUser())) {
       throw new ResourceNotFoundException();
     }
 
@@ -82,7 +82,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if(!(user.isIdentifiedUser())) {
+    } else if (!(user.isIdentifiedUser())) {
       throw new ResourceNotFoundException(id);
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index 44e8a4d..d5081b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -18,8 +18,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -49,11 +47,14 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.SortedMap;
+import java.util.TreeMap;
 
 /** List groups visible to the calling user. */
 public class ListGroups implements RestReadView<TopLevelResource> {
@@ -61,12 +62,12 @@
   protected final GroupCache groupCache;
 
   private final List<ProjectControl> projects = new ArrayList<>();
-  private final Set<AccountGroup.UUID> groupsToInspect = Sets.newHashSet();
+  private final Set<AccountGroup.UUID> groupsToInspect = new HashSet<>();
   private final GroupControl.Factory groupControlFactory;
   private final GroupControl.GenericFactory genericGroupControlFactory;
   private final Provider<IdentifiedUser> identifiedUser;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<GetGroups> accountGetGroups;
+  private final GetGroups accountGetGroups;
   private final GroupJson json;
   private final GroupBackend groupBackend;
 
@@ -127,7 +128,8 @@
     this.matchSubstring = matchSubstring;
   }
 
-  @Option(name = "--suggest", usage = "to get a suggestion of groups")
+  @Option(name = "--suggest", aliases = {"-s"},
+      usage = "to get a suggestion of groups")
   public void setSuggest(String suggest) {
     this.suggest = suggest;
   }
@@ -148,7 +150,8 @@
       final GroupControl.GenericFactory genericGroupControlFactory,
       final Provider<IdentifiedUser> identifiedUser,
       final IdentifiedUser.GenericFactory userFactory,
-      final Provider<GetGroups> accountGetGroups, GroupJson json,
+      final GetGroups accountGetGroups,
+      GroupJson json,
       GroupBackend groupBackend) {
     this.groupCache = groupCache;
     this.groupControlFactory = groupControlFactory;
@@ -175,7 +178,7 @@
   @Override
   public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
       throws OrmException, BadRequestException {
-    SortedMap<String, GroupInfo> output = Maps.newTreeMap();
+    SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(
           info.name,
@@ -196,7 +199,7 @@
     }
 
     if (user != null) {
-      return accountGetGroups.get().apply(
+      return accountGetGroups.apply(
           new AccountResource(userFactory.create(user)));
     }
 
@@ -207,7 +210,7 @@
     List<GroupInfo> groupInfos;
     List<AccountGroup> groupList;
     if (!projects.isEmpty()) {
-      Map<AccountGroup.UUID, AccountGroup> groups = Maps.newHashMap();
+      Map<AccountGroup.UUID, AccountGroup> groups = new HashMap<>();
       for (final ProjectControl projectControl : projects) {
         final Set<GroupReference> groupsRefs = projectControl.getAllGroups();
         for (final GroupReference groupRef : groupsRefs) {
@@ -288,7 +291,7 @@
 
   private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
       throws OrmException {
-    List<GroupInfo> groups = Lists.newArrayList();
+    List<GroupInfo> groups = new ArrayList<>();
     int found = 0;
     int foundIndex = 0;
     for (AccountGroup g : filterGroups(groupCache.all())) {
@@ -312,7 +315,7 @@
   }
 
   private List<AccountGroup> filterGroups(final Iterable<AccountGroup> groups) {
-    final List<AccountGroup> filteredGroups = Lists.newArrayList();
+    final List<AccountGroup> filteredGroups = new ArrayList<>();
     final boolean isAdmin =
         identifiedUser.get().getCapabilities().canAdministrateServer();
     for (final AccountGroup group : groups) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
index 8e22ef9..803c498 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Strings.nullToEmpty;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -31,6 +30,7 @@
 
 import org.slf4j.Logger;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
@@ -59,7 +59,7 @@
     }
 
     boolean ownerOfParent = rsrc.getControl().isOwner();
-    List<GroupInfo> included = Lists.newArrayList();
+    List<GroupInfo> included = new ArrayList<>();
     for (AccountGroupById u : dbProvider.get()
         .accountGroupById()
         .byGroup(rsrc.toAccountGroup().getId())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
index 405cdf1..98d18ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.data.GroupDetail;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -30,13 +27,14 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupDetailFactory;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
 import org.kohsuke.args4j.Option;
 
 import java.util.Collections;
-import java.util.Comparator;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -83,15 +81,7 @@
     final Map<Account.Id, AccountInfo> members =
         getMembers(groupId, new HashSet<AccountGroup.UUID>());
     final List<AccountInfo> memberInfos = Lists.newArrayList(members.values());
-    Collections.sort(memberInfos, new Comparator<AccountInfo>() {
-      @Override
-      public int compare(AccountInfo a, AccountInfo b) {
-        return ComparisonChain.start()
-            .compare(a.name, b.name, Ordering.natural().nullsFirst())
-            .compare(a.email, b.email, Ordering.natural().nullsFirst())
-            .compare(a._accountId, b._accountId, Ordering.natural().nullsFirst()).result();
-      }
-    });
+    Collections.sort(memberInfos, AccountInfoComparator.ORDER_NULLS_FIRST);
     return memberInfos;
   }
 
@@ -100,7 +90,7 @@
       final HashSet<AccountGroup.UUID> seenGroups) throws OrmException {
     seenGroups.add(groupUUID);
 
-    final Map<Account.Id, AccountInfo> members = Maps.newHashMap();
+    final Map<Account.Id, AccountInfo> members = new HashMap<>();
     final AccountGroup group = groupCache.get(groupUUID);
     if (group == null) {
       // the included group is an external group and can't be resolved
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
index 8266162..c59f0ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
@@ -113,9 +113,8 @@
         //
         throw new ResourceConflictException("group with name " + newName
             + "already exists");
-      } else {
-        throw e;
       }
+      throw e;
     }
 
     group.setNameKey(key);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 32a051b..9809ef3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -37,35 +37,38 @@
 import java.util.TreeMap;
 
 public class SystemGroupBackend extends AbstractGroupBackend {
+  public static final String SYSTEM_GROUP_SCHEME = "global:";
+
   /** Common UUID assigned to the "Anonymous Users" group. */
   public static final AccountGroup.UUID ANONYMOUS_USERS =
-      new AccountGroup.UUID("global:Anonymous-Users");
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Anonymous-Users");
 
   /** Common UUID assigned to the "Registered Users" group. */
   public static final AccountGroup.UUID REGISTERED_USERS =
-      new AccountGroup.UUID("global:Registered-Users");
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Registered-Users");
 
   /** Common UUID assigned to the "Project Owners" placeholder group. */
   public static final AccountGroup.UUID PROJECT_OWNERS =
-      new AccountGroup.UUID("global:Project-Owners");
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Project-Owners");
 
   /** Common UUID assigned to the "Change Owner" placeholder group. */
   public static final AccountGroup.UUID CHANGE_OWNER =
-      new AccountGroup.UUID("global:Change-Owner");
+      new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
 
   private static final SortedMap<String, GroupReference> names;
   private static final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+  private static final AccountGroup.UUID[] all = {
+      ANONYMOUS_USERS,
+      REGISTERED_USERS,
+      PROJECT_OWNERS,
+      CHANGE_OWNER,
+  };
 
   static {
     SortedMap<String, GroupReference> n = new TreeMap<>();
     ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u =
         ImmutableMap.builder();
-    AccountGroup.UUID[] all = {
-        ANONYMOUS_USERS,
-        REGISTERED_USERS,
-        PROJECT_OWNERS,
-        CHANGE_OWNER,
-    };
+
     for (AccountGroup.UUID uuid : all) {
       int c = uuid.get().indexOf(':');
       String name = uuid.get().substring(c + 1).replace('-', ' ');
@@ -78,7 +81,7 @@
   }
 
   public static boolean isSystemGroup(AccountGroup.UUID uuid) {
-    return uuid.get().startsWith("global:");
+    return uuid.get().startsWith(SYSTEM_GROUP_SCHEME);
   }
 
   public static boolean isAnonymousOrRegistered(GroupReference ref) {
@@ -93,6 +96,15 @@
     return checkNotNull(uuids.get(uuid), "group %s not found", uuid.get());
   }
 
+  public static List<String> getNames() {
+    List<String> names = new ArrayList<>();
+    for (AccountGroup.UUID uuid : all) {
+      int c = uuid.get().indexOf(':');
+      names.add(uuid.get().substring(c + 1).replace('-', ' '));
+    }
+    return names;
+  }
+
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
     return isSystemGroup(uuid);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
deleted file mode 100644
index 4fa5cd3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
+++ /dev/null
@@ -1,754 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gwtorm.protobuf.CodecFactory;
-import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.OrmException;
-import com.google.protobuf.CodedOutputStream;
-
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.FooterLine;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Fields indexed on change documents.
- * <p>
- * Each field corresponds to both a field name supported by
- * {@link ChangeQueryBuilder} for querying that field, and a method on
- * {@link ChangeData} used for populating the corresponding document fields in
- * the secondary index.
- * <p>
- * Field names are all lowercase alphanumeric plus underscore; index
- * implementations may create unambiguous derived field names containing other
- * characters.
- */
-public class ChangeField {
-  @Deprecated
-  /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      new FieldDef.Single<ChangeData, Integer>("_id",
-          FieldType.INTEGER, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args) {
-          return input.getId().get();
-        }
-      };
-
-  /** Legacy change ID without underscore prefix. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID2 =
-      new FieldDef.Single<ChangeData, Integer>("legacy_id",
-          FieldType.INTEGER, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args) {
-          return input.getId().get();
-        }
-      };
-
-  /** Newer style Change-Id key. */
-  public static final FieldDef<ChangeData, String> ID =
-      new FieldDef.Single<ChangeData, String>("change_id",
-          FieldType.PREFIX, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getKey().get();
-        }
-      };
-
-  /** Change status string, in the same format as {@code status:}. */
-  public static final FieldDef<ChangeData, String> STATUS =
-      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_STATUS,
-          FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return ChangeStatusPredicate.canonicalize(c.getStatus());
-        }
-      };
-
-  /** Project containing the change. */
-  public static final FieldDef<ChangeData, String> PROJECT =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_PROJECT, FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getProject().get();
-        }
-      };
-
-  /** Project containing the change, as a prefix field. */
-  public static final FieldDef<ChangeData, String> PROJECTS =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_PROJECTS, FieldType.PREFIX, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getProject().get();
-        }
-      };
-
-  /** Reference (aka branch) the change will submit onto. */
-  public static final FieldDef<ChangeData, String> REF =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_REF, FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getDest().get();
-        }
-      };
-
-  @Deprecated
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> LEGACY_TOPIC2 =
-      new FieldDef.Single<ChangeData, String>(
-          "topic2", FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
-
-  @Deprecated
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> LEGACY_TOPIC3 =
-      new FieldDef.Single<ChangeData, String>(
-          "topic3", FieldType.PREFIX, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
-
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      new FieldDef.Single<ChangeData, String>(
-          "topic4", FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
-
-  /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      new FieldDef.Single<ChangeData, String>(
-          "topic5", FieldType.FULL_TEXT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
-
-  /** Submission id assigned by MergeOp. */
-  public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      new FieldDef.Single<ChangeData, String>(
-          "submissionid", FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getSubmissionId();
-        }
-      };
-
-  /** Last update time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      new FieldDef.Single<ChangeData, Timestamp>(
-          "updated2", FieldType.TIMESTAMP, true) {
-        @Override
-        public Timestamp get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getLastUpdatedOn();
-        }
-      };
-
-  /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
-      new FieldDef.Repeatable<ChangeData, String>(
-          // Named for backwards compatibility.
-          "file", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return firstNonNull(input.currentFilePaths(),
-              ImmutableList.<String> of());
-        }
-      };
-
-  public static Set<String> getFileParts(ChangeData cd) throws OrmException {
-    List<String> paths = cd.currentFilePaths();
-    if (paths == null) {
-      return ImmutableSet.of();
-    }
-    Splitter s = Splitter.on('/').omitEmptyStrings();
-    Set<String> r = Sets.newHashSet();
-    for (String path : paths) {
-      for (String part : s.split(path)) {
-        r.add(part);
-      }
-    }
-    return r;
-  }
-
-  /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      new FieldDef.Repeatable<ChangeData, String>(
-          "hashtag", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.notes().load()
-              .getHashtags(), new Function<String, String>() {
-
-            @Override
-            public String apply(String input) {
-              return input.toLowerCase();
-            }
-
-          }));
-        }
-      };
-
-  /** Components of each file path modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      new FieldDef.Repeatable<ChangeData, String>(
-          "filepart", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getFileParts(input);
-        }
-      };
-
-  /** Owner/creator of the change. */
-  public static final FieldDef<ChangeData, Integer> OWNER =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_OWNER, FieldType.INTEGER, false) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getOwner().get();
-        }
-      };
-
-  /** Reviewer(s) associated with the change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWER =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_REVIEWER, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return ImmutableSet.of();
-          }
-          Set<Integer> r = Sets.newHashSet();
-          if (!args.allowsDrafts && c.getStatus() == Change.Status.DRAFT) {
-            return r;
-          }
-          for (PatchSetApproval a : input.approvals().values()) {
-            r.add(a.getAccountId().get());
-          }
-          return r;
-        }
-      };
-
-  /** Commit ID of any patch set on the change, using prefix match. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_COMMIT, FieldType.PREFIX, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getRevisions(input);
-        }
-      };
-
-  /** Commit ID of any patch set on the change, using exact match. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      new FieldDef.Repeatable<ChangeData, String>(
-          "exactcommit", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getRevisions(input);
-        }
-      };
-
-  private static Set<String> getRevisions(ChangeData cd) throws OrmException {
-    Set<String> revisions = Sets.newHashSet();
-    for (PatchSet ps : cd.patchSets()) {
-      if (ps.getRevision() != null) {
-        revisions.add(ps.getRevision().get());
-      }
-    }
-    return revisions;
-  }
-
-  /** Tracking id extracted from a footer. */
-  public static final FieldDef<ChangeData, Iterable<String>> TR =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_TR, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          try {
-            List<FooterLine> footers = input.commitFooters();
-            if (footers == null) {
-              return ImmutableSet.of();
-            }
-            return Sets.newHashSet(
-                args.trackingFooters.extract(footers).values());
-          } catch (IOException e) {
-            throw new OrmException(e);
-          }
-        }
-      };
-
-  /** List of labels on the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_LABEL, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> allApprovals = Sets.newHashSet();
-          Set<String> distinctApprovals = Sets.newHashSet();
-          for (PatchSetApproval a : input.currentApprovals()) {
-            if (a.getValue() != 0 && !a.isSubmit()) {
-              allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
-                  a.getAccountId()));
-              distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
-            }
-          }
-          allApprovals.addAll(distinctApprovals);
-          return allApprovals;
-        }
-      };
-
-  /** Set true if the change has a non-zero label score. */
-  @Deprecated
-  public static final FieldDef<ChangeData, String> LEGACY_REVIEWED =
-      new FieldDef.Single<ChangeData, String>(
-          "reviewed", FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          for (PatchSetApproval a : input.currentApprovals()) {
-            if (a.getValue() != 0) {
-              return "1";
-            }
-          }
-          return null;
-        }
-      };
-
-  private static Set<String> getPersonParts(PersonIdent person) {
-    if (person == null) {
-      return ImmutableSet.of();
-    }
-    HashSet<String> parts = Sets.newHashSet();
-    String email = person.getEmailAddress().toLowerCase();
-    parts.add(email);
-    parts.addAll(Arrays.asList(email.split("@")));
-    Splitter s = Splitter.on(CharMatcher.anyOf("@.- ")).omitEmptyStrings();
-    Iterables.addAll(parts, s.split(email));
-    Iterables.addAll(parts, s.split(person.getName().toLowerCase()));
-    return parts;
-  }
-
-  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException {
-    try {
-      return getPersonParts(cd.getAuthor());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException {
-    try {
-      return getPersonParts(cd.getCommitter());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  /**
-   * The exact email address, or any part of the author name or email address,
-   * in the current patch set.
-   */
-  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_AUTHOR, FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getAuthorParts(input);
-        }
-      };
-
-  /**
-   * The exact email address, or any part of the committer name or email address,
-   * in the current patch set.
-   */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_COMMITTER, FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getCommitterParts(input);
-        }
-      };
-
-  public static class ChangeProtoField extends FieldDef.Single<ChangeData, byte[]> {
-    public static final ProtobufCodec<Change> CODEC =
-        CodecFactory.encoder(Change.class);
-
-    private ChangeProtoField() {
-      super("_change", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public byte[] get(ChangeData input, FieldDef.FillArgs args)
-        throws OrmException {
-      Change c = input.change();
-      if (c == null) {
-        return null;
-      }
-      return CODEC.encodeToByteArray(c);
-    }
-  }
-
-  /** Serialized change object, used for pre-populating results. */
-  public static final ChangeProtoField CHANGE = new ChangeProtoField();
-
-  public static class PatchSetApprovalProtoField
-      extends FieldDef.Repeatable<ChangeData, byte[]> {
-    public static final ProtobufCodec<PatchSetApproval> CODEC =
-        CodecFactory.encoder(PatchSetApproval.class);
-
-    private PatchSetApprovalProtoField() {
-      super("_approval", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public Iterable<byte[]> get(ChangeData input, FillArgs args)
-        throws OrmException {
-      return toProtos(CODEC, input.currentApprovals());
-    }
-  }
-
-  /**
-   * Serialized approvals for the current patch set, used for pre-populating
-   * results.
-   */
-  public static final PatchSetApprovalProtoField APPROVAL =
-      new PatchSetApprovalProtoField();
-
-  public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
-  }
-
-  public static String formatLabel(String label, int value, Account.Id accountId) {
-    return label.toLowerCase() + (value >= 0 ? "+" : "") + value
-        + (accountId != null ? "," + accountId.get() : "");
-  }
-
-  /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_MESSAGE,
-          FieldType.FULL_TEXT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args) throws OrmException {
-          try {
-            return input.commitMessage();
-          } catch (IOException e) {
-            throw new OrmException(e);
-          }
-        }
-      };
-
-  /** Summary or inline comment. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      new FieldDef.Repeatable<ChangeData, String>(ChangeQueryBuilder.FIELD_COMMENT,
-          FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> r = Sets.newHashSet();
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getMessage());
-          }
-          for (ChangeMessage m : input.messages()) {
-            r.add(m.getMessage());
-          }
-          return r;
-        }
-      };
-
-  /** Whether the change is mergeable. */
-  public static final FieldDef<ChangeData, String> MERGEABLE =
-      new FieldDef.Single<ChangeData, String>(
-          "mergeable2", FieldType.EXACT, true) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Boolean m = input.isMergeable();
-          if (m == null) {
-            return null;
-          }
-          return m ? "1" : "0";
-        }
-      };
-
-  /** The number of inserted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> ADDED =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_ADDED, FieldType.INTEGER_RANGE, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-
-          return input.changedLines() != null
-              ? input.changedLines().insertions
-              : null;
-        }
-      };
-
-  /** The number of deleted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELETED =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_DELETED, FieldType.INTEGER_RANGE, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return input.changedLines() != null
-              ? input.changedLines().deletions
-              : null;
-        }
-      };
-
-  /** The total number of modified lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELTA =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_DELTA, FieldType.INTEGER_RANGE, false) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          ChangedLines changedLines = input.changedLines();
-          return changedLines != null
-              ? changedLines.insertions + changedLines.deletions
-              : null;
-        }
-      };
-
-  /** Users who have commented on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_COMMENTBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<Integer> r = new HashSet<>();
-          for (ChangeMessage m : input.messages()) {
-            if (m.getAuthor() != null) {
-              r.add(m.getAuthor().get());
-            }
-          }
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getAuthor().get());
-          }
-          return r;
-        }
-      };
-
-  /** Opaque group identifiers for this change's patch sets. */
-  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      new FieldDef.Repeatable<ChangeData, String>(
-          "group", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> r = Sets.newHashSetWithExpectedSize(1);
-          for (PatchSet ps : input.patchSets()) {
-            List<String> groups = ps.getGroups();
-            if (groups != null) {
-              r.addAll(groups);
-            }
-          }
-          return r;
-        }
-      };
-
-  public static class PatchSetProtoField
-      extends FieldDef.Repeatable<ChangeData, byte[]> {
-    public static final ProtobufCodec<PatchSet> CODEC =
-        CodecFactory.encoder(PatchSet.class);
-
-    private PatchSetProtoField() {
-      super("_patch_set", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public Iterable<byte[]> get(ChangeData input, FieldDef.FillArgs args)
-        throws OrmException {
-      return toProtos(CODEC, input.patchSets());
-    }
-  }
-
-  /** Serialized patch set object, used for pre-populating results. */
-  public static final PatchSetProtoField PATCH_SET = new PatchSetProtoField();
-
-  /** Users who have edits on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_EDITBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.editsByUser(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id account) {
-              return account.get();
-            }
-          }));
-        }
-      };
-
-  /**
-   * Users the change was reviewed by since the last author update.
-   * <p>
-   * A change is considered reviewed by a user if the latest update by that user
-   * is newer than the latest update by the change author. Both top-level change
-   * messages and new patch sets are considered to be updates.
-   * <p>
-   * If the latest update is by the change owner, then the special value {@link
-   * #NOT_REVIEWED} is emitted.
-   */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_REVIEWEDBY, FieldType.INTEGER, true) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<Account.Id> reviewedBy = input.reviewedBy();
-          if (reviewedBy.isEmpty()) {
-            return ImmutableSet.of(NOT_REVIEWED);
-          }
-          List<Integer> result = new ArrayList<>(reviewedBy.size());
-          for (Account.Id id : reviewedBy) {
-            result.add(id.get());
-          }
-          return result;
-        }
-      };
-
-  public static final Integer NOT_REVIEWED = -1;
-
-  private static String getTopic(ChangeData input) throws OrmException {
-    Change c = input.change();
-    if (c == null) {
-      return null;
-    }
-    return firstNonNull(c.getTopic(), "");
-  }
-
-  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
-      throws OrmException {
-    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
-    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
-    try {
-      for (T obj : objs) {
-        out.reset();
-        CodedOutputStream cos = CodedOutputStream.newInstance(out);
-        codec.encode(obj, cos);
-        cos.flush();
-        result.add(out.toByteArray());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return result;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
deleted file mode 100644
index 02e737a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.QueryOptions;
-
-import java.io.IOException;
-
-/**
- * Secondary index implementation for change documents.
- * <p>
- * {@link ChangeData} objects are inserted into the index and are queried by
- * converting special {@link com.google.gerrit.server.query.Predicate} instances
- * into index-aware predicates that use the index search results as a source.
- * <p>
- * Implementations must be thread-safe and should batch inserts/updates where
- * appropriate.
- */
-public interface ChangeIndex {
-  /** @return the schema version used by this index. */
-  public Schema<ChangeData> getSchema();
-
-  /** Close this index. */
-  public void close();
-
-  /**
-   * Update a change document in the index.
-   * <p>
-   * Semantically equivalent to deleting the document and reinserting it with
-   * new field values. A document that does not already exist is created. Results
-   * may not be immediately visible to searchers, but should be visible within a
-   * reasonable amount of time.
-   *
-   * @param cd change document
-   *
-   * @throws IOException
-   */
-  public void replace(ChangeData cd) throws IOException;
-
-  /**
-   * Delete a change document from the index by id.
-   *
-   * @param id change id
-   *
-   * @throws IOException
-   */
-  public void delete(Change.Id id) throws IOException;
-
-  /**
-   * Delete all change documents from the index.
-   *
-   * @throws IOException
-   */
-  public void deleteAll() throws IOException;
-
-  /**
-   * Convert the given operator predicate into a source searching the index and
-   * returning only the documents matching that predicate.
-   * <p>
-   * This method may be called multiple times for variations on the same
-   * predicate or multiple predicate subtrees in the course of processing a
-   * single query, so it should not have any side effects (e.g. starting a
-   * search in the background).
-   *
-   * @param p the predicate to match. Must be a tree containing only AND, OR,
-   *     or NOT predicates as internal nodes, and {@link IndexPredicate}s as
-   *     leaves.
-   * @param opts query options not implied by the predicate, such as start and
-   *     limit.
-   * @return a source of documents matching the predicate. Documents must be
-   *     returned in descending updated timestamp order.
-   *
-   * @throws QueryParseException if the predicate could not be converted to an
-   *     indexed data source.
-   */
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException;
-
-  /**
-   * Mark whether this index is up-to-date and ready to serve reads.
-   *
-   * @param ready whether the index is ready
-   * @throws IOException
-   */
-  public void markReady(boolean ready) throws IOException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
deleted file mode 100644
index b7ea69e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
+++ /dev/null
@@ -1,284 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.common.base.Function;
-import com.google.common.util.concurrent.Atomics;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import com.google.inject.util.Providers;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicReference;
-
-/**
- * Helper for (re)indexing a change document.
- * <p>
- * Indexing is run in the background, as it may require substantial work to
- * compute some of the fields and/or update the index.
- */
-public class ChangeIndexer {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeIndexer.class);
-
-  public interface Factory {
-    ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
-    ChangeIndexer create(ListeningExecutorService executor,
-        IndexCollection indexes);
-  }
-
-  public static CheckedFuture<?, IOException> allAsList(
-      List<? extends ListenableFuture<?>> futures) {
-    // allAsList propagates the first seen exception, wrapped in
-    // ExecutionException, so we can reuse the same mapper as for a single
-    // future. Assume the actual contents of the exception are not useful to
-    // callers. All exceptions are already logged by IndexTask.
-    return Futures.makeChecked(Futures.allAsList(futures), MAPPER);
-  }
-
-  private static final Function<Exception, IOException> MAPPER =
-      new Function<Exception, IOException>() {
-    @Override
-    public IOException apply(Exception in) {
-      if (in instanceof IOException) {
-        return (IOException) in;
-      } else if (in instanceof ExecutionException
-          && in.getCause() instanceof IOException) {
-        return (IOException) in.getCause();
-      } else {
-        return new IOException(in);
-      }
-    }
-  };
-
-  private final IndexCollection indexes;
-  private final ChangeIndex index;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final ThreadLocalRequestContext context;
-  private final ListeningExecutorService executor;
-
-  @AssistedInject
-  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
-      ChangeData.Factory changeDataFactory,
-      ThreadLocalRequestContext context,
-      @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndex index) {
-    this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.context = context;
-    this.index = index;
-    this.indexes = null;
-  }
-
-  @AssistedInject
-  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
-      ChangeData.Factory changeDataFactory,
-      ThreadLocalRequestContext context,
-      @Assisted ListeningExecutorService executor,
-      @Assisted IndexCollection indexes) {
-    this.executor = executor;
-    this.schemaFactory = schemaFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.context = context;
-    this.index = null;
-    this.indexes = indexes;
-  }
-
-  /**
-   * Start indexing a change.
-   *
-   * @param id change to index.
-   * @return future for the indexing task.
-   */
-  public CheckedFuture<?, IOException> indexAsync(Change.Id id) {
-    return executor != null
-        ? submit(new IndexTask(id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
-  }
-
-  /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public CheckedFuture<?, IOException> indexAsync(Collection<Change.Id> ids) {
-    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(id));
-    }
-    return allAsList(futures);
-  }
-
-  /**
-   * Synchronously index a change.
-   *
-   * @param cd change to index.
-   */
-  public void index(ChangeData cd) throws IOException {
-    for (ChangeIndex i : getWriteIndexes()) {
-      i.replace(cd);
-    }
-  }
-
-  /**
-   * Synchronously index a change.
-   *
-   * @param change change to index.
-   * @param db review database.
-   */
-  public void index(ReviewDb db, Change change) throws IOException {
-    index(changeDataFactory.create(db, change));
-  }
-
-  /**
-   * Start deleting a change.
-   *
-   * @param id change to delete.
-   * @return future for the deleting task.
-   */
-  public CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
-    return executor != null
-        ? submit(new DeleteTask(id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
-  }
-
-  /**
-   * Synchronously delete a change.
-   *
-   * @param id change ID to delete.
-   */
-  public void delete(Change.Id id) throws IOException {
-    new DeleteTask(id).call();
-  }
-
-  private Collection<ChangeIndex> getWriteIndexes() {
-    return indexes != null
-        ? indexes.getWriteIndexes()
-        : Collections.singleton(index);
-  }
-
-  private CheckedFuture<?, IOException> submit(Callable<?> task) {
-    return Futures.makeChecked(
-        Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
-  }
-
-  private class IndexTask implements Callable<Void> {
-    private final Change.Id id;
-
-    private IndexTask(Change.Id id) {
-      this.id = id;
-    }
-
-    @Override
-    public Void call() throws Exception {
-      try {
-        final AtomicReference<Provider<ReviewDb>> dbRef =
-            Atomics.newReference();
-        RequestContext newCtx = new RequestContext() {
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            Provider<ReviewDb> db = dbRef.get();
-            if (db == null) {
-              try {
-                db = Providers.of(schemaFactory.open());
-              } catch (OrmException e) {
-                ProvisionException pe =
-                    new ProvisionException("error opening ReviewDb");
-                pe.initCause(e);
-                throw pe;
-              }
-              dbRef.set(db);
-            }
-            return db;
-          }
-
-          @Override
-          public CurrentUser getUser() {
-            throw new OutOfScopeException("No user during ChangeIndexer");
-          }
-        };
-        RequestContext oldCtx = context.setContext(newCtx);
-        try {
-          ChangeData cd = changeDataFactory.create(
-              newCtx.getReviewDbProvider().get(), id);
-          for (ChangeIndex i : getWriteIndexes()) {
-            i.replace(cd);
-          }
-          return null;
-        } finally  {
-          context.setContext(oldCtx);
-          Provider<ReviewDb> db = dbRef.get();
-          if (db != null) {
-            db.get().close();
-          }
-        }
-      } catch (Exception e) {
-        log.error(String.format("Failed to index change %d", id.get()), e);
-        throw e;
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "index-change-" + id.get();
-    }
-  }
-
-  private class DeleteTask implements Callable<Void> {
-    private final Change.Id id;
-
-    private DeleteTask(Change.Id id) {
-      this.id = id;
-    }
-
-    @Override
-    public Void call() throws IOException {
-      // Don't bother setting a RequestContext to provide the DB.
-      // Implementations should not need to access the DB in order to delete a
-      // change ID.
-      for (ChangeIndex i : getWriteIndexes()) {
-        i.delete(id);
-      }
-      return null;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
deleted file mode 100644
index 4789a14..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
+++ /dev/null
@@ -1,465 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
-import com.google.gerrit.server.query.change.ChangeData;
-
-import java.lang.reflect.Field;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.ParameterizedType;
-import java.util.Collection;
-import java.util.Map;
-
-/** Secondary index schemas for changes. */
-public class ChangeSchemas {
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V14 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC2,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V15 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC2,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V16 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC3,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V17 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC3,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V18 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC3,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V19 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC3,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.LEGACY_REVIEWED,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V20 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.LEGACY_TOPIC3,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY);
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V21 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY);
-
-  @Deprecated
-  static final Schema<ChangeData> V22 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY,
-      ChangeField.EXACT_COMMIT);
-
-  @Deprecated
-  static final Schema<ChangeData> V23 = schema(
-      ChangeField.LEGACY_ID2,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY,
-      ChangeField.EXACT_COMMIT);
-
-  static final Schema<ChangeData> V24 = schema(
-      ChangeField.LEGACY_ID2,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY,
-      ChangeField.EXACT_COMMIT,
-      ChangeField.AUTHOR,
-      ChangeField.COMMITTER);
-
-  static final Schema<ChangeData> V25 = schema(
-      ChangeField.LEGACY_ID2,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.SUBMISSIONID,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY,
-      ChangeField.EXACT_COMMIT,
-      ChangeField.AUTHOR,
-      ChangeField.COMMITTER);
-
-  private static Schema<ChangeData> schema(Collection<FieldDef<ChangeData, ?>> fields) {
-    return new Schema<>(ImmutableList.copyOf(fields));
-  }
-
-  @SafeVarargs
-  private static Schema<ChangeData> schema(FieldDef<ChangeData, ?>... fields) {
-    return schema(ImmutableList.copyOf(fields));
-  }
-
-  public static final ImmutableMap<Integer, Schema<ChangeData>> ALL;
-
-  public static Schema<ChangeData> get(int version) {
-    Schema<ChangeData> schema = ALL.get(version);
-    checkArgument(schema != null, "Unrecognized schema version: %s", version);
-    return schema;
-  }
-
-  public static Schema<ChangeData> getLatest() {
-    return Iterables.getLast(ALL.values());
-  }
-
-  static {
-    Map<Integer, Schema<ChangeData>> all = Maps.newTreeMap();
-    for (Field f : ChangeSchemas.class.getDeclaredFields()) {
-      if (Modifier.isStatic(f.getModifiers())
-          && Modifier.isFinal(f.getModifiers())
-          && Schema.class.isAssignableFrom(f.getType())) {
-        ParameterizedType t = (ParameterizedType) f.getGenericType();
-        if (t.getActualTypeArguments()[0] == ChangeData.class) {
-          try {
-            @SuppressWarnings("unchecked")
-            Schema<ChangeData> schema = (Schema<ChangeData>) f.get(null);
-            checkArgument(f.getName().startsWith("V"));
-            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
-            all.put(schema.getVersion(), schema);
-          } catch (IllegalArgumentException | IllegalAccessException e) {
-            throw new ExceptionInInitializerError(e);
-          }
-        } else {
-          throw new ExceptionInInitializerError(
-              "non-ChangeData schema: " + f);
-        }
-      }
-    }
-    if (all.isEmpty()) {
-      throw new ExceptionInInitializerError("no ChangeSchemas found");
-    }
-    ALL = ImmutableMap.copyOf(all);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndex.java
deleted file mode 100644
index d204905..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndex.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.QueryOptions;
-
-import java.io.IOException;
-
-public class DummyIndex implements ChangeIndex {
-  @Override
-  public Schema<ChangeData> getSchema() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void close() {
-  }
-
-  @Override
-  public void replace(ChangeData cd) throws IOException {
-  }
-
-  @Override
-  public void delete(Change.Id id) throws IOException {
-  }
-
-  @Override
-  public void deleteAll() throws IOException {
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void markReady(boolean ready) throws IOException {
-  }
-
-  public int getMaxLimit() {
-    return Integer.MAX_VALUE;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
index d14a2f7..ed196c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -14,14 +14,34 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.DummyChangeIndex;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 
 public class DummyIndexModule extends AbstractModule {
+  private static class DummyChangeIndexFactory implements ChangeIndex.Factory {
+    @Override
+    public ChangeIndex create(Schema<ChangeData> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static class DummyAccountIndexFactory implements AccountIndex.Factory {
+    @Override
+    public AccountIndex create(Schema<AccountState> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
 
   @Override
   protected void configure() {
     install(new IndexModule(1));
     bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
-    bind(ChangeIndex.class).toInstance(new DummyIndex());
+    bind(Index.class).toInstance(new DummyChangeIndex());
+    bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
+    bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
index cf3fd09..386092d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
@@ -36,7 +36,7 @@
 public abstract class FieldDef<I, T> {
   /** Definition of a single (non-repeatable) field. */
   public abstract static class Single<I, T> extends FieldDef<I, T> {
-    Single(String name, FieldType<T> type, boolean stored) {
+    protected Single(String name, FieldType<T> type, boolean stored) {
       super(name, type, stored);
     }
 
@@ -49,7 +49,7 @@
   /** Definition of a repeatable field. */
   public abstract static class Repeatable<I, T>
       extends FieldDef<I, Iterable<T>> {
-    Repeatable(String name, FieldType<T> type, boolean stored) {
+    protected Repeatable(String name, FieldType<T> type, boolean stored) {
       super(name, type, stored);
       Preconditions.checkArgument(type != FieldType.INTEGER_RANGE,
           "Range queries against repeated fields are unsupported");
@@ -63,8 +63,8 @@
 
   /** Arguments needed to fill in missing data in the input object. */
   public static class FillArgs {
-    final TrackingFooters trackingFooters;
-    final boolean allowsDrafts;
+    public final TrackingFooters trackingFooters;
+    public final boolean allowsDrafts;
 
     @Inject
     FillArgs(TrackingFooters trackingFooters,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
new file mode 100644
index 0000000..d12de44
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+
+import java.io.IOException;
+
+/**
+ * Secondary index implementation for arbitrary documents.
+ * <p>
+ * Documents are inserted into the index and are queried by converting special
+ * {@link com.google.gerrit.server.query.Predicate} instances into index-aware
+ * predicates that use the index search results as a source.
+ * <p>
+ * Implementations must be thread-safe and should batch inserts/updates where
+ * appropriate.
+ */
+public interface Index<K, V> {
+  /** @return the schema version used by this index. */
+  Schema<V> getSchema();
+
+  /** Stop and await termination of all executor threads */
+  void stop();
+
+  /** Close this index. */
+  void close();
+
+  /**
+   * Update a document in the index.
+   * <p>
+   * Semantically equivalent to deleting the document and reinserting it with
+   * new field values. A document that does not already exist is created. Results
+   * may not be immediately visible to searchers, but should be visible within a
+   * reasonable amount of time.
+   *
+   * @param obj document object
+   *
+   * @throws IOException
+   */
+  void replace(V obj) throws IOException;
+
+  /**
+   * Delete a document from the index by key.
+   *
+   * @param key document key
+   *
+   * @throws IOException
+   */
+  void delete(K key) throws IOException;
+
+  /**
+   * Delete all documents from the index.
+   *
+   * @throws IOException
+   */
+  void deleteAll() throws IOException;
+
+  /**
+   * Convert the given operator predicate into a source searching the index and
+   * returning only the documents matching that predicate.
+   * <p>
+   * This method may be called multiple times for variations on the same
+   * predicate or multiple predicate subtrees in the course of processing a
+   * single query, so it should not have any side effects (e.g. starting a
+   * search in the background).
+   *
+   * @param p the predicate to match. Must be a tree containing only AND, OR,
+   *     or NOT predicates as internal nodes, and {@link IndexPredicate}s as
+   *     leaves.
+   * @param opts query options not implied by the predicate, such as start and
+   *     limit.
+   * @return a source of documents matching the predicate, returned in a
+   *     defined order depending on the type of documents.
+   *
+   * @throws QueryParseException if the predicate could not be converted to an
+   *     indexed data source.
+   */
+  DataSource<V> getSource(Predicate<V> p, QueryOptions opts)
+      throws QueryParseException;
+
+  /**
+   * Mark whether this index is up-to-date and ready to serve reads.
+   *
+   * @param ready whether the index is ready
+   * @throws IOException
+   */
+  void markReady(boolean ready) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
index 2380c76..61c4675 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -26,35 +23,33 @@
 import java.util.concurrent.atomic.AtomicReference;
 
 /** Dynamic pointers to the index versions used for searching and writing. */
-@Singleton
-public class IndexCollection implements LifecycleListener {
-  private final CopyOnWriteArrayList<ChangeIndex> writeIndexes;
-  private final AtomicReference<ChangeIndex> searchIndex;
+public abstract class IndexCollection<K, V, I extends Index<K, V>>
+    implements LifecycleListener {
+  private final CopyOnWriteArrayList<I> writeIndexes;
+  private final AtomicReference<I> searchIndex;
 
-  @Inject
-  @VisibleForTesting
-  public IndexCollection() {
+  protected IndexCollection() {
     this.writeIndexes = Lists.newCopyOnWriteArrayList();
     this.searchIndex = new AtomicReference<>();
   }
 
   /** @return the current search index version. */
-  public ChangeIndex getSearchIndex() {
+  public I getSearchIndex() {
     return searchIndex.get();
   }
 
-  public void setSearchIndex(ChangeIndex index) {
-    ChangeIndex old = searchIndex.getAndSet(index);
+  public void setSearchIndex(I index) {
+    I old = searchIndex.getAndSet(index);
     if (old != null && old != index && !writeIndexes.contains(old)) {
       old.close();
     }
   }
 
-  public Collection<ChangeIndex> getWriteIndexes() {
+  public Collection<I> getWriteIndexes() {
     return Collections.unmodifiableCollection(writeIndexes);
   }
 
-  public synchronized ChangeIndex addWriteIndex(ChangeIndex index) {
+  public synchronized I addWriteIndex(I index) {
     int version = index.getSchema().getVersion();
     for (int i = 0; i < writeIndexes.size(); i++) {
       if (writeIndexes.get(i).getSchema().getVersion() == version) {
@@ -82,8 +77,8 @@
     }
   }
 
-  public ChangeIndex getWriteIndex(int version) {
-    for (ChangeIndex i : writeIndexes) {
+  public I getWriteIndex(int version) {
+    for (I i : writeIndexes) {
       if (i.getSchema().getVersion() == version) {
         return i;
       }
@@ -97,12 +92,13 @@
 
   @Override
   public void stop() {
-    ChangeIndex read = searchIndex.get();
+    I read = searchIndex.get();
     if (read != null) {
       read.close();
     }
-    for (ChangeIndex write : writeIndexes) {
+    for (I write : writeIndexes) {
       if (write != read) {
+        write.stop();
         write.close();
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
index 125d32d..12eb347 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
@@ -24,33 +24,30 @@
  * Implementation-specific configuration for secondary indexes.
  * <p>
  * Contains configuration that is tied to a specific index implementation but is
- * otherwise global, i.e. not tied to a specific {@link ChangeIndex} and schema
+ * otherwise global, i.e. not tied to a specific {@link Index} and schema
  * version.
  */
 @AutoValue
 public abstract class IndexConfig {
   private static final int DEFAULT_MAX_TERMS = 1024;
-  private static final int DEFAULT_MAX_PREFIX_TERMS = 100;
 
   public static IndexConfig createDefault() {
-    return create(0, 0, DEFAULT_MAX_TERMS, DEFAULT_MAX_PREFIX_TERMS);
+    return create(0, 0, DEFAULT_MAX_TERMS);
   }
 
   public static IndexConfig fromConfig(Config cfg) {
     return create(
         cfg.getInt("index", null, "maxLimit", 0),
         cfg.getInt("index", null, "maxPages", 0),
-        cfg.getInt("index", null, "maxTerms", 0),
-        cfg.getInt("index", null, "maxPrefixTerms", DEFAULT_MAX_PREFIX_TERMS));
+        cfg.getInt("index", null, "maxTerms", 0));
   }
 
   public static IndexConfig create(int maxLimit, int maxPages,
-      int maxTerms, int maxPrefixTerms) {
+      int maxTerms) {
     return new AutoValue_IndexConfig(
         checkLimit(maxLimit, "maxLimit", Integer.MAX_VALUE),
         checkLimit(maxPages, "maxPages", Integer.MAX_VALUE),
-        checkLimit(maxTerms, "maxTerms", DEFAULT_MAX_TERMS),
-        checkLimit(maxPrefixTerms, "maxPrefixTerms", DEFAULT_MAX_PREFIX_TERMS));
+        checkLimit(maxTerms, "maxTerms", DEFAULT_MAX_TERMS));
   }
 
   private static int checkLimit(int limit, String name, int defaultValue) {
@@ -78,12 +75,4 @@
    *     underlying index, or limited for performance reasons.
    */
   public abstract int maxTerms();
-
-  /**
-   * @return maximum number of prefix terms per query supported by the
-   *     underlying index, or limited for performance reasons. Not enforced for
-   *     general queries; only for specific cases where the query system can
-   *     split into equivalent subqueries.
-   */
-  public abstract int maxPrefixTerms();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
new file mode 100644
index 0000000..629dff8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.collect.ImmutableSortedMap;
+
+/**
+ * Definition of an index over a Gerrit data type.
+ * <p>
+ * An <em>index</em> includes a set of schema definitions along with the
+ * specific implementations used to query the secondary index implementation in
+ * a running server. If you are just interested in the static definition of one
+ * or more schemas, see the implementations of {@link SchemaDefinitions}.
+ */
+public abstract class IndexDefinition<K, V, I extends Index<K, V>> {
+  public interface IndexFactory<K, V, I extends Index<K, V>> {
+    I create(Schema<V> schema);
+  }
+
+  private final SchemaDefinitions<V> schemaDefs;
+  private final IndexCollection<K, V, I> indexCollection;
+  private final IndexFactory<K, V, I> indexFactory;
+  private final SiteIndexer<K, V, I> siteIndexer;
+
+  protected IndexDefinition(
+      SchemaDefinitions<V> schemaDefs,
+      IndexCollection<K, V, I> indexCollection,
+      IndexFactory<K, V, I> indexFactory,
+      SiteIndexer<K, V, I> siteIndexer) {
+    this.schemaDefs = schemaDefs;
+    this.indexCollection = indexCollection;
+    this.indexFactory = indexFactory;
+    this.siteIndexer = siteIndexer;
+  }
+
+  public final String getName() {
+    return schemaDefs.getName();
+  }
+
+  public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
+    return schemaDefs.getSchemas();
+  }
+
+  public final Schema<V> getLatest() {
+    return schemaDefs.getLatest();
+  }
+
+  public final IndexCollection<K, V, I> getIndexCollection() {
+    return indexCollection;
+  }
+
+  public final IndexFactory<K, V, I> getIndexFactory() {
+    return indexFactory;
+  }
+
+  public final SiteIndexer<K, V, I> getSiteIndexer() {
+    return siteIndexer;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index 2799144..d5d90d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,18 +17,37 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.AccountIndexerImpl;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexDefinition;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.Config;
 
+import java.util.Collection;
+import java.util.Set;
+
 /**
  * Module for non-indexer-specific secondary index setup.
  * <p>
@@ -40,6 +59,11 @@
     LUCENE
   }
 
+  public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
+      ImmutableList.<SchemaDefinitions<?>> of(
+          AccountSchemaDefinitions.INSTANCE,
+          ChangeSchemaDefinitions.INSTANCE);
+
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
     Config cfg = injector.getInstance(
@@ -66,18 +90,60 @@
 
   @Override
   protected void configure() {
-    bind(IndexRewriter.class);
-    bind(IndexCollection.class);
-    listener().to(IndexCollection.class);
+    bind(AccountIndexRewriter.class);
+    bind(AccountIndexCollection.class);
+    listener().to(AccountIndexCollection.class);
+    factory(AccountIndexerImpl.Factory.class);
+
+    bind(ChangeIndexRewriter.class);
+    bind(ChangeIndexCollection.class);
+    listener().to(ChangeIndexCollection.class);
     factory(ChangeIndexer.Factory.class);
   }
 
   @Provides
+  Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
+      AccountIndexDefinition accounts,
+      ChangeIndexDefinition changes) {
+    Collection<IndexDefinition<?, ?, ?>> result =
+        ImmutableList.<IndexDefinition<?, ?, ?>> of(
+            accounts,
+            changes);
+    Set<String> expected = FluentIterable.from(ALL_SCHEMA_DEFS)
+        .transform(new Function<SchemaDefinitions<?>, String>() {
+          @Override
+          public String apply(SchemaDefinitions<?> in) {
+            return in.getName();
+          }
+        }).toSet();
+    Set<String> actual = FluentIterable.from(result)
+        .transform(new Function<IndexDefinition<?, ?, ?>, String>() {
+          @Override
+          public String apply(IndexDefinition<?, ?, ?> in) {
+            return in.getName();
+          }
+        }).toSet();
+    if (!expected.equals(actual)) {
+      throw new ProvisionException(
+          "need index definitions for all schemas: "
+          + expected + " != " + actual);
+    }
+    return result;
+  }
+
+  @Provides
+  @Singleton
+  AccountIndexer getAccountIndexer(AccountIndexerImpl.Factory factory,
+      AccountIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
   @Singleton
   ChangeIndexer getChangeIndexer(
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       ChangeIndexer.Factory factory,
-      IndexCollection indexes) {
+      ChangeIndexCollection indexes) {
     // Bind default indexer to interactive executor; callers who need a
     // different executor can use the factory directly.
     return factory.create(executor, indexes);
@@ -97,11 +163,7 @@
       threads = config.getInt("index", null, "threads", 0);
     }
     if (threads <= 0) {
-      threads =
-          config.getInt("changeMerge", null, "interactiveThreadPoolSize", 0);
-    }
-    if (threads <= 0) {
-      return MoreExecutors.newDirectExecutorService();
+      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
     }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive"));
@@ -118,9 +180,6 @@
     }
     int threads = config.getInt("index", null, "batchThreads", 0);
     if (threads <= 0) {
-      threads = config.getInt("changeMerge", null, "threadPoolSize", 0);
-    }
-    if (threads <= 0) {
       threads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
index d3b9e95..ff9ff03 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
@@ -20,7 +20,7 @@
 public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
   private final FieldDef<I, ?> def;
 
-  public IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(FieldDef<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
index d56348b..276c52b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,253 +14,11 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.NotPredicate;
-import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.AndSource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gerrit.server.query.change.LimitPredicate;
-import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.server.query.change.QueryOptions;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 
-import org.eclipse.jgit.util.MutableInteger;
+public interface IndexRewriter<T> {
 
-import java.util.BitSet;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-
-/** Rewriter that pushes boolean logic into the secondary index. */
-@Singleton
-public class IndexRewriter {
-  /** Set of all open change statuses. */
-  public static final Set<Change.Status> OPEN_STATUSES;
-
-  /** Set of all closed change statuses. */
-  public static final Set<Change.Status> CLOSED_STATUSES;
-
-  static {
-    EnumSet<Change.Status> open = EnumSet.noneOf(Change.Status.class);
-    EnumSet<Change.Status> closed = EnumSet.noneOf(Change.Status.class);
-    for (Change.Status s : Change.Status.values()) {
-      if (s.isOpen()) {
-        open.add(s);
-      } else {
-        closed.add(s);
-      }
-    }
-    OPEN_STATUSES = Sets.immutableEnumSet(open);
-    CLOSED_STATUSES = Sets.immutableEnumSet(closed);
-  }
-
-  /**
-   * Get the set of statuses that changes matching the given predicate may have.
-   *
-   * @param in predicate
-   * @return the maximal set of statuses that any changes matching the input
-   *     predicates may have, based on examining boolean and
-   *     {@link ChangeStatusPredicate}s.
-   */
-  public static EnumSet<Change.Status> getPossibleStatus(Predicate<ChangeData> in) {
-    EnumSet<Change.Status> s = extractStatus(in);
-    return s != null ? s : EnumSet.allOf(Change.Status.class);
-  }
-
-  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
-    if (in instanceof ChangeStatusPredicate) {
-      return EnumSet.of(((ChangeStatusPredicate) in).getStatus());
-    } else if (in instanceof NotPredicate) {
-      EnumSet<Status> s = extractStatus(in.getChild(0));
-      return s != null ? EnumSet.complementOf(s) : null;
-    } else if (in instanceof OrPredicate) {
-      EnumSet<Change.Status> r = null;
-      int childrenWithStatus = 0;
-      for (int i = 0; i < in.getChildCount(); i++) {
-        EnumSet<Status> c = extractStatus(in.getChild(i));
-        if (c != null) {
-          if (r == null) {
-            r = EnumSet.noneOf(Change.Status.class);
-          }
-          r.addAll(c);
-          childrenWithStatus++;
-        }
-      }
-      if (r != null && childrenWithStatus < in.getChildCount()) {
-        // At least one child supplied a status but another did not.
-        // Assume all statuses for the children that did not feed a
-        // status at this part of the tree. This matches behavior if
-        // the child was used at the root of a query.
-        return EnumSet.allOf(Change.Status.class);
-      }
-      return r;
-    } else if (in instanceof AndPredicate) {
-      EnumSet<Change.Status> r = null;
-      for (int i = 0; i < in.getChildCount(); i++) {
-        EnumSet<Change.Status> c = extractStatus(in.getChild(i));
-        if (c != null) {
-          if (r == null) {
-            r = EnumSet.allOf(Change.Status.class);
-          }
-          r.retainAll(c);
-        }
-      }
-      return r;
-    }
-    return null;
-  }
-
-  private final IndexCollection indexes;
-  private final IndexConfig config;
-
-  @Inject
-  IndexRewriter(IndexCollection indexes,
-      IndexConfig config) {
-    this.indexes = indexes;
-    this.config = config;
-  }
-
-  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
-      QueryOptions opts) throws QueryParseException {
-    ChangeIndex index = indexes.getSearchIndex();
-
-    MutableInteger leafTerms = new MutableInteger();
-    Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
-    if (in == out || out instanceof IndexPredicate) {
-      return new IndexedChangeQuery(index, out, opts);
-    } else if (out == null /* cannot rewrite */) {
-      return in;
-    } else {
-      return out;
-    }
-  }
-
-  /**
-   * Rewrite a single predicate subtree.
-   *
-   * @param in predicate to rewrite.
-   * @param index index whose schema determines which fields are indexed.
-   * @param opts other query options.
-   * @param leafTerms number of leaf index query terms encountered so far.
-   * @return {@code null} if no part of this subtree can be queried in the
-   *     index directly. {@code in} if this subtree and all its children can be
-   *     queried directly in the index. Otherwise, a predicate that is
-   *     semantically equivalent, with some of its subtrees wrapped to query the
-   *     index directly.
-   * @throws QueryParseException if the underlying index implementation does not
-   *     support this predicate.
-   */
-  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
-      ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
-      throws QueryParseException {
-    if (isIndexPredicate(in, index)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new QueryParseException("too many terms in query");
-      }
-      return in;
-    } else if (in instanceof LimitPredicate) {
-      // Replace any limits with the limit provided by the caller. The caller
-      // should have already searched the predicate tree for limit predicates
-      // and included that in their limit computation.
-      return new LimitPredicate(opts.limit());
-    } else if (!isRewritePossible(in)) {
-      return null; // magic to indicate "in" cannot be rewritten
-    }
-
-    int n = in.getChildCount();
-    BitSet isIndexed = new BitSet(n);
-    BitSet notIndexed = new BitSet(n);
-    BitSet rewritten = new BitSet(n);
-    List<Predicate<ChangeData>> newChildren = Lists.newArrayListWithCapacity(n);
-    for (int i = 0; i < n; i++) {
-      Predicate<ChangeData> c = in.getChild(i);
-      Predicate<ChangeData> nc = rewriteImpl(c, index, opts, leafTerms);
-      if (nc == c) {
-        isIndexed.set(i);
-        newChildren.add(c);
-      } else if (nc == null /* cannot rewrite c */) {
-        notIndexed.set(i);
-        newChildren.add(c);
-      } else {
-        rewritten.set(i);
-        newChildren.add(nc);
-      }
-    }
-
-    if (isIndexed.cardinality() == n) {
-      return in; // All children are indexed, leave as-is for parent.
-    } else if (notIndexed.cardinality() == n) {
-      return null; // Can't rewrite any children, so cannot rewrite in.
-    } else if (rewritten.cardinality() == n) {
-      return in.copy(newChildren); // All children were rewritten.
-    }
-    return partitionChildren(in, newChildren, isIndexed, index, opts);
-  }
-
-  private boolean isIndexPredicate(Predicate<ChangeData> in, ChangeIndex index) {
-    if (!(in instanceof IndexPredicate)) {
-      return false;
-    }
-    IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
-    return index.getSchema().hasField(p.getField());
-  }
-
-  private Predicate<ChangeData> partitionChildren(
-      Predicate<ChangeData> in,
-      List<Predicate<ChangeData>> newChildren,
-      BitSet isIndexed,
-      ChangeIndex index,
-      QueryOptions opts) throws QueryParseException {
-    if (isIndexed.cardinality() == 1) {
-      int i = isIndexed.nextSetBit(0);
-      newChildren.add(
-          0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
-      return copy(in, newChildren);
-    }
-
-    // Group all indexed predicates into a wrapped subtree.
-    List<Predicate<ChangeData>> indexed =
-        Lists.newArrayListWithCapacity(isIndexed.cardinality());
-
-    List<Predicate<ChangeData>> all =
-        Lists.newArrayListWithCapacity(
-            newChildren.size() - isIndexed.cardinality() + 1);
-
-    for (int i = 0; i < newChildren.size(); i++) {
-      Predicate<ChangeData> c = newChildren.get(i);
-      if (isIndexed.get(i)) {
-        indexed.add(c);
-      } else {
-        all.add(c);
-      }
-    }
-    all.add(0, new IndexedChangeQuery(index, in.copy(indexed), opts));
-    return copy(in, all);
-  }
-
-  private Predicate<ChangeData> copy(
-      Predicate<ChangeData> in,
-      List<Predicate<ChangeData>> all) {
-    if (in instanceof AndPredicate) {
-      return new AndSource(all);
-    } else if (in instanceof OrPredicate) {
-      return new OrSource(all);
-    }
-    return in.copy(all);
-  }
-
-  private static boolean isRewritePossible(Predicate<ChangeData> p) {
-    return p.getChildCount() > 0 && (
-           p instanceof AndPredicate
-        || p instanceof OrPredicate
-        || p instanceof NotPredicate);
-  }
+  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts)
+      throws QueryParseException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
deleted file mode 100644
index 683f8cf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
+++ /dev/null
@@ -1,196 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.Paginated;
-import com.google.gerrit.server.query.change.QueryOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.Collection;
-import java.util.Iterator;
-import java.util.List;
-
-/**
- * Wrapper combining an {@link IndexPredicate} together with a
- * {@link ChangeDataSource} that returns matching results from the index.
- * <p>
- * Appropriate to return as the rootmost predicate that can be processed using
- * the secondary index; such predicates must also implement
- * {@link ChangeDataSource} to be chosen by the query processor.
- */
-public class IndexedChangeQuery extends Predicate<ChangeData>
-    implements ChangeDataSource, Paginated {
-  @VisibleForTesting
-  static QueryOptions convertOptions(QueryOptions opts) {
-    // Increase the limit rather than skipping, since we don't know how many
-    // skipped results would have been filtered out by the enclosing AndSource.
-    int backendLimit = opts.config().maxLimit();
-    int limit = Ints.saturatedCast((long) opts.limit() + opts.start());
-    limit = Math.min(limit, backendLimit);
-    return QueryOptions.create(opts.config(), 0, limit);
-  }
-
-  private final ChangeIndex index;
-
-  private QueryOptions opts;
-  private Predicate<ChangeData> pred;
-  private ChangeDataSource source;
-
-  public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred,
-      QueryOptions opts) throws QueryParseException {
-    this.index = index;
-    this.opts = convertOptions(opts);
-    this.pred = pred;
-    this.source = index.getSource(pred, this.opts);
-  }
-
-  @Override
-  public int getChildCount() {
-    return 1;
-  }
-
-  @Override
-  public Predicate<ChangeData> getChild(int i) {
-    if (i == 0) {
-      return pred;
-    }
-    throw new ArrayIndexOutOfBoundsException(i);
-  }
-
-  @Override
-  public List<Predicate<ChangeData>> getChildren() {
-    return ImmutableList.of(pred);
-  }
-
-  @Override
-  public QueryOptions getOptions() {
-    return opts;
-  }
-
-  @Override
-  public int getCardinality() {
-    return source != null ? source.getCardinality() : opts.limit();
-  }
-
-  @Override
-  public boolean hasChange() {
-    return index.getSchema().hasField(ChangeField.CHANGE);
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    final ChangeDataSource currSource = source;
-    final ResultSet<ChangeData> rs = currSource.read();
-
-    return new ResultSet<ChangeData>() {
-      @Override
-      public Iterator<ChangeData> iterator() {
-        return Iterables.transform(
-            rs,
-            new Function<ChangeData, ChangeData>() {
-              @Override
-              public
-              ChangeData apply(ChangeData input) {
-                input.cacheFromSource(currSource);
-                return input;
-              }
-            }).iterator();
-      }
-
-      @Override
-      public List<ChangeData> toList() {
-        List<ChangeData> r = rs.toList();
-        for (ChangeData cd : r) {
-          cd.cacheFromSource(currSource);
-        }
-        return r;
-      }
-
-      @Override
-      public void close() {
-        rs.close();
-      }
-    };
-  }
-
-  @Override
-  public ResultSet<ChangeData> restart(int start) throws OrmException {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new OrmException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
-  }
-
-  @Override
-  public Predicate<ChangeData> copy(
-      Collection<? extends Predicate<ChangeData>> children) {
-    return this;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    return (source != null && cd.isFromSource(source)) || pred.match(cd);
-  }
-
-  @Override
-  public int getCost() {
-    // Index queries are assumed to be cheaper than any other type of query, so
-    // so try to make sure they get picked. Note that pred's cost may be higher
-    // because it doesn't know whether it's being used in an index query or not.
-    return 1;
-  }
-
-  @Override
-  public int hashCode() {
-    return pred.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null || getClass() != other.getClass()) {
-      return false;
-    }
-    IndexedChangeQuery o = (IndexedChangeQuery) other;
-    return pred.equals(o.pred)
-        && opts.equals(o.opts);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper("index")
-        .add("p", pred)
-        .add("opts", opts)
-        .toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
new file mode 100644
index 0000000..65097b4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Paginated;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Wrapper combining an {@link IndexPredicate} together with a
+ * {@link DataSource} that returns matching results from the index.
+ * <p>
+ * Appropriate to return as the rootmost predicate that can be processed using
+ * the secondary index; such predicates must also implement {@link DataSource}
+ * to be chosen by the query processor.
+ *
+ * @param <I> The type of the IDs by which the entities are stored in the index.
+ * @param <T> The type of the entities that are stored in the index.
+ */
+public class IndexedQuery<I, T> extends Predicate<T>
+    implements DataSource<T>, Paginated<T> {
+  protected final Index<I, T> index;
+
+  private QueryOptions opts;
+  private final Predicate<T> pred;
+  protected DataSource<T> source;
+
+  public IndexedQuery(Index<I, T> index, Predicate<T> pred,
+      QueryOptions opts) throws QueryParseException {
+    this.index = index;
+    this.opts = opts;
+    this.pred = pred;
+    this.source = index.getSource(pred, this.opts);
+  }
+
+  @Override
+  public int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<T> getChild(int i) {
+    if (i == 0) {
+      return pred;
+    }
+    throw new ArrayIndexOutOfBoundsException(i);
+  }
+
+  @Override
+  public List<Predicate<T>> getChildren() {
+    return ImmutableList.of(pred);
+  }
+
+  @Override
+  public QueryOptions getOptions() {
+    return opts;
+  }
+
+  @Override
+  public int getCardinality() {
+    return source != null ? source.getCardinality() : opts.limit();
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    return source.read();
+  }
+
+  @Override
+  public ResultSet<T> restart(int start) throws OrmException {
+    opts = opts.withStart(start);
+    try {
+      source = index.getSource(pred, opts);
+    } catch (QueryParseException e) {
+      // Don't need to show this exception to the user; the only thing that
+      // changed about pred was its start, and any other QPEs that might happen
+      // should have already thrown from the constructor.
+      throw new OrmException(e);
+    }
+    // Don't convert start to a limit, since the caller of this method (see
+    // AndSource) has calculated the actual number to skip.
+    return read();
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return this;
+  }
+
+  @Override
+  public int hashCode() {
+    return pred.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
+    return pred.equals(o.pred)
+        && opts.equals(o.opts);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("index")
+        .add("p", pred)
+        .add("opts", opts)
+        .toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
index 1259951..e62a685 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
@@ -31,11 +31,13 @@
     }
   }
 
-  protected abstract int getValueInt(T object) throws OrmException;
+  protected abstract Integer getValueInt(T object) throws OrmException;
 
-  @Override
   public boolean match(T object) throws OrmException {
-    int valueInt = getValueInt(object);
+    Integer valueInt = getValueInt(object);
+    if (valueInt == null) {
+      return false;
+    }
     return valueInt >= range.min && valueInt <= range.max;
   }
 
@@ -48,9 +50,4 @@
   public int getMaximumValue() {
     return range.max;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
new file mode 100644
index 0000000..133d78b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Lists;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class OnlineReindexer<K, V, I extends Index<K, V>> {
+  private static final Logger log = LoggerFactory
+      .getLogger(OnlineReindexer.class);
+
+  private final IndexCollection<K, V, I> indexes;
+  private final SiteIndexer<K, V, I> batchIndexer;
+  private final int version;
+  private I index;
+  private final AtomicBoolean running = new AtomicBoolean();
+
+  public OnlineReindexer(
+      IndexDefinition<K, V, I> def,
+      int version) {
+    this.indexes = def.getIndexCollection();
+    this.batchIndexer = def.getSiteIndexer();
+    this.version = version;
+  }
+
+  public void start() {
+    if (running.compareAndSet(false, true)) {
+      Thread t = new Thread() {
+        @Override
+        public void run() {
+          try {
+            reindex();
+          } finally {
+            running.set(false);
+          }
+        }
+      };
+      t.setName(String.format("Reindex v%d-v%d",
+          version(indexes.getSearchIndex()), version));
+      t.start();
+    }
+  }
+
+  public boolean isRunning() {
+    return running.get();
+  }
+
+  public int getVersion() {
+    return version;
+  }
+
+  private static int version(Index<?, ?> i) {
+    return i.getSchema().getVersion();
+  }
+
+  private void reindex() {
+    index = checkNotNull(indexes.getWriteIndex(version),
+        "not an active write schema version: %s", version);
+    log.info("Starting online reindex from schema version {} to {}",
+        version(indexes.getSearchIndex()), version(index));
+    SiteIndexer.Result result = batchIndexer.indexAll(index);
+    if (!result.success()) {
+      log.error("Online reindex of schema version {} failed. Successfully"
+          + " indexed {} changes, failed to index {} changes",
+          version(index), result.doneCount(), result.failedCount());
+      return;
+    }
+    log.info("Reindex to version {} complete", version(index));
+    activateIndex();
+  }
+
+  public void activateIndex() {
+    indexes.setSearchIndex(index);
+    log.info("Using schema version {}", version(index));
+    try {
+      index.markReady(true);
+    } catch (IOException e) {
+      log.warn("Error activating new schema version {}", version(index));
+    }
+
+    List<I> toRemove = Lists.newArrayListWithExpectedSize(1);
+    for (I i : indexes.getWriteIndexes()) {
+      if (version(i) != version(index)) {
+        toRemove.add(i);
+      }
+    }
+    for (I i : toRemove) {
+      try {
+        i.markReady(false);
+        indexes.removeWriteIndex(version(i));
+      } catch (IOException e) {
+        log.warn("Error deactivating old schema version {}", version(i));
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
new file mode 100644
index 0000000..d0c2095
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+
+import java.util.Set;
+
+@AutoValue
+public abstract class QueryOptions {
+  public static QueryOptions create(IndexConfig config, int start, int limit,
+      Set<String> fields) {
+    checkArgument(start >= 0, "start must be nonnegative: %s", start);
+    checkArgument(limit > 0, "limit must be positive: %s", limit);
+    return new AutoValue_QueryOptions(config, start, limit,
+        ImmutableSet.copyOf(fields));
+  }
+
+  public QueryOptions convertForBackend() {
+    // Increase the limit rather than skipping, since we don't know how many
+    // skipped results would have been filtered out by the enclosing AndSource.
+    int backendLimit = config().maxLimit();
+    int limit = Ints.saturatedCast((long) limit() + start());
+    limit = Math.min(limit, backendLimit);
+    return create(config(), 0, limit, fields());
+  }
+
+  public abstract IndexConfig config();
+  public abstract int start();
+  public abstract int limit();
+  public abstract ImmutableSet<String> fields();
+
+  public QueryOptions withLimit(int newLimit) {
+    return create(config(), start(), newLimit, fields());
+  }
+
+  public QueryOptions withStart(int newStart) {
+    return create(config(), newStart, limit(), fields());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java
deleted file mode 100644
index db7f31a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-
-import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.QueueProvider.QueueType;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.Callable;
-
-public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory
-      .getLogger(ReindexAfterUpdate.class);
-
-  private final OneOffRequestContext requestContext;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeIndexer.Factory indexerFactory;
-  private final IndexCollection indexes;
-  private final ListeningExecutorService executor;
-
-  @Inject
-  ReindexAfterUpdate(
-      OneOffRequestContext requestContext,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeIndexer.Factory indexerFactory,
-      IndexCollection indexes,
-      @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
-    this.requestContext = requestContext;
-    this.queryProvider = queryProvider;
-    this.indexerFactory = indexerFactory;
-    this.indexes = indexes;
-    this.executor = executor;
-  }
-
-  @Override
-  public void onGitReferenceUpdated(final Event event) {
-    Futures.transformAsync(
-        executor.submit(new GetChanges(event)),
-        new AsyncFunction<List<Change>, List<Void>>() {
-          @Override
-          public ListenableFuture<List<Void>> apply(List<Change> changes) {
-            List<ListenableFuture<Void>> result =
-                Lists.newArrayListWithCapacity(changes.size());
-            for (Change c : changes) {
-              result.add(executor.submit(new Index(event, c.getId())));
-            }
-            return Futures.allAsList(result);
-          }
-        });
-  }
-
-  private abstract class Task<V> implements Callable<V> {
-    protected Event event;
-
-    protected Task(Event event) {
-      this.event = event;
-    }
-
-    @Override
-    public final V call() throws Exception {
-      try (ManualRequestContext ctx = requestContext.open()) {
-        return impl(ctx);
-      } catch (Exception e) {
-        log.error("Failed to reindex changes after " + event, e);
-        throw e;
-      }
-    }
-
-    protected abstract V impl(RequestContext ctx) throws Exception;
-  }
-
-  private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
-    }
-
-    @Override
-    protected List<Change> impl(RequestContext ctx) throws OrmException {
-      String ref = event.getRefName();
-      Project.NameKey project = new Project.NameKey(event.getProjectName());
-      if (ref.equals(RefNames.REFS_CONFIG)) {
-        return asChanges(queryProvider.get().byProjectOpen(project));
-      } else {
-        return asChanges(queryProvider.get().byBranchOpen(
-            new Branch.NameKey(project, ref)));
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "Get changes to reindex caused by " + event.getRefName()
-          + " update of project " + event.getProjectName();
-    }
-  }
-
-  private class Index extends Task<Void> {
-    private final Change.Id id;
-
-    Index(Event event, Change.Id id) {
-      super(event);
-      this.id = id;
-    }
-
-    @Override
-    protected Void impl(RequestContext ctx) throws OrmException, IOException {
-      // Reload change, as some time may have passed since GetChanges.
-      ReviewDb db = ctx.getReviewDbProvider().get();
-      Change c = db.changes().get(id);
-      // The change might have been a draft and got deleted
-      if (c != null) {
-        indexerFactory.create(executor, indexes).index(db, c);
-      }
-      return null;
-    }
-
-    @Override
-    public String toString() {
-      return "Index change " + id.get() + " of project "
-          + event.getProjectName();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
index df70292..10f5ecb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Optional;
 import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gwtorm.server.OrmException;
@@ -29,10 +29,38 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
+  public static class Builder<T> {
+    private final List<FieldDef<T, ?>> fields = new ArrayList<>();
+
+    public Builder<T> add(Schema<T> schema) {
+      this.fields.addAll(schema.getFields().values());
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> add(FieldDef<T, ?>... fields) {
+      this.fields.addAll(Arrays.asList(fields));
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(FieldDef<T, ?>... fields) {
+      this.fields.removeAll(Arrays.asList(fields));
+      return this;
+    }
+
+    public Schema<T> build() {
+      return new Schema<>(ImmutableList.copyOf(fields));
+    }
+  }
+
   private static final Logger log = LoggerFactory.getLogger(Schema.class);
 
   public static class Values<T> {
@@ -61,20 +89,26 @@
   }
 
   private final ImmutableMap<String, FieldDef<T, ?>> fields;
+  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
+
   private int version;
 
-  protected Schema(Iterable<FieldDef<T, ?>> fields) {
+  public Schema(Iterable<FieldDef<T, ?>> fields) {
     this(0, fields);
   }
 
-  @VisibleForTesting
   public Schema(int version, Iterable<FieldDef<T, ?>> fields) {
     this.version = version;
     ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
+    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
     for (FieldDef<T, ?> f : fields) {
       b.put(f.getName(), f);
+      if (f.isStored()) {
+        sb.put(f.getName(), f);
+      }
     }
     this.fields = b.build();
+    this.storedFields = sb.build();
   }
 
   public final int getVersion() {
@@ -95,6 +129,14 @@
   }
 
   /**
+   * @return all fields in this schema where {@link FieldDef#isStored()} is
+   *     true.
+   */
+  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+    return storedFields;
+  }
+
+  /**
    * Look up fields in this schema.
    *
    * @param first the preferred field to look up.
@@ -175,7 +217,7 @@
         .toString();
   }
 
-  void setVersion(int version) {
+  public void setVersion(int version) {
     this.version = version;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
new file mode 100644
index 0000000..f9a799e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSortedMap;
+
+/**
+ * Definitions of the various schema versions over a given Gerrit data type.
+ * <p>
+ * A <em>schema</em> is a description of the fields that are indexed over the
+ * given data type. This class contains all the versions of a schema defined
+ * over its data type, exposed as a map of version number to schema definition.
+ * If you are interested in the classes responsible for backend-specific runtime
+ * implementations, see the implementations of {@link IndexDefinition}.
+ */
+public abstract class SchemaDefinitions<V> {
+  private final String name;
+  private final ImmutableSortedMap<Integer, Schema<V>> schemas;
+
+  protected SchemaDefinitions(String name, Class<V> valueClass) {
+    this.name = checkNotNull(name);
+    this.schemas = SchemaUtil.schemasFromClass(getClass(), valueClass);
+  }
+
+  public final String getName() {
+    return name;
+  }
+
+  public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
+    return schemas;
+  }
+
+  public final Schema<V> get(int version) {
+    Schema<V> schema = schemas.get(version);
+    checkArgument(schema != null,
+        "Unrecognized %s schema version: %s", name, version);
+    return schema;
+  }
+
+  public final Schema<V> getLatest() {
+    return schemas.lastEntry().getValue();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
new file mode 100644
index 0000000..ca61b00
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+
+import org.eclipse.jgit.lib.PersonIdent;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+public class SchemaUtil {
+  public static <V> ImmutableSortedMap<Integer, Schema<V>> schemasFromClass(
+      Class<?> schemasClass, Class<V> valueClass) {
+    Map<Integer, Schema<V>> schemas = new HashMap<>();
+    for (Field f : schemasClass.getDeclaredFields()) {
+      if (Modifier.isStatic(f.getModifiers())
+          && Modifier.isFinal(f.getModifiers())
+          && Schema.class.isAssignableFrom(f.getType())) {
+        ParameterizedType t = (ParameterizedType) f.getGenericType();
+        if (t.getActualTypeArguments()[0] == valueClass) {
+          try {
+            f.setAccessible(true);
+            @SuppressWarnings("unchecked")
+            Schema<V> schema = (Schema<V>) f.get(null);
+            checkArgument(f.getName().startsWith("V"));
+            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            schemas.put(schema.getVersion(), schema);
+          } catch (IllegalAccessException e) {
+            throw new IllegalArgumentException(e);
+          }
+        } else {
+          throw new IllegalArgumentException(
+              "non-" + schemasClass.getSimpleName() + " schema: " + f);
+        }
+      }
+    }
+    if (schemas.isEmpty()) {
+      throw new ExceptionInInitializerError("no ChangeSchemas found");
+    }
+    return ImmutableSortedMap.copyOf(schemas);
+  }
+
+  public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
+    return new Schema<>(ImmutableList.copyOf(fields));
+  }
+
+  @SafeVarargs
+  public static <V> Schema<V> schema(Schema<V> schema,
+      FieldDef<V, ?>... moreFields) {
+    return new Schema<>(
+        new ImmutableList.Builder<FieldDef<V, ?>>()
+            .addAll(schema.getFields().values())
+            .addAll(ImmutableList.copyOf(moreFields))
+            .build());
+  }
+
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return schema(ImmutableList.copyOf(fields));
+  }
+
+  public static Set<String> getPersonParts(PersonIdent person) {
+    if (person == null) {
+      return ImmutableSet.of();
+    }
+    return getPersonParts(
+        person.getName(),
+        Collections.singleton(person.getEmailAddress()));
+  }
+
+  public static Set<String> getPersonParts(String name,
+      Iterable<String> emails) {
+    Splitter at = Splitter.on('@');
+    Splitter s = Splitter.on(CharMatcher.anyOf("@.- ")).omitEmptyStrings();
+    HashSet<String> parts = new HashSet<>();
+    for (String email : emails) {
+      if (email == null) {
+        continue;
+      }
+      String lowerEmail = email.toLowerCase();
+      parts.add(lowerEmail);
+      Iterables.addAll(parts, at.split(lowerEmail));
+      Iterables.addAll(parts, s.split(lowerEmail));
+    }
+    if (name != null) {
+      Iterables.addAll(parts, s.split(name.toLowerCase()));
+    }
+    return parts;
+  }
+
+  private SchemaUtil() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
index 53af8ad..8ee1ced 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -15,71 +15,24 @@
 package com.google.gerrit.server.index;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
-import com.google.gerrit.server.git.ScanningChangeCacheImpl;
-import com.google.gerrit.server.patch.PatchListLoader;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
 
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
 import org.eclipse.jgit.util.io.NullOutputStream;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
 import java.io.OutputStream;
 import java.io.PrintWriter;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
-public class SiteIndexer {
-  private static final Logger log =
-      LoggerFactory.getLogger(SiteIndexer.class);
+public abstract class SiteIndexer<K, V, I extends Index<K, V>> {
+  private static final Logger log = LoggerFactory.getLogger(SiteIndexer.class);
 
   public static class Result {
     private final long elapsedNanos;
@@ -87,7 +40,7 @@
     private final int done;
     private final int failed;
 
-    private Result(Stopwatch sw, boolean success, int done, int failed) {
+    public Result(Stopwatch sw, boolean success, int done, int failed) {
       this.elapsedNanos = sw.elapsed(TimeUnit.NANOSECONDS);
       this.success = success;
       this.done = done;
@@ -111,299 +64,78 @@
     }
   }
 
-  private final SchemaFactory<ReviewDb> schemaFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final GitRepositoryManager repoManager;
-  private final ListeningExecutorService executor;
-  private final ChangeIndexer.Factory indexerFactory;
-  private final ThreeWayMergeStrategy mergeStrategy;
-
-  private int numChanges = -1;
-  private OutputStream progressOut = NullOutputStream.INSTANCE;
-  private PrintWriter verboseWriter =
+  protected int totalWork = -1;
+  protected OutputStream progressOut = NullOutputStream.INSTANCE;
+  protected PrintWriter verboseWriter =
       new PrintWriter(NullOutputStream.INSTANCE);
 
-  @Inject
-  SiteIndexer(SchemaFactory<ReviewDb> schemaFactory,
-      ChangeData.Factory changeDataFactory,
-      GitRepositoryManager repoManager,
-      @IndexExecutor(BATCH) ListeningExecutorService executor,
-      ChangeIndexer.Factory indexerFactory,
-      @GerritServerConfig Config config) {
-    this.schemaFactory = schemaFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.repoManager = repoManager;
-    this.executor = executor;
-    this.indexerFactory = indexerFactory;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(config);
+  public void setTotalWork(int num) {
+    totalWork = num;
   }
 
-  public SiteIndexer setNumChanges(int num) {
-    numChanges = num;
-    return this;
-  }
-
-  public SiteIndexer setProgressOut(OutputStream out) {
+  public void setProgressOut(OutputStream out) {
     progressOut = checkNotNull(out);
-    return this;
   }
 
-  public SiteIndexer setVerboseOut(OutputStream out) {
+  public void setVerboseOut(OutputStream out) {
     verboseWriter = new PrintWriter(checkNotNull(out));
-    return this;
   }
 
-  public Result indexAll(ChangeIndex index,
-      Iterable<Project.NameKey> projects) {
-    Stopwatch sw = Stopwatch.createStarted();
-    final MultiProgressMonitor mpm =
-        new MultiProgressMonitor(progressOut, "Reindexing changes");
-    final Task projTask = mpm.beginSubTask("projects",
-        (projects instanceof Collection)
-          ? ((Collection<?>) projects).size()
-          : MultiProgressMonitor.UNKNOWN);
-    final Task doneTask = mpm.beginSubTask(null,
-        numChanges >= 0 ? numChanges : MultiProgressMonitor.UNKNOWN);
-    final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+  public abstract Result indexAll(I index);
 
-    final List<ListenableFuture<?>> futures = Lists.newArrayList();
-    final AtomicBoolean ok = new AtomicBoolean(true);
-
-    for (final Project.NameKey project : projects) {
-      final ListenableFuture<?> future = executor.submit(reindexProject(
-          indexerFactory.create(executor, index), project, doneTask, failedTask,
-          verboseWriter));
-      futures.add(future);
-      future.addListener(new Runnable() {
-        @Override
-        public void run() {
-          try {
-            future.get();
-          } catch (ExecutionException | InterruptedException e) {
-            fail(project, e);
-          } catch (RuntimeException e) {
-            failAndThrow(project, e);
-          } catch (Error e) {
-            // Can't join with RuntimeException because "RuntimeException |
-            // Error" becomes Throwable, which messes with signatures.
-            failAndThrow(project, e);
-          } finally {
-            projTask.update(1);
-          }
-        }
-
-        private void fail(Project.NameKey project, Throwable t) {
-          log.error("Failed to index project " + project, t);
-          ok.set(false);
-        }
-
-        private void failAndThrow(Project.NameKey project, RuntimeException e) {
-          fail(project, e);
-          throw e;
-        }
-
-        private void failAndThrow(Project.NameKey project, Error e) {
-          fail(project, e);
-          throw e;
-        }
-      }, MoreExecutors.directExecutor());
-    }
-
-    try {
-      mpm.waitFor(Futures.transformAsync(Futures.successfulAsList(futures),
-          new AsyncFunction<List<?>, Void>() {
-            @Override
-            public ListenableFuture<Void> apply(List<?> input) {
-              mpm.end();
-              return Futures.immediateFuture(null);
-            }
-      }));
-    } catch (ExecutionException e) {
-      log.error("Error in batch indexer", e);
-      ok.set(false);
-    }
-    return new Result(sw, ok.get(), doneTask.getCount(), failedTask.getCount());
+  protected final void addErrorListener(ListenableFuture<?> future,
+      String desc, ProgressMonitor progress, AtomicBoolean ok) {
+    future.addListener(
+        new ErrorListener(future, desc, progress, ok),
+        MoreExecutors.directExecutor());
   }
 
-  private Callable<Void> reindexProject(final ChangeIndexer indexer,
-      final Project.NameKey project, final Task done, final Task failed,
-      final PrintWriter verboseWriter) {
-    return new Callable<Void>() {
-      @Override
-      public Void call() throws Exception {
-        Multimap<ObjectId, ChangeData> byId = ArrayListMultimap.create();
-        // TODO(dborowitz): Opening all repositories in a live server may be
-        // wasteful; see if we can determine which ones it is safe to close
-        // with RepositoryCache.close(repo).
-        try (Repository repo = repoManager.openRepository(project);
-            ReviewDb db = schemaFactory.open()) {
-          Map<String, Ref> refs = repo.getRefDatabase().getRefs(ALL);
-          for (Change c : ScanningChangeCacheImpl.scan(repo, db)) {
-            Ref r = refs.get(c.currentPatchSetId().toRefName());
-            if (r != null) {
-              byId.put(r.getObjectId(), changeDataFactory.create(db, c));
-            }
-          }
-          new ProjectIndexer(indexer,
-              mergeStrategy,
-              byId,
-              repo,
-              done,
-              failed,
-              verboseWriter).call();
-        } catch (RepositoryNotFoundException rnfe) {
-          log.error(rnfe.getMessage());
-        }
-        return null;
-      }
+  private static class ErrorListener implements Runnable {
+    private final ListenableFuture<?> future;
+    private final String desc;
+    private final ProgressMonitor progress;
+    private final AtomicBoolean ok;
 
-      @Override
-      public String toString() {
-        return "Index all changes of project " + project.get();
-      }
-    };
-  }
-
-  private static class ProjectIndexer implements Callable<Void> {
-    private final ChangeIndexer indexer;
-    private final ThreeWayMergeStrategy mergeStrategy;
-    private final Multimap<ObjectId, ChangeData> byId;
-    private final ProgressMonitor done;
-    private final ProgressMonitor failed;
-    private final PrintWriter verboseWriter;
-    private final Repository repo;
-    private RevWalk walk;
-
-    private ProjectIndexer(ChangeIndexer indexer,
-        ThreeWayMergeStrategy mergeStrategy,
-        Multimap<ObjectId, ChangeData> changesByCommitId,
-        Repository repo,
-        ProgressMonitor done,
-        ProgressMonitor failed,
-        PrintWriter verboseWriter) {
-      this.indexer = indexer;
-      this.mergeStrategy = mergeStrategy;
-      this.byId = changesByCommitId;
-      this.repo = repo;
-      this.done = done;
-      this.failed = failed;
-      this.verboseWriter = verboseWriter;
+    private ErrorListener(ListenableFuture<?> future, String desc,
+        ProgressMonitor progress, AtomicBoolean ok) {
+      this.future = future;
+      this.desc = desc;
+      this.progress = progress;
+      this.ok = ok;
     }
 
     @Override
-    public Void call() throws Exception {
-      walk = new RevWalk(repo);
+    public void run() {
       try {
-        // Walk only refs first to cover as many changes as we can without having
-        // to mark every single change.
-        for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
-          RevObject o = walk.parseAny(ref.getObjectId());
-          if (o instanceof RevCommit) {
-            walk.markStart((RevCommit) o);
-          }
-        }
-
-        RevCommit bCommit;
-        while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
-          if (byId.containsKey(bCommit)) {
-            getPathsAndIndex(bCommit);
-            byId.removeAll(bCommit);
-          }
-        }
-
-        for (ObjectId id : byId.keySet()) {
-          getPathsAndIndex(id);
-        }
+        future.get();
+      } catch (ExecutionException | InterruptedException e) {
+        fail(e);
+      } catch (RuntimeException e) {
+        failAndThrow(e);
+      } catch (Error e) {
+        // Can't join with RuntimeException because "RuntimeException |
+        // Error" becomes Throwable, which messes with signatures.
+        failAndThrow(e);
       } finally {
-        walk.close();
-      }
-      return null;
-    }
-
-    private void getPathsAndIndex(ObjectId b) throws Exception {
-      List<ChangeData> cds = Lists.newArrayList(byId.get(b));
-      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        RevCommit bCommit = walk.parseCommit(b);
-        RevTree bTree = bCommit.getTree();
-        RevTree aTree = aFor(bCommit, walk);
-        df.setRepository(repo);
-        if (!cds.isEmpty()) {
-          List<String> paths = (aTree != null)
-              ? getPaths(df.scan(aTree, bTree))
-              : Collections.<String>emptyList();
-          Iterator<ChangeData> cdit = cds.iterator();
-          for (ChangeData cd ; cdit.hasNext(); cdit.remove()) {
-            cd = cdit.next();
-            try {
-              cd.setCurrentFilePaths(paths);
-              indexer.index(cd);
-              done.update(1);
-              if (verboseWriter != null) {
-                verboseWriter.println("Reindexed change " + cd.getId());
-              }
-            } catch (Exception e) {
-              fail("Failed to index change " + cd.getId(), true, e);
-            }
-          }
-        }
-      } catch (Exception e) {
-        fail("Failed to index commit " + b.name(), false, e);
-        for (ChangeData cd : cds) {
-          fail("Failed to index change " + cd.getId(), true, null);
+        synchronized (progress) {
+          progress.update(1);
         }
       }
     }
 
-    private List<String> getPaths(List<DiffEntry> filenames) {
-      Set<String> paths = Sets.newTreeSet();
-      for (DiffEntry e : filenames) {
-        if (e.getOldPath() != null) {
-          paths.add(e.getOldPath());
-        }
-        if (e.getNewPath() != null) {
-          paths.add(e.getNewPath());
-        }
-      }
-      return ImmutableList.copyOf(paths);
+    private void fail(Throwable t) {
+      log.error("Failed to index " + desc, t);
+      ok.set(false);
     }
 
-    private RevTree aFor(RevCommit b, RevWalk walk) throws IOException {
-      switch (b.getParentCount()) {
-        case 0:
-          return walk.parseTree(emptyTree());
-        case 1:
-          RevCommit a = b.getParent(0);
-          walk.parseBody(a);
-          return walk.parseTree(a.getTree());
-        case 2:
-          return PatchListLoader.automerge(repo, walk, b, mergeStrategy);
-        default:
-          return null;
-      }
+    private void failAndThrow(RuntimeException e) {
+      fail(e);
+      throw e;
     }
 
-    private ObjectId emptyTree() throws IOException {
-      try (ObjectInserter oi = repo.newObjectInserter()) {
-        ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
-        oi.flush();
-        return id;
-      }
-    }
-
-    private void fail(String error, boolean failed, Exception e) {
-      if (failed) {
-        this.failed.update(1);
-      }
-
-      if (e != null) {
-        log.warn(error, e);
-      } else {
-        log.warn(error);
-      }
-
-      if (verboseWriter != null) {
-        verboseWriter.println(error);
-      }
+    private void failAndThrow(Error e) {
+      fail(e);
+      throw e;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
index 8ba7df9..1e2e80b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
@@ -38,9 +38,4 @@
 
   public abstract Date getMinTimestamp();
   public abstract Date getMaxTimestamp();
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
new file mode 100644
index 0000000..824739e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.common.base.Function;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.SchemaUtil;
+
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.Set;
+
+/** Secondary index schemas for accounts. */
+public class AccountField {
+  public static final FieldDef<AccountState, Integer> ID =
+      new FieldDef.Single<AccountState, Integer>(
+          "id", FieldType.INTEGER, true) {
+        @Override
+        public Integer get(AccountState input, FillArgs args) {
+          return input.getAccount().getId().get();
+        }
+      };
+
+  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
+      new FieldDef.Repeatable<AccountState, String>(
+          "external_id", FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(AccountState input, FillArgs args) {
+          return Iterables.transform(
+              input.getExternalIds(),
+              new Function<AccountExternalId, String>() {
+                @Override
+                public String apply(AccountExternalId in) {
+                  return in.getKey().get();
+                }
+              });
+        }
+      };
+
+  /** Fuzzy prefix match on name and email parts. */
+  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
+      new FieldDef.Repeatable<AccountState, String>(
+          "name", FieldType.PREFIX, false) {
+        @Override
+        public Iterable<String> get(AccountState input, FillArgs args) {
+          String fullName = input.getAccount().getFullName();
+          Set<String> parts = SchemaUtil.getPersonParts(
+              fullName,
+              Iterables.transform(
+                  input.getExternalIds(),
+                  new Function<AccountExternalId, String>() {
+                    @Override
+                    public String apply(AccountExternalId in) {
+                      return in.getEmailAddress();
+                    }
+                  }));
+
+          // Additional values not currently added by getPersonParts.
+          // TODO(dborowitz): Move to getPersonParts and remove this hack.
+          if (fullName != null) {
+            parts.add(fullName.toLowerCase());
+          }
+          return parts;
+        }
+      };
+
+  public static final FieldDef<AccountState, String> FULL_NAME =
+      new FieldDef.Single<AccountState, String>("full_name", FieldType.EXACT,
+          false) {
+        @Override
+        public String get(AccountState input, FillArgs args) {
+          return input.getAccount().getFullName();
+        }
+      };
+
+  public static final FieldDef<AccountState, String> ACTIVE =
+      new FieldDef.Single<AccountState, String>(
+          "inactive", FieldType.EXACT, false) {
+        @Override
+        public String get(AccountState input, FillArgs args) {
+          return input.getAccount().isActive() ? "1" : "0";
+        }
+      };
+
+  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
+      new FieldDef.Repeatable<AccountState, String>(
+          "email", FieldType.PREFIX, false) {
+        @Override
+        public Iterable<String> get(AccountState input, FillArgs args) {
+          return FluentIterable.from(input.getExternalIds())
+            .transform(
+                new Function<AccountExternalId, String>() {
+                  @Override
+                  public String apply(AccountExternalId in) {
+                    return in.getEmailAddress();
+                  }
+                })
+            .append(
+                Collections.singleton(input.getAccount().getPreferredEmail()))
+            .filter(Predicates.notNull())
+            .transform(
+                new Function<String, String>() {
+                  @Override
+                  public String apply(String in) {
+                    return in.toLowerCase();
+                  }
+                })
+            .toSet();
+        }
+      };
+
+  public static final FieldDef<AccountState, Timestamp> REGISTERED =
+      new FieldDef.Single<AccountState, Timestamp>(
+          "registered", FieldType.TIMESTAMP, false) {
+        @Override
+        public Timestamp get(AccountState input, FillArgs args) {
+          return input.getAccount().getRegisteredOn();
+        }
+      };
+
+  public static final FieldDef<AccountState, String> USERNAME =
+      new FieldDef.Single<AccountState, String>(
+            "username", FieldType.EXACT, false) {
+        @Override
+        public String get(AccountState input, FillArgs args) {
+          return Strings.nullToEmpty(input.getUserName()).toLowerCase();
+        }
+      };
+
+  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
+      new FieldDef.Repeatable<AccountState, String>(
+          "watchedproject", FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(AccountState input, FillArgs args) {
+          return FluentIterable.from(input.getProjectWatches().keySet())
+              .transform(new Function<ProjectWatchKey, String>() {
+            @Override
+            public String apply(ProjectWatchKey in) {
+              return in.project().get();
+            }
+          }).toSet();
+        }
+      };
+
+  private AccountField() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
new file mode 100644
index 0000000..cb7b3ef
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+
+public interface AccountIndex extends Index<Account.Id, AccountState> {
+  public interface Factory extends
+      IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
new file mode 100644
index 0000000..6aa516c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccountIndexCollection extends
+    IndexCollection<Account.Id, AccountState, AccountIndex> {
+  @Inject
+  @VisibleForTesting
+  public AccountIndexCollection() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
new file mode 100644
index 0000000..ea16e13
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.inject.Inject;
+
+public class AccountIndexDefinition
+    extends IndexDefinition<Account.Id, AccountState, AccountIndex> {
+
+  @Inject
+  AccountIndexDefinition(
+      AccountIndexCollection indexCollection,
+      AccountIndex.Factory indexFactory,
+      AllAccountsIndexer allAccountsIndexer) {
+    super(AccountSchemaDefinitions.INSTANCE, indexCollection, indexFactory,
+        allAccountsIndexer);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
new file mode 100644
index 0000000..65e9b09
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.account.AccountPredicates;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccountIndexRewriter implements IndexRewriter<AccountState> {
+
+  private final AccountIndexCollection indexes;
+
+  @Inject
+  AccountIndexRewriter(AccountIndexCollection indexes) {
+    this.indexes = indexes;
+  }
+
+  @Override
+  public Predicate<AccountState> rewrite(Predicate<AccountState> in,
+      QueryOptions opts) throws QueryParseException {
+    if (!AccountPredicates.hasActive(in)) {
+      in = Predicate.and(in, AccountPredicates.isActive());
+    }
+    AccountIndex index = indexes.getSearchIndex();
+    checkNotNull(index, "no active search index configured for accounts");
+    return new IndexedAccountQuery(index, in, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
new file mode 100644
index 0000000..3203563
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+
+import java.io.IOException;
+
+public interface AccountIndexer {
+
+  /**
+   * Synchronously index an account.
+   *
+   * @param id account id to index.
+   */
+  void index(Account.Id id) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
new file mode 100644
index 0000000..7cdf269
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.Index;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+public class AccountIndexerImpl implements AccountIndexer {
+  public interface Factory {
+    AccountIndexerImpl create(AccountIndexCollection indexes);
+    AccountIndexerImpl create(@Nullable AccountIndex index);
+  }
+
+  private final AccountCache byIdCache;
+  private final DynamicSet<AccountIndexedListener> indexedListener;
+  private final AccountIndexCollection indexes;
+  private final AccountIndex index;
+
+  @AssistedInject
+  AccountIndexerImpl(AccountCache byIdCache,
+      DynamicSet<AccountIndexedListener> indexedListener,
+      @Assisted AccountIndexCollection indexes) {
+    this.byIdCache = byIdCache;
+    this.indexedListener = indexedListener;
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  AccountIndexerImpl(AccountCache byIdCache,
+      DynamicSet<AccountIndexedListener> indexedListener,
+      @Assisted AccountIndex index) {
+    this.byIdCache = byIdCache;
+    this.indexedListener = indexedListener;
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(Account.Id id) throws IOException {
+    for (Index<?, AccountState> i : getWriteIndexes()) {
+      i.replace(byIdCache.get(id));
+    }
+    fireAccountIndexedEvent(id.get());
+  }
+
+  private void fireAccountIndexedEvent(int id) {
+    for (AccountIndexedListener listener : indexedListener) {
+      listener.onAccountIndexed(id);
+    }
+  }
+
+  private Collection<AccountIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null
+        ? Collections.singleton(index)
+        : ImmutableSet.<AccountIndex> of();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
new file mode 100644
index 0000000..bebe668
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.gerrit.server.index.SchemaUtil.schema;
+
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.SchemaDefinitions;
+
+public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+  static final Schema<AccountState> V1 = schema(
+      AccountField.ID,
+      AccountField.ACTIVE,
+      AccountField.EMAIL,
+      AccountField.EXTERNAL_ID,
+      AccountField.NAME_PART,
+      AccountField.REGISTERED,
+      AccountField.USERNAME);
+
+  static final Schema<AccountState> V2 =
+      schema(V1, AccountField.WATCHED_PROJECT);
+
+  static final Schema<AccountState> V3 =
+      schema(V2, AccountField.FULL_NAME);
+
+  public static final AccountSchemaDefinitions INSTANCE =
+      new AccountSchemaDefinitions();
+
+  private AccountSchemaDefinitions() {
+    super("accounts", AccountState.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
new file mode 100644
index 0000000..1c008b46
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.SiteIndexer;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Singleton
+public class AllAccountsIndexer
+    extends SiteIndexer<Account.Id, AccountState, AccountIndex> {
+  private static final Logger log =
+      LoggerFactory.getLogger(AllAccountsIndexer.class);
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ListeningExecutorService executor;
+  private final AccountCache accountCache;
+
+  @Inject
+  AllAccountsIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      AccountCache accountCache) {
+    this.schemaFactory = schemaFactory;
+    this.executor = executor;
+    this.accountCache = accountCache;
+  }
+
+  @Override
+  public SiteIndexer.Result indexAll(final AccountIndex index) {
+    ProgressMonitor progress =
+        new TextProgressMonitor(new PrintWriter(progressOut));
+    progress.start(2);
+    Stopwatch sw = Stopwatch.createStarted();
+    List<Account.Id> ids;
+    try {
+      ids = collectAccounts(progress);
+    } catch (OrmException e) {
+      log.error("Error collecting accounts", e);
+      return new Result(sw, false, 0, 0);
+    }
+    return reindexAccounts(index, ids, progress);
+  }
+
+  private SiteIndexer.Result reindexAccounts(final AccountIndex index,
+      List<Account.Id> ids, ProgressMonitor progress) {
+    progress.beginTask("Reindexing accounts", ids.size());
+    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+    AtomicBoolean ok = new AtomicBoolean(true);
+    final AtomicInteger done = new AtomicInteger();
+    final AtomicInteger failed = new AtomicInteger();
+    Stopwatch sw = Stopwatch.createStarted();
+    for (final Account.Id id : ids) {
+      final String desc = "account " + id;
+      ListenableFuture<?> future = executor.submit(
+          new Callable<Void>() {
+            @Override
+            public Void call() throws Exception {
+              try {
+                accountCache.evict(id);
+                index.replace(accountCache.get(id));
+                if (verboseWriter != null) {
+                  verboseWriter.println("Reindexed " + desc);
+                }
+                done.incrementAndGet();
+              } catch (Exception e) {
+                failed.incrementAndGet();
+                throw e;
+              }
+              return null;
+            }
+          });
+      addErrorListener(future, desc, progress, ok);
+      futures.add(future);
+    }
+
+    try {
+      Futures.successfulAsList(futures).get();
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Error waiting on account futures", e);
+      return new Result(sw, false, 0, 0);
+    }
+
+    progress.endTask();
+    return new Result(sw, ok.get(), done.get(), failed.get());
+  }
+
+  private List<Account.Id> collectAccounts(ProgressMonitor progress)
+      throws OrmException {
+    progress.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
+    List<Account.Id> ids = new ArrayList<>();
+    try (ReviewDb db = schemaFactory.open()) {
+      for (Account account : db.accounts().all()) {
+        ids.add(account.getId());
+      }
+    }
+    progress.endTask();
+    return ids;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
new file mode 100644
index 0000000..76103fc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexedQuery;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+
+public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
+    implements DataSource<AccountState> {
+
+  public IndexedAccountQuery(Index<Account.Id, AccountState> index,
+      Predicate<AccountState> pred, QueryOptions opts)
+          throws QueryParseException {
+    super(index, pred, opts.convertForBackend());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
new file mode 100644
index 0000000..d659215
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -0,0 +1,397 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+import static org.eclipse.jgit.lib.RefDatabase.ALL;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.SiteIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class AllChangesIndexer
+    extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
+  private static final Logger log =
+      LoggerFactory.getLogger(AllChangesIndexer.class);
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final GitRepositoryManager repoManager;
+  private final ListeningExecutorService executor;
+  private final ChangeIndexer.Factory indexerFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ProjectCache projectCache;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final AutoMerger autoMerger;
+
+  @Inject
+  AllChangesIndexer(SchemaFactory<ReviewDb> schemaFactory,
+      ChangeData.Factory changeDataFactory,
+      GitRepositoryManager repoManager,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ChangeIndexer.Factory indexerFactory,
+      ChangeNotes.Factory notesFactory,
+      @GerritServerConfig Config config,
+      ProjectCache projectCache,
+      AutoMerger autoMerger) {
+    this.schemaFactory = schemaFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.repoManager = repoManager;
+    this.executor = executor;
+    this.indexerFactory = indexerFactory;
+    this.notesFactory = notesFactory;
+    this.projectCache = projectCache;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(config);
+    this.autoMerger = autoMerger;
+  }
+
+  private static class ProjectHolder implements Comparable<ProjectHolder> {
+    private Project.NameKey name;
+    private int size;
+
+    ProjectHolder(Project.NameKey name, int size) {
+      this.name = name;
+      this.size = size;
+    }
+
+    @Override
+    public int compareTo(ProjectHolder other) {
+      return ComparisonChain.start()
+          .compare(other.size, size)
+          .compare(other.name.get(), name.get())
+          .result();
+    }
+  }
+
+  @Override
+  public Result indexAll(ChangeIndex index) {
+    ProgressMonitor pm = new TextProgressMonitor();
+    pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
+    SortedSet<ProjectHolder> projects = new TreeSet<>();
+    int changeCount = 0;
+    Stopwatch sw = Stopwatch.createStarted();
+    for (Project.NameKey name : projectCache.all()) {
+      try (Repository repo = repoManager.openRepository(name)) {
+        int size = ChangeNotes.Factory.scan(repo).size();
+        changeCount += size;
+        projects.add(new ProjectHolder(name, size));
+      } catch (IOException e) {
+        log.error("Error collecting projects", e);
+        return new Result(sw, false, 0, 0);
+      }
+      pm.update(1);
+    }
+    pm.endTask();
+    setTotalWork(changeCount);
+
+    return indexAll(index, projects);
+  }
+
+  public SiteIndexer.Result indexAll(ChangeIndex index,
+      Iterable<ProjectHolder> projects) {
+    Stopwatch sw = Stopwatch.createStarted();
+    final MultiProgressMonitor mpm =
+        new MultiProgressMonitor(progressOut, "Reindexing changes");
+    final Task projTask = mpm.beginSubTask("projects",
+        (projects instanceof Collection)
+          ? ((Collection<?>) projects).size()
+          : MultiProgressMonitor.UNKNOWN);
+    final Task doneTask = mpm.beginSubTask(null,
+        totalWork >= 0 ? totalWork : MultiProgressMonitor.UNKNOWN);
+    final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+
+    final List<ListenableFuture<?>> futures = new ArrayList<>();
+    final AtomicBoolean ok = new AtomicBoolean(true);
+
+    for (final ProjectHolder project : projects) {
+      ListenableFuture<?> future = executor.submit(reindexProject(
+          indexerFactory.create(executor, index), project.name, doneTask,
+          failedTask, verboseWriter));
+      addErrorListener(future, "project " + project.name, projTask, ok);
+      futures.add(future);
+    }
+
+    try {
+      mpm.waitFor(Futures.transformAsync(Futures.successfulAsList(futures),
+          new AsyncFunction<List<?>, Void>() {
+            @Override
+            public ListenableFuture<Void> apply(List<?> input) {
+              mpm.end();
+              return Futures.immediateFuture(null);
+            }
+      }));
+    } catch (ExecutionException e) {
+      log.error("Error in batch indexer", e);
+      ok.set(false);
+    }
+    // If too many changes failed, maybe there was a bug in the indexer. Don't
+    // trust the results. This is not an exact percentage since we bump the same
+    // failure counter if a project can't be read, but close enough.
+    int nFailed = failedTask.getCount();
+    int nTotal = nFailed + doneTask.getCount();
+    double pctFailed = ((double) nFailed) / nTotal * 100;
+    if (pctFailed > 10) {
+      log.error("Failed {}/{} changes ({}%); not marking new index as ready",
+          nFailed, nTotal, Math.round(pctFailed));
+      ok.set(false);
+    }
+    return new Result(sw, ok.get(), doneTask.getCount(), failedTask.getCount());
+  }
+
+  private Callable<Void> reindexProject(final ChangeIndexer indexer,
+      final Project.NameKey project, final Task done, final Task failed,
+      final PrintWriter verboseWriter) {
+    return new Callable<Void>() {
+      @Override
+      public Void call() throws Exception {
+        Multimap<ObjectId, ChangeData> byId = ArrayListMultimap.create();
+        // TODO(dborowitz): Opening all repositories in a live server may be
+        // wasteful; see if we can determine which ones it is safe to close
+        // with RepositoryCache.close(repo).
+        try (Repository repo = repoManager.openRepository(project);
+            ReviewDb db = schemaFactory.open()) {
+          Map<String, Ref> refs = repo.getRefDatabase().getRefs(ALL);
+          for (ChangeNotes cn : notesFactory.scan(repo, db, project)) {
+            Ref r = refs.get(cn.getChange().currentPatchSetId().toRefName());
+            if (r != null) {
+              byId.put(r.getObjectId(), changeDataFactory.create(db, cn));
+            }
+          }
+          new ProjectIndexer(indexer,
+              mergeStrategy,
+              autoMerger,
+              byId,
+              repo,
+              done,
+              failed,
+              verboseWriter).call();
+        } catch (RepositoryNotFoundException rnfe) {
+          log.error(rnfe.getMessage());
+        }
+        return null;
+      }
+
+      @Override
+      public String toString() {
+        return "Index all changes of project " + project.get();
+      }
+    };
+  }
+
+  private static class ProjectIndexer implements Callable<Void> {
+    private final ChangeIndexer indexer;
+    private final ThreeWayMergeStrategy mergeStrategy;
+    private final AutoMerger autoMerger;
+    private final Multimap<ObjectId, ChangeData> byId;
+    private final ProgressMonitor done;
+    private final ProgressMonitor failed;
+    private final PrintWriter verboseWriter;
+    private final Repository repo;
+
+    private ProjectIndexer(ChangeIndexer indexer,
+        ThreeWayMergeStrategy mergeStrategy,
+        AutoMerger autoMerger,
+        Multimap<ObjectId, ChangeData> changesByCommitId,
+        Repository repo,
+        ProgressMonitor done,
+        ProgressMonitor failed,
+        PrintWriter verboseWriter) {
+      this.indexer = indexer;
+      this.mergeStrategy = mergeStrategy;
+      this.autoMerger = autoMerger;
+      this.byId = changesByCommitId;
+      this.repo = repo;
+      this.done = done;
+      this.failed = failed;
+      this.verboseWriter = verboseWriter;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      try (ObjectInserter ins = repo.newObjectInserter();
+          RevWalk walk = new RevWalk(ins.newReader())) {
+        // Walk only refs first to cover as many changes as we can without having
+        // to mark every single change.
+        for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
+          RevObject o = walk.parseAny(ref.getObjectId());
+          if (o instanceof RevCommit) {
+            walk.markStart((RevCommit) o);
+          }
+        }
+
+        RevCommit bCommit;
+        while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
+          if (byId.containsKey(bCommit)) {
+            getPathsAndIndex(walk, ins, bCommit);
+            byId.removeAll(bCommit);
+          }
+        }
+
+        for (ObjectId id : byId.keySet()) {
+          getPathsAndIndex(walk, ins, id);
+        }
+      }
+      return null;
+    }
+
+    private void getPathsAndIndex(RevWalk walk, ObjectInserter ins, ObjectId b)
+        throws Exception {
+      List<ChangeData> cds = Lists.newArrayList(byId.get(b));
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        RevCommit bCommit = walk.parseCommit(b);
+        RevTree bTree = bCommit.getTree();
+        RevTree aTree = aFor(bCommit, walk, ins);
+        df.setRepository(repo);
+        if (!cds.isEmpty()) {
+          List<String> paths = (aTree != null)
+              ? getPaths(df.scan(aTree, bTree))
+              : Collections.<String>emptyList();
+          Iterator<ChangeData> cdit = cds.iterator();
+          for (ChangeData cd ; cdit.hasNext(); cdit.remove()) {
+            cd = cdit.next();
+            try {
+              cd.setCurrentFilePaths(paths);
+              indexer.index(cd);
+              done.update(1);
+              if (verboseWriter != null) {
+                verboseWriter.println("Reindexed change " + cd.getId());
+              }
+            } catch (Exception e) {
+              fail("Failed to index change " + cd.getId(), true, e);
+            }
+          }
+        }
+      } catch (Exception e) {
+        fail("Failed to index commit " + b.name(), false, e);
+        for (ChangeData cd : cds) {
+          fail("Failed to index change " + cd.getId(), true, null);
+        }
+      }
+    }
+
+    private List<String> getPaths(List<DiffEntry> filenames) {
+      Set<String> paths = Sets.newTreeSet();
+      for (DiffEntry e : filenames) {
+        if (e.getOldPath() != null) {
+          paths.add(e.getOldPath());
+        }
+        if (e.getNewPath() != null) {
+          paths.add(e.getNewPath());
+        }
+      }
+      return ImmutableList.copyOf(paths);
+    }
+
+    private RevTree aFor(RevCommit b, RevWalk walk, ObjectInserter ins)
+        throws IOException {
+      switch (b.getParentCount()) {
+        case 0:
+          return walk.parseTree(emptyTree());
+        case 1:
+          RevCommit a = b.getParent(0);
+          walk.parseBody(a);
+          return walk.parseTree(a.getTree());
+        case 2:
+          RevCommit am = autoMerger.merge(repo, walk, ins, b, mergeStrategy);
+          return am == null ? null : am.getTree();
+        default:
+          return null;
+      }
+    }
+
+    private ObjectId emptyTree() throws IOException {
+      try (ObjectInserter oi = repo.newObjectInserter()) {
+        ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
+        oi.flush();
+        return id;
+      }
+    }
+
+    private void fail(String error, boolean failed, Exception e) {
+      if (failed) {
+        this.failed.update(1);
+      }
+
+      if (e != null) {
+        log.warn(error, e);
+      } else {
+        log.warn(error);
+      }
+
+      if (verboseWriter != null) {
+        verboseWriter.println(error);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
new file mode 100644
index 0000000..fe448c6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -0,0 +1,826 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.SchemaUtil;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.CodedOutputStream;
+
+import org.eclipse.jgit.revwalk.FooterLine;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Fields indexed on change documents.
+ * <p>
+ * Each field corresponds to both a field name supported by
+ * {@link ChangeQueryBuilder} for querying that field, and a method on
+ * {@link ChangeData} used for populating the corresponding document fields in
+ * the secondary index.
+ * <p>
+ * Field names are all lowercase alphanumeric plus underscore; index
+ * implementations may create unambiguous derived field names containing other
+ * characters.
+ */
+public class ChangeField {
+  /** Legacy change ID. */
+  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
+      new FieldDef.Single<ChangeData, Integer>("legacy_id",
+          FieldType.INTEGER, true) {
+        @Override
+        public Integer get(ChangeData input, FillArgs args) {
+          return input.getId().get();
+        }
+      };
+
+  /** Newer style Change-Id key. */
+  public static final FieldDef<ChangeData, String> ID =
+      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_CHANGE_ID,
+          FieldType.PREFIX, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getKey().get();
+        }
+      };
+
+  /** Change status string, in the same format as {@code status:}. */
+  public static final FieldDef<ChangeData, String> STATUS =
+      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_STATUS,
+          FieldType.EXACT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return ChangeStatusPredicate.canonicalize(c.getStatus());
+        }
+      };
+
+  /** Project containing the change. */
+  public static final FieldDef<ChangeData, String> PROJECT =
+      new FieldDef.Single<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_PROJECT, FieldType.EXACT, true) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getProject().get();
+        }
+      };
+
+  /** Project containing the change, as a prefix field. */
+  public static final FieldDef<ChangeData, String> PROJECTS =
+      new FieldDef.Single<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_PROJECTS, FieldType.PREFIX, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getProject().get();
+        }
+      };
+
+  /** Reference (aka branch) the change will submit onto. */
+  public static final FieldDef<ChangeData, String> REF =
+      new FieldDef.Single<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_REF, FieldType.EXACT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getDest().get();
+        }
+      };
+
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
+      new FieldDef.Single<ChangeData, String>(
+          "topic4", FieldType.EXACT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getTopic(input);
+        }
+      };
+
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
+      new FieldDef.Single<ChangeData, String>(
+          "topic5", FieldType.FULL_TEXT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getTopic(input);
+        }
+      };
+
+  /** Submission id assigned by MergeOp. */
+  public static final FieldDef<ChangeData, String> SUBMISSIONID =
+      new FieldDef.Single<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_SUBMISSIONID, FieldType.EXACT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getSubmissionId();
+        }
+      };
+
+  /** Last update time since January 1, 1970. */
+  public static final FieldDef<ChangeData, Timestamp> UPDATED =
+      new FieldDef.Single<ChangeData, Timestamp>(
+          "updated2", FieldType.TIMESTAMP, true) {
+        @Override
+        public Timestamp get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getLastUpdatedOn();
+        }
+      };
+
+  /** List of full file paths modified in the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> PATH =
+      new FieldDef.Repeatable<ChangeData, String>(
+          // Named for backwards compatibility.
+          ChangeQueryBuilder.FIELD_FILE, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return firstNonNull(input.currentFilePaths(),
+              ImmutableList.<String> of());
+        }
+      };
+
+  public static Set<String> getFileParts(ChangeData cd) throws OrmException {
+    List<String> paths = cd.currentFilePaths();
+    if (paths == null) {
+      return ImmutableSet.of();
+    }
+    Splitter s = Splitter.on('/').omitEmptyStrings();
+    Set<String> r = new HashSet<>();
+    for (String path : paths) {
+      for (String part : s.split(path)) {
+        r.add(part);
+      }
+    }
+    return r;
+  }
+
+  /** Hashtags tied to a change */
+  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_HASHTAG, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
+              new Function<String, String>() {
+            @Override
+            public String apply(String input) {
+              return input.toLowerCase();
+            }
+          }));
+        }
+      };
+
+  /** Hashtags with original case. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
+      new FieldDef.Repeatable<ChangeData, byte[]>(
+          "_hashtag", FieldType.STORED_ONLY, true) {
+        @Override
+        public Iterable<byte[]> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
+              new Function<String, byte[]>() {
+            @Override
+            public byte[] apply(String hashtag) {
+              return hashtag.getBytes(UTF_8);
+            }
+          }));
+        }
+      };
+
+  /** Components of each file path modified in the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_FILEPART, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getFileParts(input);
+        }
+      };
+
+  /** Owner/creator of the change. */
+  public static final FieldDef<ChangeData, Integer> OWNER =
+      new FieldDef.Single<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_OWNER, FieldType.INTEGER, false) {
+        @Override
+        public Integer get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return null;
+          }
+          return c.getOwner().get();
+        }
+      };
+
+  /** Reviewer(s) associated with the change. */
+  @Deprecated
+  public static final FieldDef<ChangeData, Iterable<Integer>> LEGACY_REVIEWER =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_REVIEWER, FieldType.INTEGER, false) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change c = input.change();
+          if (c == null) {
+            return ImmutableSet.of();
+          }
+          Set<Integer> r = new HashSet<>();
+          if (!args.allowsDrafts && c.getStatus() == Change.Status.DRAFT) {
+            return r;
+          }
+          for (PatchSetApproval a : input.approvals().values()) {
+            r.add(a.getAccountId().get());
+          }
+          return r;
+        }
+      };
+
+  /** Reviewer(s) associated with the change. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
+      new FieldDef.Repeatable<ChangeData, String>(
+          "reviewer2", FieldType.EXACT, true) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getReviewerFieldValues(input.reviewers());
+        }
+      };
+
+  @VisibleForTesting
+  static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
+    List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c
+        : reviewers.asTable().cellSet()) {
+      String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerFieldValue(ReviewerStateInternal state,
+      Account.Id id) {
+    return state.toString() + ',' + id;
+  }
+
+  public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+        ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Account.Id.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerSet.fromTable(b.build());
+  }
+
+  /** Commit ID of any patch set on the change, using prefix match. */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_COMMIT, FieldType.PREFIX, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getRevisions(input);
+        }
+      };
+
+  /** Commit ID of any patch set on the change, using exact match. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_EXACTCOMMIT, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getRevisions(input);
+        }
+      };
+
+  private static Set<String> getRevisions(ChangeData cd) throws OrmException {
+    Set<String> revisions = new HashSet<>();
+    for (PatchSet ps : cd.patchSets()) {
+      if (ps.getRevision() != null) {
+        revisions.add(ps.getRevision().get());
+      }
+    }
+    return revisions;
+  }
+
+  /** Tracking id extracted from a footer. */
+  public static final FieldDef<ChangeData, Iterable<String>> TR =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_TR, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          try {
+            List<FooterLine> footers = input.commitFooters();
+            if (footers == null) {
+              return ImmutableSet.of();
+            }
+            return Sets.newHashSet(
+                args.trackingFooters.extract(footers).values());
+          } catch (IOException e) {
+            throw new OrmException(e);
+          }
+        }
+      };
+
+  /** List of labels on the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_LABEL, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<String> allApprovals = new HashSet<>();
+          Set<String> distinctApprovals = new HashSet<>();
+          for (PatchSetApproval a : input.currentApprovals()) {
+            if (a.getValue() != 0 && !a.isLegacySubmit()) {
+              allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
+                  a.getAccountId()));
+              distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+            }
+          }
+          allApprovals.addAll(distinctApprovals);
+          return allApprovals;
+        }
+      };
+
+  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException {
+    try {
+      return SchemaUtil.getPersonParts(cd.getAuthor());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException {
+    try {
+      return SchemaUtil.getPersonParts(cd.getCommitter());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  /**
+   * The exact email address, or any part of the author name or email address,
+   * in the current patch set.
+   */
+  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_AUTHOR, FieldType.FULL_TEXT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getAuthorParts(input);
+        }
+      };
+
+  /**
+   * The exact email address, or any part of the committer name or email address,
+   * in the current patch set.
+   */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_COMMITTER, FieldType.FULL_TEXT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getCommitterParts(input);
+        }
+      };
+
+  public static class ChangeProtoField extends FieldDef.Single<ChangeData, byte[]> {
+    public static final ProtobufCodec<Change> CODEC =
+        CodecFactory.encoder(Change.class);
+
+    private ChangeProtoField() {
+      super("_change", FieldType.STORED_ONLY, true);
+    }
+
+    @Override
+    public byte[] get(ChangeData input, FieldDef.FillArgs args)
+        throws OrmException {
+      Change c = input.change();
+      if (c == null) {
+        return null;
+      }
+      return CODEC.encodeToByteArray(c);
+    }
+  }
+
+  /** Serialized change object, used for pre-populating results. */
+  public static final ChangeProtoField CHANGE = new ChangeProtoField();
+
+  public static class PatchSetApprovalProtoField
+      extends FieldDef.Repeatable<ChangeData, byte[]> {
+    public static final ProtobufCodec<PatchSetApproval> CODEC =
+        CodecFactory.encoder(PatchSetApproval.class);
+
+    private PatchSetApprovalProtoField() {
+      super("_approval", FieldType.STORED_ONLY, true);
+    }
+
+    @Override
+    public Iterable<byte[]> get(ChangeData input, FillArgs args)
+        throws OrmException {
+      return toProtos(CODEC, input.currentApprovals());
+    }
+  }
+
+  /**
+   * Serialized approvals for the current patch set, used for pre-populating
+   * results.
+   */
+  public static final PatchSetApprovalProtoField APPROVAL =
+      new PatchSetApprovalProtoField();
+
+  public static String formatLabel(String label, int value) {
+    return formatLabel(label, value, null);
+  }
+
+  public static String formatLabel(String label, int value, Account.Id accountId) {
+    return label.toLowerCase() + (value >= 0 ? "+" : "") + value
+        + (accountId != null ? "," + accountId.get() : "");
+  }
+
+  /** Commit message of the current patch set. */
+  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
+      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_MESSAGE,
+          FieldType.FULL_TEXT, false) {
+        @Override
+        public String get(ChangeData input, FillArgs args) throws OrmException {
+          try {
+            return input.commitMessage();
+          } catch (IOException e) {
+            throw new OrmException(e);
+          }
+        }
+      };
+
+  /** Summary or inline comment. */
+  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
+      new FieldDef.Repeatable<ChangeData, String>(ChangeQueryBuilder.FIELD_COMMENT,
+          FieldType.FULL_TEXT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<String> r = new HashSet<>();
+          for (PatchLineComment c : input.publishedComments()) {
+            r.add(c.getMessage());
+          }
+          for (ChangeMessage m : input.messages()) {
+            r.add(m.getMessage());
+          }
+          return r;
+        }
+      };
+
+  /** Whether the change is mergeable. */
+  public static final FieldDef<ChangeData, String> MERGEABLE =
+      new FieldDef.Single<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_MERGEABLE, FieldType.EXACT, true) {
+        @Override
+        public String get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Boolean m = input.isMergeable();
+          if (m == null) {
+            return null;
+          }
+          return m ? "1" : "0";
+        }
+      };
+
+  /** The number of inserted lines in this change. */
+  public static final FieldDef<ChangeData, Integer> ADDED =
+      new FieldDef.Single<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_ADDED, FieldType.INTEGER_RANGE, true) {
+        @Override
+        public Integer get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return input.changedLines().isPresent()
+              ? input.changedLines().get().insertions
+              : null;
+        }
+      };
+
+  /** The number of deleted lines in this change. */
+  public static final FieldDef<ChangeData, Integer> DELETED =
+      new FieldDef.Single<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_DELETED, FieldType.INTEGER_RANGE, true) {
+        @Override
+        public Integer get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return input.changedLines().isPresent()
+              ? input.changedLines().get().deletions
+              : null;
+        }
+      };
+
+  /** The total number of modified lines in this change. */
+  public static final FieldDef<ChangeData, Integer> DELTA =
+      new FieldDef.Single<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_DELTA, FieldType.INTEGER_RANGE, false) {
+        @Override
+        public Integer get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Optional<ChangedLines> changedLines = input.changedLines();
+          return changedLines.isPresent()
+              ? changedLines.get().insertions + changedLines.get().deletions
+              : null;
+        }
+      };
+
+  /** Users who have commented on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_COMMENTBY, FieldType.INTEGER, false) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<Integer> r = new HashSet<>();
+          for (ChangeMessage m : input.messages()) {
+            if (m.getAuthor() != null) {
+              r.add(m.getAuthor().get());
+            }
+          }
+          for (PatchLineComment c : input.publishedComments()) {
+            r.add(c.getAuthor().get());
+          }
+          return r;
+        }
+      };
+
+  /** Users who have starred this change. */
+  @Deprecated
+  public static final FieldDef<ChangeData, Iterable<Integer>> STARREDBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_STARREDBY, FieldType.INTEGER, true) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return Iterables.transform(input.starredBy(),
+              new Function<Account.Id, Integer>() {
+            @Override
+            public Integer apply(Account.Id accountId) {
+              return accountId.get();
+            }
+          });
+        }
+      };
+
+  /**
+   * Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt;
+   */
+  public static final FieldDef<ChangeData, Iterable<String>> STAR =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_STAR, FieldType.EXACT, true) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return Iterables.transform(input.stars().entries(),
+              new Function<Map.Entry<Account.Id, String>, String>() {
+            @Override
+            public String apply(Map.Entry<Account.Id, String> e) {
+              return StarredChangesUtil.StarField.create(
+                  e.getKey(), e.getValue()).toString();
+            }
+          });
+        }
+      };
+
+  /** Users that have starred the change with any label. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_STARBY, FieldType.INTEGER, false) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return Iterables.transform(input.stars().keySet(),
+              ReviewDbUtil.INT_KEY_FUNCTION);
+        }
+      };
+
+  /** Opaque group identifiers for this change's patch sets. */
+  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
+      new FieldDef.Repeatable<ChangeData, String>(
+          ChangeQueryBuilder.FIELD_GROUP, FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<String> r = Sets.newHashSetWithExpectedSize(1);
+          for (PatchSet ps : input.patchSets()) {
+            r.addAll(ps.getGroups());
+          }
+          return r;
+        }
+      };
+
+  public static class PatchSetProtoField
+      extends FieldDef.Repeatable<ChangeData, byte[]> {
+    public static final ProtobufCodec<PatchSet> CODEC =
+        CodecFactory.encoder(PatchSet.class);
+
+    private PatchSetProtoField() {
+      super("_patch_set", FieldType.STORED_ONLY, true);
+    }
+
+    @Override
+    public Iterable<byte[]> get(ChangeData input, FieldDef.FillArgs args)
+        throws OrmException {
+      return toProtos(CODEC, input.patchSets());
+    }
+  }
+
+  /** Serialized patch set object, used for pre-populating results. */
+  public static final PatchSetProtoField PATCH_SET = new PatchSetProtoField();
+
+  /** Users who have edits on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_EDITBY, FieldType.INTEGER, false) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return ImmutableSet.copyOf(Iterables.transform(input.editsByUser(),
+              new Function<Account.Id, Integer>() {
+            @Override
+            public Integer apply(Account.Id account) {
+              return account.get();
+            }
+          }));
+        }
+      };
+
+
+  /** Users who have draft comments on this change. */
+  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_DRAFTBY, FieldType.INTEGER, false) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return ImmutableSet.copyOf(Iterables.transform(input.draftsByUser(),
+              new Function<Account.Id, Integer>() {
+            @Override
+            public Integer apply(Account.Id account) {
+              return account.get();
+            }
+          }));
+        }
+      };
+
+  /**
+   * Users the change was reviewed by since the last author update.
+   * <p>
+   * A change is considered reviewed by a user if the latest update by that user
+   * is newer than the latest update by the change author. Both top-level change
+   * messages and new patch sets are considered to be updates.
+   * <p>
+   * If the latest update is by the change owner, then the special value {@link
+   * #NOT_REVIEWED} is emitted.
+   */
+  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
+      new FieldDef.Repeatable<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_REVIEWEDBY, FieldType.INTEGER, true) {
+        @Override
+        public Iterable<Integer> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<Account.Id> reviewedBy = input.reviewedBy();
+          if (reviewedBy.isEmpty()) {
+            return ImmutableSet.of(NOT_REVIEWED);
+          }
+          List<Integer> result = new ArrayList<>(reviewedBy.size());
+          for (Account.Id id : reviewedBy) {
+            result.add(id.get());
+          }
+          return result;
+        }
+      };
+
+  public static final Integer NOT_REVIEWED = -1;
+
+  private static String getTopic(ChangeData input) throws OrmException {
+    Change c = input.change();
+    if (c == null) {
+      return null;
+    }
+    return firstNonNull(c.getTopic(), "");
+  }
+
+  private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
new file mode 100644
index 0000000..9545c0a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.change.ChangeData;
+
+public interface ChangeIndex extends Index<Change.Id, ChangeData> {
+  public interface Factory extends
+      IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
new file mode 100644
index 0000000..dc1c4a5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ChangeIndexCollection extends
+    IndexCollection<Change.Id, ChangeData, ChangeIndex> {
+  @Inject
+  @VisibleForTesting
+  public ChangeIndexCollection() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
new file mode 100644
index 0000000..9bfd11f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+
+public class ChangeIndexDefinition
+    extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
+
+  @Inject
+  ChangeIndexDefinition(
+      ChangeIndexCollection indexCollection,
+      ChangeIndex.Factory indexFactory,
+      AllChangesIndexer allChangesIndexer) {
+    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory,
+        allChangesIndexer);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
new file mode 100644
index 0000000..3523e5f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -0,0 +1,288 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.LimitPredicate;
+import com.google.gerrit.server.query.NotPredicate;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AndChangeSource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.util.MutableInteger;
+
+import java.util.BitSet;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+/** Rewriter that pushes boolean logic into the secondary index. */
+@Singleton
+public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
+  /** Set of all open change statuses. */
+  public static final Set<Change.Status> OPEN_STATUSES;
+
+  /** Set of all closed change statuses. */
+  public static final Set<Change.Status> CLOSED_STATUSES;
+
+  static {
+    EnumSet<Change.Status> open = EnumSet.noneOf(Change.Status.class);
+    EnumSet<Change.Status> closed = EnumSet.noneOf(Change.Status.class);
+    for (Change.Status s : Change.Status.values()) {
+      if (s.isOpen()) {
+        open.add(s);
+      } else {
+        closed.add(s);
+      }
+    }
+    OPEN_STATUSES = Sets.immutableEnumSet(open);
+    CLOSED_STATUSES = Sets.immutableEnumSet(closed);
+  }
+
+  /**
+   * Get the set of statuses that changes matching the given predicate may have.
+   *
+   * @param in predicate
+   * @return the maximal set of statuses that any changes matching the input
+   *     predicates may have, based on examining boolean and
+   *     {@link ChangeStatusPredicate}s.
+   */
+  public static EnumSet<Change.Status> getPossibleStatus(Predicate<ChangeData> in) {
+    EnumSet<Change.Status> s = extractStatus(in);
+    return s != null ? s : EnumSet.allOf(Change.Status.class);
+  }
+
+  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+    if (in instanceof ChangeStatusPredicate) {
+      return EnumSet.of(((ChangeStatusPredicate) in).getStatus());
+    } else if (in instanceof NotPredicate) {
+      EnumSet<Status> s = extractStatus(in.getChild(0));
+      return s != null ? EnumSet.complementOf(s) : null;
+    } else if (in instanceof OrPredicate) {
+      EnumSet<Change.Status> r = null;
+      int childrenWithStatus = 0;
+      for (int i = 0; i < in.getChildCount(); i++) {
+        EnumSet<Status> c = extractStatus(in.getChild(i));
+        if (c != null) {
+          if (r == null) {
+            r = EnumSet.noneOf(Change.Status.class);
+          }
+          r.addAll(c);
+          childrenWithStatus++;
+        }
+      }
+      if (r != null && childrenWithStatus < in.getChildCount()) {
+        // At least one child supplied a status but another did not.
+        // Assume all statuses for the children that did not feed a
+        // status at this part of the tree. This matches behavior if
+        // the child was used at the root of a query.
+        return EnumSet.allOf(Change.Status.class);
+      }
+      return r;
+    } else if (in instanceof AndPredicate) {
+      EnumSet<Change.Status> r = null;
+      for (int i = 0; i < in.getChildCount(); i++) {
+        EnumSet<Change.Status> c = extractStatus(in.getChild(i));
+        if (c != null) {
+          if (r == null) {
+            r = EnumSet.allOf(Change.Status.class);
+          }
+          r.retainAll(c);
+        }
+      }
+      return r;
+    }
+    return null;
+  }
+
+  private final ChangeIndexCollection indexes;
+  private final IndexConfig config;
+
+  @Inject
+  ChangeIndexRewriter(ChangeIndexCollection indexes,
+      IndexConfig config) {
+    this.indexes = indexes;
+    this.config = config;
+  }
+
+  @Override
+  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
+      QueryOptions opts) throws QueryParseException {
+    Predicate<ChangeData> s = rewriteImpl(in, opts);
+    if (!(s instanceof ChangeDataSource)) {
+      in = Predicate.and(open(), in);
+      s = rewriteImpl(in, opts);
+    }
+    if (!(s instanceof ChangeDataSource)) {
+      throw new QueryParseException("invalid query: " + s);
+    }
+    return s;
+  }
+
+  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
+      QueryOptions opts) throws QueryParseException {
+    ChangeIndex index = indexes.getSearchIndex();
+
+    MutableInteger leafTerms = new MutableInteger();
+    Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
+    if (in == out || out instanceof IndexPredicate) {
+      return new IndexedChangeQuery(index, out, opts);
+    } else if (out == null /* cannot rewrite */) {
+      return in;
+    } else {
+      return out;
+    }
+  }
+
+  /**
+   * Rewrite a single predicate subtree.
+   *
+   * @param in predicate to rewrite.
+   * @param index index whose schema determines which fields are indexed.
+   * @param opts other query options.
+   * @param leafTerms number of leaf index query terms encountered so far.
+   * @return {@code null} if no part of this subtree can be queried in the
+   *     index directly. {@code in} if this subtree and all its children can be
+   *     queried directly in the index. Otherwise, a predicate that is
+   *     semantically equivalent, with some of its subtrees wrapped to query the
+   *     index directly.
+   * @throws QueryParseException if the underlying index implementation does not
+   *     support this predicate.
+   */
+  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
+      ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
+      throws QueryParseException {
+    if (isIndexPredicate(in, index)) {
+      if (++leafTerms.value > config.maxTerms()) {
+        throw new QueryParseException("too many terms in query");
+      }
+      return in;
+    } else if (in instanceof LimitPredicate) {
+      // Replace any limits with the limit provided by the caller. The caller
+      // should have already searched the predicate tree for limit predicates
+      // and included that in their limit computation.
+      return new LimitPredicate<>(ChangeQueryBuilder.FIELD_LIMIT, opts.limit());
+    } else if (!isRewritePossible(in)) {
+      return null; // magic to indicate "in" cannot be rewritten
+    }
+
+    int n = in.getChildCount();
+    BitSet isIndexed = new BitSet(n);
+    BitSet notIndexed = new BitSet(n);
+    BitSet rewritten = new BitSet(n);
+    List<Predicate<ChangeData>> newChildren = Lists.newArrayListWithCapacity(n);
+    for (int i = 0; i < n; i++) {
+      Predicate<ChangeData> c = in.getChild(i);
+      Predicate<ChangeData> nc = rewriteImpl(c, index, opts, leafTerms);
+      if (nc == c) {
+        isIndexed.set(i);
+        newChildren.add(c);
+      } else if (nc == null /* cannot rewrite c */) {
+        notIndexed.set(i);
+        newChildren.add(c);
+      } else {
+        rewritten.set(i);
+        newChildren.add(nc);
+      }
+    }
+
+    if (isIndexed.cardinality() == n) {
+      return in; // All children are indexed, leave as-is for parent.
+    } else if (notIndexed.cardinality() == n) {
+      return null; // Can't rewrite any children, so cannot rewrite in.
+    } else if (rewritten.cardinality() == n) {
+      return in.copy(newChildren); // All children were rewritten.
+    }
+    return partitionChildren(in, newChildren, isIndexed, index, opts);
+  }
+
+  private boolean isIndexPredicate(Predicate<ChangeData> in,
+      ChangeIndex index) {
+    if (!(in instanceof IndexPredicate)) {
+      return false;
+    }
+    IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
+    return index.getSchema().hasField(p.getField());
+  }
+
+  private Predicate<ChangeData> partitionChildren(
+      Predicate<ChangeData> in,
+      List<Predicate<ChangeData>> newChildren,
+      BitSet isIndexed,
+      ChangeIndex index,
+      QueryOptions opts) throws QueryParseException {
+    if (isIndexed.cardinality() == 1) {
+      int i = isIndexed.nextSetBit(0);
+      newChildren.add(
+          0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
+      return copy(in, newChildren);
+    }
+
+    // Group all indexed predicates into a wrapped subtree.
+    List<Predicate<ChangeData>> indexed =
+        Lists.newArrayListWithCapacity(isIndexed.cardinality());
+
+    List<Predicate<ChangeData>> all =
+        Lists.newArrayListWithCapacity(
+            newChildren.size() - isIndexed.cardinality() + 1);
+
+    for (int i = 0; i < newChildren.size(); i++) {
+      Predicate<ChangeData> c = newChildren.get(i);
+      if (isIndexed.get(i)) {
+        indexed.add(c);
+      } else {
+        all.add(c);
+      }
+    }
+    all.add(0, new IndexedChangeQuery(index, in.copy(indexed), opts));
+    return copy(in, all);
+  }
+
+  private Predicate<ChangeData> copy(
+      Predicate<ChangeData> in,
+      List<Predicate<ChangeData>> all) {
+    if (in instanceof AndPredicate) {
+      return new AndChangeSource(all);
+    } else if (in instanceof OrPredicate) {
+      return new OrSource(all);
+    }
+    return in.copy(all);
+  }
+
+  private static boolean isRewritePossible(Predicate<ChangeData> p) {
+    return p.getChildCount() > 0 && (
+           p instanceof AndPredicate
+        || p instanceof OrPredicate
+        || p instanceof NotPredicate);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
new file mode 100644
index 0000000..fa4f2fa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -0,0 +1,359 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.Atomics;
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.OutOfScopeException;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.util.Providers;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Helper for (re)indexing a change document.
+ * <p>
+ * Indexing is run in the background, as it may require substantial work to
+ * compute some of the fields and/or update the index.
+ */
+public class ChangeIndexer {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeIndexer.class);
+
+  public interface Factory {
+    ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
+    ChangeIndexer create(ListeningExecutorService executor,
+        ChangeIndexCollection indexes);
+  }
+
+  public static CheckedFuture<?, IOException> allAsList(
+      List<? extends ListenableFuture<?>> futures) {
+    // allAsList propagates the first seen exception, wrapped in
+    // ExecutionException, so we can reuse the same mapper as for a single
+    // future. Assume the actual contents of the exception are not useful to
+    // callers. All exceptions are already logged by IndexTask.
+    return Futures.makeChecked(Futures.allAsList(futures), MAPPER);
+  }
+
+  private static final Function<Exception, IOException> MAPPER =
+      new Function<Exception, IOException>() {
+    @Override
+    public IOException apply(Exception in) {
+      if (in instanceof IOException) {
+        return (IOException) in;
+      } else if (in instanceof ExecutionException
+          && in.getCause() instanceof IOException) {
+        return (IOException) in.getCause();
+      } else {
+        return new IOException(in);
+      }
+    }
+  };
+
+  private final ChangeIndexCollection indexes;
+  private final ChangeIndex index;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final NotesMigration notesMigration;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ThreadLocalRequestContext context;
+  private final ListeningExecutorService executor;
+  private final DynamicSet<ChangeIndexedListener> indexedListener;
+
+  @AssistedInject
+  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+      NotesMigration notesMigration,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      ThreadLocalRequestContext context,
+      DynamicSet<ChangeIndexedListener> indexedListener,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index) {
+    this.executor = executor;
+    this.schemaFactory = schemaFactory;
+    this.notesMigration = notesMigration;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.context = context;
+    this.index = index;
+    this.indexes = null;
+    this.indexedListener = indexedListener;
+  }
+
+  @AssistedInject
+  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+      NotesMigration notesMigration,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      ThreadLocalRequestContext context,
+      DynamicSet<ChangeIndexedListener> indexedListener,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndexCollection indexes) {
+    this.executor = executor;
+    this.schemaFactory = schemaFactory;
+    this.notesMigration = notesMigration;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.context = context;
+    this.index = null;
+    this.indexes = indexes;
+    this.indexedListener = indexedListener;
+  }
+
+  /**
+   * Start indexing a change.
+   *
+   * @param id change to index.
+   * @return future for the indexing task.
+   */
+  public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
+      Change.Id id) {
+    return executor != null
+        ? submit(new IndexTask(project, id))
+        : Futures.<Object, IOException> immediateCheckedFuture(null);
+  }
+
+  /**
+   * Start indexing multiple changes in parallel.
+   *
+   * @param ids changes to index.
+   * @return future for completing indexing of all changes.
+   */
+  public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
+      Collection<Change.Id> ids) {
+    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+    for (Change.Id id : ids) {
+      futures.add(indexAsync(project, id));
+    }
+    return allAsList(futures);
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param cd change to index.
+   */
+  public void index(ChangeData cd) throws IOException {
+    for (Index<?, ChangeData> i : getWriteIndexes()) {
+      i.replace(cd);
+    }
+    fireChangeIndexedEvent(cd.getId().get());
+  }
+
+  private void fireChangeIndexedEvent(int id) {
+    for (ChangeIndexedListener listener : indexedListener) {
+      listener.onChangeIndexed(id);
+    }
+  }
+
+  private void fireChangeDeletedFromIndexEvent(int id) {
+    for (ChangeIndexedListener listener : indexedListener) {
+      listener.onChangeDeleted(id);
+    }
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param db review database.
+   * @param change change to index.
+   */
+  public void index(ReviewDb db, Change change)
+      throws IOException, OrmException {
+    index(newChangeData(db, change));
+  }
+
+  /**
+   * Synchronously index a change.
+   *
+   * @param db review database.
+   * @param project the project to which the change belongs.
+   * @param changeId ID of the change to index.
+   */
+  public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws IOException, OrmException {
+    index(newChangeData(db, project, changeId));
+  }
+
+  /**
+   * Start deleting a change.
+   *
+   * @param id change to delete.
+   * @return future for the deleting task.
+   */
+  public CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
+    return executor != null
+        ? submit(new DeleteTask(id))
+        : Futures.<Object, IOException> immediateCheckedFuture(null);
+  }
+
+  /**
+   * Synchronously delete a change.
+   *
+   * @param id change ID to delete.
+   */
+  public void delete(Change.Id id) throws IOException {
+    new DeleteTask(id).call();
+  }
+
+  private Collection<ChangeIndex> getWriteIndexes() {
+    return indexes != null
+        ? indexes.getWriteIndexes()
+        : Collections.singleton(index);
+  }
+
+  private CheckedFuture<?, IOException> submit(Callable<?> task) {
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
+  }
+
+  private class IndexTask implements Callable<Void> {
+    private final Project.NameKey project;
+    private final Change.Id id;
+
+    private IndexTask(Project.NameKey project, Change.Id id) {
+      this.project = project;
+      this.id = id;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      try {
+        final AtomicReference<Provider<ReviewDb>> dbRef =
+            Atomics.newReference();
+        RequestContext newCtx = new RequestContext() {
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            Provider<ReviewDb> db = dbRef.get();
+            if (db == null) {
+              try {
+                db = Providers.of(schemaFactory.open());
+              } catch (OrmException e) {
+                ProvisionException pe =
+                    new ProvisionException("error opening ReviewDb");
+                pe.initCause(e);
+                throw pe;
+              }
+              dbRef.set(db);
+            }
+            return db;
+          }
+
+          @Override
+          public CurrentUser getUser() {
+            throw new OutOfScopeException("No user during ChangeIndexer");
+          }
+        };
+        RequestContext oldCtx = context.setContext(newCtx);
+        try {
+          ChangeData cd = newChangeData(
+              newCtx.getReviewDbProvider().get(), project, id);
+          index(cd);
+          return null;
+        } finally  {
+          context.setContext(oldCtx);
+          Provider<ReviewDb> db = dbRef.get();
+          if (db != null) {
+            db.get().close();
+          }
+        }
+      } catch (Exception e) {
+        log.error(String.format("Failed to index change %d", id.get()), e);
+        throw e;
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "index-change-" + id.get();
+    }
+  }
+
+  private class DeleteTask implements Callable<Void> {
+    private final Change.Id id;
+
+    private DeleteTask(Change.Id id) {
+      this.id = id;
+    }
+
+    @Override
+    public Void call() throws IOException {
+      // Don't bother setting a RequestContext to provide the DB.
+      // Implementations should not need to access the DB in order to delete a
+      // change ID.
+      for (ChangeIndex i : getWriteIndexes()) {
+        i.delete(id);
+      }
+      fireChangeDeletedFromIndexEvent(id.get());
+      return null;
+    }
+  }
+
+  // Avoid auto-rebuilding when reindexing if reading is disabled. This just
+  // increases contention on the meta ref from a background indexing thread
+  // with little benefit. The next actual write to the entity may still incur a
+  // less-contentious rebuild.
+  private ChangeData newChangeData(ReviewDb db, Change change)
+      throws OrmException {
+    if (!notesMigration.readChanges()) {
+      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(
+          change, null);
+      return changeDataFactory.create(db, notes);
+    }
+    return changeDataFactory.create(db, change);
+  }
+
+  private ChangeData newChangeData(ReviewDb db, Project.NameKey project,
+      Change.Id changeId) throws OrmException {
+    if (!notesMigration.readChanges()) {
+      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(
+          db, project, changeId);
+      return changeDataFactory.create(db, notes);
+    }
+    return changeDataFactory.create(db, project, changeId);
+  }
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
new file mode 100644
index 0000000..c98d311
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.gerrit.server.index.SchemaUtil.schema;
+
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.SchemaDefinitions;
+import com.google.gerrit.server.query.change.ChangeData;
+
+public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  @Deprecated
+  static final Schema<ChangeData> V25 = schema(
+      ChangeField.LEGACY_ID,
+      ChangeField.ID,
+      ChangeField.STATUS,
+      ChangeField.PROJECT,
+      ChangeField.PROJECTS,
+      ChangeField.REF,
+      ChangeField.EXACT_TOPIC,
+      ChangeField.FUZZY_TOPIC,
+      ChangeField.UPDATED,
+      ChangeField.FILE_PART,
+      ChangeField.PATH,
+      ChangeField.OWNER,
+      ChangeField.LEGACY_REVIEWER,
+      ChangeField.COMMIT,
+      ChangeField.TR,
+      ChangeField.LABEL,
+      ChangeField.COMMIT_MESSAGE,
+      ChangeField.COMMENT,
+      ChangeField.CHANGE,
+      ChangeField.APPROVAL,
+      ChangeField.MERGEABLE,
+      ChangeField.ADDED,
+      ChangeField.DELETED,
+      ChangeField.DELTA,
+      ChangeField.HASHTAG,
+      ChangeField.COMMENTBY,
+      ChangeField.PATCH_SET,
+      ChangeField.GROUP,
+      ChangeField.SUBMISSIONID,
+      ChangeField.EDITBY,
+      ChangeField.REVIEWEDBY,
+      ChangeField.EXACT_COMMIT,
+      ChangeField.AUTHOR,
+      ChangeField.COMMITTER);
+
+  @Deprecated
+  static final Schema<ChangeData> V26 = schema(V25, ChangeField.DRAFTBY);
+
+  @Deprecated
+  static final Schema<ChangeData> V27 = schema(V26.getFields().values());
+
+  @Deprecated
+  static final Schema<ChangeData> V28 = schema(V27, ChangeField.STARREDBY);
+
+  @Deprecated
+  static final Schema<ChangeData> V29 =
+      schema(V28, ChangeField.HASHTAG_CASE_AWARE);
+
+  @Deprecated
+  static final Schema<ChangeData> V30 =
+      schema(V29, ChangeField.STAR, ChangeField.STARBY);
+
+  @Deprecated
+  static final Schema<ChangeData> V31 = new Schema.Builder<ChangeData>()
+      .add(V30)
+      .remove(ChangeField.STARREDBY)
+      .build();
+
+  @SuppressWarnings("deprecation")
+  static final Schema<ChangeData> V32 = new Schema.Builder<ChangeData>()
+      .add(V31)
+      .remove(ChangeField.LEGACY_REVIEWER)
+      .add(ChangeField.REVIEWER)
+      .build();
+
+  public static final String NAME = "changes";
+  public static final ChangeSchemaDefinitions INSTANCE =
+      new ChangeSchemaDefinitions();
+
+  private ChangeSchemaDefinitions() {
+    super(NAME, ChangeData.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
new file mode 100644
index 0000000..78c463c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+
+import java.io.IOException;
+
+public class DummyChangeIndex implements ChangeIndex {
+  @Override
+  public Schema<ChangeData> getSchema() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void close() {
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+  }
+
+  @Override
+  public void delete(Change.Id id) throws IOException {
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+  }
+
+  public int getMaxLimit() {
+    return Integer.MAX_VALUE;
+  }
+
+  @Override
+  public void stop() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
new file mode 100644
index 0000000..996caa7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IndexedQuery;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Wrapper combining an {@link IndexPredicate} together with a
+ * {@link ChangeDataSource} that returns matching results from the index.
+ * <p>
+ * Appropriate to return as the rootmost predicate that can be processed using
+ * the secondary index; such predicates must also implement
+ * {@link ChangeDataSource} to be chosen by the query processor.
+ */
+public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
+    implements ChangeDataSource, Matchable<ChangeData> {
+  public static QueryOptions oneResult() {
+    return createOptions(IndexConfig.createDefault(), 0, 1,
+        ImmutableSet.<String> of());
+  }
+
+  public static QueryOptions createOptions(IndexConfig config, int start,
+      int limit, Set<String> fields) {
+    // Always include project since it is needed to load the change from NoteDb.
+    if (!fields.contains(CHANGE.getName())
+        && !fields.contains(PROJECT.getName())) {
+      fields = new HashSet<>(fields);
+      fields.add(PROJECT.getName());
+    }
+    return QueryOptions.create(config, start, limit, fields);
+  }
+
+  @VisibleForTesting
+  static QueryOptions convertOptions(QueryOptions opts) {
+    opts = opts.convertForBackend();
+    return IndexedChangeQuery.createOptions(opts.config(), opts.start(),
+        opts.limit(), opts.fields());
+  }
+
+  private final Map<ChangeData, DataSource<ChangeData>> fromSource;
+
+  public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred,
+      QueryOptions opts) throws QueryParseException {
+    super(index, pred, convertOptions(opts));
+    this.fromSource = new HashMap<>();
+  }
+
+  @Override
+  public ResultSet<ChangeData> read() throws OrmException {
+    final DataSource<ChangeData> currSource = source;
+    final ResultSet<ChangeData> rs = currSource.read();
+
+    return new ResultSet<ChangeData>() {
+      @Override
+      public Iterator<ChangeData> iterator() {
+        return Iterables.transform(
+            rs,
+            new Function<ChangeData, ChangeData>() {
+              @Override
+              public ChangeData apply(ChangeData cd) {
+                fromSource.put(cd, currSource);
+                return cd;
+              }
+            }).iterator();
+      }
+
+      @Override
+      public List<ChangeData> toList() {
+        List<ChangeData> r = rs.toList();
+        for (ChangeData cd : r) {
+          fromSource.put(cd, currSource);
+        }
+        return r;
+      }
+
+      @Override
+      public void close() {
+        rs.close();
+      }
+    };
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    if (source != null && fromSource.get(cd) == source) {
+      return true;
+    }
+
+    Predicate<ChangeData> pred = getChild(0);
+    checkState(pred.isMatchable(),
+        "match invoked, but child predicate %s " + "doesn't implement %s", pred,
+        Matchable.class.getName());
+    return pred.asMatchable().match(cd);
+  }
+
+  @Override
+  public int getCost() {
+    // Index queries are assumed to be cheaper than any other type of query, so
+    // so try to make sure they get picked. Note that pred's cost may be higher
+    // because it doesn't know whether it's being used in an index query or not.
+    return 1;
+  }
+
+  @Override
+  public boolean hasChange() {
+    return index.getSchema().hasField(ChangeField.CHANGE);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
new file mode 100644
index 0000000..942ce88
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.QueueProvider.QueueType;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory
+      .getLogger(ReindexAfterUpdate.class);
+
+  private final OneOffRequestContext requestContext;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeIndexer.Factory indexerFactory;
+  private final ChangeIndexCollection indexes;
+  private final ChangeNotes.Factory notesFactory;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  ReindexAfterUpdate(
+      OneOffRequestContext requestContext,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeIndexer.Factory indexerFactory,
+      ChangeIndexCollection indexes,
+      ChangeNotes.Factory notesFactory,
+      @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
+    this.requestContext = requestContext;
+    this.queryProvider = queryProvider;
+    this.indexerFactory = indexerFactory;
+    this.indexes = indexes;
+    this.notesFactory = notesFactory;
+    this.executor = executor;
+  }
+
+  @Override
+  public void onGitReferenceUpdated(final Event event) {
+    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)
+        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
+      return;
+    }
+    Futures.addCallback(
+        executor.submit(new GetChanges(event)),
+        new FutureCallback<List<Change>>() {
+          @Override
+          public void onSuccess(List<Change> changes) {
+            for (Change c : changes) {
+              executor.submit(new Index(event, c.getId()));
+            }
+          }
+
+          @Override
+          public void onFailure(Throwable ignored) {
+            // Logged by {@link GetChanges#call()}.
+          }
+        });
+  }
+
+  private abstract class Task<V> implements Callable<V> {
+    protected Event event;
+
+    protected Task(Event event) {
+      this.event = event;
+    }
+
+    @Override
+    public final V call() throws Exception {
+      try (ManualRequestContext ctx = requestContext.open()) {
+        return impl(ctx);
+      } catch (Exception e) {
+        log.error("Failed to reindex changes after " + event, e);
+        throw e;
+      }
+    }
+
+    protected abstract V impl(RequestContext ctx) throws Exception;
+  }
+
+  private class GetChanges extends Task<List<Change>> {
+    private GetChanges(Event event) {
+      super(event);
+    }
+
+    @Override
+    protected List<Change> impl(RequestContext ctx) throws OrmException {
+      String ref = event.getRefName();
+      Project.NameKey project = new Project.NameKey(event.getProjectName());
+      if (ref.equals(RefNames.REFS_CONFIG)) {
+        return asChanges(queryProvider.get().byProjectOpen(project));
+      }
+      return asChanges(queryProvider.get().byBranchNew(
+          new Branch.NameKey(project, ref)));
+    }
+
+    @Override
+    public String toString() {
+      return "Get changes to reindex caused by " + event.getRefName()
+          + " update of project " + event.getProjectName();
+    }
+  }
+
+  private class Index extends Task<Void> {
+    private final Change.Id id;
+
+    Index(Event event, Change.Id id) {
+      super(event);
+      this.id = id;
+    }
+
+    @Override
+    protected Void impl(RequestContext ctx)
+        throws OrmException, IOException, NoSuchChangeException {
+      // Reload change, as some time may have passed since GetChanges.
+      ReviewDb db = ctx.getReviewDbProvider().get();
+      try {
+        Change c = notesFactory
+            .createChecked(db, new Project.NameKey(event.getProjectName()), id)
+            .getChange();
+        indexerFactory.create(executor, indexes).index(db, c);
+      } catch (NoSuchChangeException e) {
+        indexerFactory.create(executor, indexes).delete(id);
+      }
+      return null;
+    }
+
+    @Override
+    public String toString() {
+      return "Index change " + id.get() + " of project "
+          + event.getProjectName();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 8ea6653..989b270 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -104,10 +104,9 @@
       if ((value & ~0x7F) == 0) {
         output.write(value);
         return;
-      } else {
-        output.write((value & 0x7F) | 0x80);
-        value >>>= 7;
       }
+      output.write((value & 0x7F) | 0x80);
+      value >>>= 7;
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
index 409c155..1e8bdf4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
@@ -17,22 +17,25 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change being abandoned by its owner. */
 public class AbandonedSender extends ReplyToChangeSender {
-  public static interface Factory extends
+  public interface Factory extends
       ReplyToChangeSender.Factory<AbandonedSender> {
     @Override
-    AbandonedSender create(Change.Id change);
+    AbandonedSender create(Project.NameKey project, Change.Id change);
   }
 
   @Inject
-  public AbandonedSender(EmailArguments ea, @Assisted Change.Id id)
+  public AbandonedSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "abandon", newChangeData(ea, id));
+    super(ea, "abandon", newChangeData(ea, project, id));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
index 0f1e86e..f825d1c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
@@ -25,9 +25,9 @@
 
 public class AddKeySender extends OutgoingEmail {
   public interface Factory {
-    public AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
+    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
 
-    public AddKeySender create(IdentifiedUser user, List<String> gpgKey);
+    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
   private final IdentifiedUser callingUser;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
index 7a6d204..5f61353 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
@@ -14,22 +14,32 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
 public class AddReviewerSender extends NewChangeSender {
-  public static interface Factory {
-    AddReviewerSender create(Change.Id id);
+  public interface Factory {
+    AddReviewerSender create(Project.NameKey project, Change.Id id,
+        NotifyHandling notify);
   }
 
   @Inject
-  public AddReviewerSender(EmailArguments ea, @Assisted Change.Id id)
+  public AddReviewerSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id,
+      @Assisted @Nullable NotifyHandling notify)
       throws OrmException {
-    super(ea, newChangeData(ea, id));
+    super(ea, newChangeData(ea, project, id));
+    if (notify != null) {
+      setNotify(notify);
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
index 8948ce3..badc706 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.mail;
 
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.collect.Multimap;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -23,14 +27,16 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.StarredChange;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.ProjectWatch.Watchers;
-import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -44,10 +50,13 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
 
@@ -55,15 +64,17 @@
 public abstract class ChangeEmail extends NotificationEmail {
   private static final Logger log = LoggerFactory.getLogger(ChangeEmail.class);
 
-  protected static ChangeData newChangeData(EmailArguments ea, Change.Id id) {
-    return ea.changeDataFactory.create(ea.db.get(), id);
+  protected static ChangeData newChangeData(EmailArguments ea,
+      Project.NameKey project, Change.Id id) {
+    return ea.changeDataFactory.create(ea.db.get(), project, id);
   }
 
   protected final Change change;
   protected final ChangeData changeData;
   protected PatchSet patchSet;
   protected PatchSetInfo patchSetInfo;
-  protected ChangeMessage changeMessage;
+  protected String changeMessage;
+  protected Timestamp timestamp;
 
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
@@ -95,8 +106,14 @@
     patchSetInfo = psi;
   }
 
+  @Deprecated
   public void setChangeMessage(final ChangeMessage cm) {
+    setChangeMessage(cm.getMessage(), cm.getWrittenOn());
+  }
+
+  public void setChangeMessage(String cm, Timestamp t) {
     changeMessage = cm;
+    timestamp = t;
   }
 
   /** Format the message body by calling {@link #appendText(String)}. */
@@ -106,7 +123,7 @@
     appendText(velocifyFile("ChangeFooter.vm"));
     try {
       TreeSet<String> names = new TreeSet<>();
-      for (Account.Id who : changeData.reviewers().values()) {
+      for (Account.Id who : changeData.reviewers().all()) {
         names.add(getNameEmailFor(who));
       }
       for (String name : names) {
@@ -140,7 +157,7 @@
 
     if (patchSet == null) {
       try {
-        patchSet = args.db.get().patchSets().get(change.currentPatchSetId());
+        patchSet = changeData.currentPatchSet();
       } catch (OrmException err) {
         patchSet = null;
       }
@@ -148,17 +165,17 @@
 
     if (patchSet != null && patchSetInfo == null) {
       try {
-        patchSetInfo = args.patchSetInfoFactory.get(args.db.get(), patchSet.getId());
-      } catch (PatchSetInfoNotAvailableException err) {
+        patchSetInfo = args.patchSetInfoFactory.get(
+            args.db.get(), changeData.notes(), patchSet.getId());
+      } catch (PatchSetInfoNotAvailableException | OrmException err) {
         patchSetInfo = null;
       }
     }
     authors = getAuthors();
 
     super.init();
-
-    if (changeMessage != null && changeMessage.getWrittenOn() != null) {
-      setHeader("Date", new Date(changeMessage.getWrittenOn().getTime()));
+    if (timestamp != null) {
+      setHeader("Date", new Date(timestamp.getTime()));
     }
     setChangeSubjectHeader();
     setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
@@ -210,13 +227,10 @@
     }
   }
 
-  /** Get the text of the "cover letter", from {@link ChangeMessage}. */
+  /** Get the text of the "cover letter". */
   public String getCoverLetter() {
     if (changeMessage != null) {
-      final String txt = changeMessage.getMessage();
-      if (txt != null) {
-        return txt.trim();
-      }
+      return changeMessage.trim();
     }
     return "";
   }
@@ -293,14 +307,29 @@
 
   /** BCC any user who has starred this change. */
   protected void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return;
+    }
+
     try {
-      // BCC anyone who has starred this change.
+      // BCC anyone who has starred this change
+      // and remove anyone who has ignored this change.
       //
-      for (StarredChange w : args.db.get().starredChanges().byChange(
-          change.getId())) {
-        super.add(RecipientType.BCC, w.getAccountId());
+      Multimap<Account.Id, String> stars =
+          args.starredChangesUtil.byChangeFromIndex(change.getId());
+      for (Map.Entry<Account.Id, Collection<String>> e :
+          stars.asMap().entrySet()) {
+        if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
+          super.add(RecipientType.BCC, e.getKey());
+        }
+        if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
+          AccountState accountState = args.accountCache.get(e.getKey());
+          if (accountState != null) {
+            removeUser(accountState.getAccount());
+          }
+        }
       }
-    } catch (OrmException err) {
+    } catch (OrmException | NoSuchChangeException err) {
       // Just don't BCC everyone. Better to send a partial message to those
       // we already have queued up then to fail deliver entirely to people
       // who have a lower interest in the change.
@@ -310,6 +339,10 @@
 
   @Override
   protected final Watchers getWatchers(NotifyType type) throws OrmException {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return new Watchers();
+    }
+
     ProjectWatch watch = new ProjectWatch(
         args, branch.getParentKey(), projectState, changeData);
     return watch.getWatchers(type);
@@ -317,8 +350,13 @@
 
   /** Any user who has published comments on this change. */
   protected void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(notify)
+        && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
     try {
-      for (Account.Id id : changeData.reviewers().values()) {
+      for (Account.Id id : changeData.reviewers().all()) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
@@ -328,8 +366,13 @@
 
   /** Users who have non-zero approval codes on the change. */
   protected void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(notify)
+        && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
     try {
-      for (Account.Id id : changeData.reviewers().get(ReviewerState.REVIEWER)) {
+      for (Account.Id id : changeData.reviewers().byState(REVIEWER)) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
@@ -348,25 +391,36 @@
   protected boolean isVisibleTo(final Account.Id to) throws OrmException {
     return projectState == null
         || projectState.controlFor(args.identifiedUserFactory.create(to))
-            .controlFor(change).isVisible(args.db.get());
+            .controlFor(args.db.get(), change).isVisible(args.db.get());
   }
 
   /** Find all users who are authors of any part of this change. */
   protected Set<Account.Id> getAuthors() {
     Set<Account.Id> authors = new HashSet<>();
 
-    authors.add(change.getOwner());
-    if (patchSet != null) {
-      authors.add(patchSet.getUploader());
+    switch (notify) {
+      case NONE:
+        break;
+      case ALL:
+      default:
+        if (patchSet != null) {
+          authors.add(patchSet.getUploader());
+        }
+        if (patchSetInfo != null) {
+          if (patchSetInfo.getAuthor().getAccount() != null) {
+            authors.add(patchSetInfo.getAuthor().getAccount());
+          }
+          if (patchSetInfo.getCommitter().getAccount() != null) {
+            authors.add(patchSetInfo.getCommitter().getAccount());
+          }
+        }
+        //$FALL-THROUGH$
+      case OWNER_REVIEWERS:
+      case OWNER:
+        authors.add(change.getOwner());
+        break;
     }
-    if (patchSetInfo != null) {
-      if (patchSetInfo.getAuthor().getAccount() != null) {
-        authors.add(patchSetInfo.getAuthor().getAccount());
-      }
-      if (patchSetInfo.getCommitter().getAccount() != null) {
-        authors.add(patchSetInfo.getCommitter().getAccount());
-      }
-    }
+
     return authors;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
index 3d0041c..b56b737 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
@@ -20,13 +20,14 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.CommentRange;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
@@ -51,21 +52,19 @@
   private static final Logger log = LoggerFactory
       .getLogger(CommentSender.class);
 
-  public static interface Factory {
-    public CommentSender create(NotifyHandling notify, Change.Id id);
+  public interface Factory {
+    CommentSender create(Project.NameKey project, Change.Id id);
   }
 
-  private final NotifyHandling notify;
   private List<PatchLineComment> inlineComments = Collections.emptyList();
   private final PatchLineCommentsUtil plcUtil;
 
   @Inject
   public CommentSender(EmailArguments ea,
       PatchLineCommentsUtil plcUtil,
-      @Assisted NotifyHandling notify,
+      @Assisted Project.NameKey project,
       @Assisted Change.Id id) throws OrmException {
-    super(ea, "comment", newChangeData(ea, id));
-    this.notify = notify;
+    super(ea, "comment", newChangeData(ea, project, id));
     this.plcUtil = plcUtil;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
index 29895d9..2110e37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.mail.ProjectWatch.Watchers;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -32,14 +33,16 @@
   private static final Logger log =
       LoggerFactory.getLogger(CreateChangeSender.class);
 
-  public static interface Factory {
-    public CreateChangeSender create(Change.Id id);
+  public interface Factory {
+    CreateChangeSender create(Project.NameKey project, Change.Id id);
   }
 
   @Inject
-  public CreateChangeSender(EmailArguments ea, @Assisted Change.Id id)
+  public CreateChangeSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, newChangeData(ea, id));
+    super(ea, newChangeData(ea, project, id));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
new file mode 100644
index 0000000..75f9f82
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Let users know that a reviewer and possibly her review have
+ * been removed. */
+public class DeleteReviewerSender extends ReplyToChangeSender {
+  private final Set<Account.Id> reviewers = new HashSet<>();
+
+  public interface Factory extends
+      ReplyToChangeSender.Factory<DeleteReviewerSender> {
+    @Override
+    DeleteReviewerSender create(Project.NameKey project, Change.Id change);
+  }
+
+  @Inject
+  public DeleteReviewerSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "deleteReviewer", newChangeData(ea, project, id));
+  }
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    ccExistingReviewers();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    add(RecipientType.TO, reviewers);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(velocifyFile("DeleteReviewer.vm"));
+  }
+
+  public List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(getNameFor(id));
+    }
+    return names;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
new file mode 100644
index 0000000..d861109
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a vote that was removed from a change. */
+public class DeleteVoteSender extends ReplyToChangeSender {
+  public interface Factory extends
+      ReplyToChangeSender.Factory<DeleteVoteSender> {
+    @Override
+    DeleteVoteSender create(Project.NameKey project, Change.Id change);
+  }
+
+  @Inject
+  protected DeleteVoteSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "deleteVote", newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(velocifyFile("DeleteVote.vm"));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
index 8e5fa6f..68e5e50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
@@ -30,10 +31,12 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
@@ -73,6 +76,9 @@
   final RuntimeInstance velocityRuntime;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
+  final StarredChangesUtil starredChangesUtil;
+  final AccountIndexCollection accountIndexes;
+  final Provider<InternalAccountQuery> accountQueryProvider;
 
   @Inject
   EmailArguments(GitRepositoryManager server, ProjectCache projectCache,
@@ -96,7 +102,10 @@
       RuntimeInstance velocityRuntime,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
-      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners) {
+      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
+      StarredChangesUtil starredChangesUtil,
+      AccountIndexCollection accountIndexes,
+      Provider<InternalAccountQuery> accountQueryProvider) {
     this.server = server;
     this.projectCache = projectCache;
     this.groupBackend = groupBackend;
@@ -122,5 +131,8 @@
     this.settings = settings;
     this.sshAddresses = sshAddresses;
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountIndexes = accountIndexes;
+    this.accountQueryProvider = accountQueryProvider;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
index 30026bd..6a964a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
@@ -99,9 +99,8 @@
             || ('A' <= cp && cp <= 'Z')
             || ('0' <= cp && cp <= '9')) {
           return false;
-        } else {
-          return true;
         }
+        return true;
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
index a125517..7ceb0ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
@@ -21,7 +21,9 @@
   protected void configure() {
     factory(AbandonedSender.Factory.class);
     factory(CommentSender.Factory.class);
-    factory(RevertedSender.Factory.class);
+    factory(DeleteReviewerSender.Factory.class);
+    factory(DeleteVoteSender.Factory.class);
     factory(RestoredSender.Factory.class);
+    factory(RevertedSender.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index 58bdac1..41e1e2c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -28,7 +28,7 @@
    *         the string provides proof the user has the ability to read messages
    *         sent to that address. Must not be null.
    */
-  public String encode(Account.Id accountId, String emailAddress);
+  String encode(Account.Id accountId, String emailAddress);
 
   /**
    * Decode a token previously created.
@@ -36,10 +36,10 @@
    * @return a pair of account id and email address.
    * @throws InvalidTokenException the token is invalid, expired, malformed, etc.
    */
-  public ParsedToken decode(String tokenString) throws InvalidTokenException;
+  ParsedToken decode(String tokenString) throws InvalidTokenException;
 
   /** Exception thrown when a token does not parse correctly. */
-  public static class InvalidTokenException extends Exception {
+  class InvalidTokenException extends Exception {
     private static final long serialVersionUID = 1L;
 
     public InvalidTokenException() {
@@ -52,7 +52,7 @@
   }
 
   /** Pair returned from decode to provide the data used during encode. */
-  public static class ParsedToken {
+  class ParsedToken {
     private final Account.Id accountId;
     private final String emailAddress;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
index dec3d2c..9bcabc3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
@@ -18,7 +18,7 @@
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
-  public boolean isGenericAddress(Account.Id fromId);
+  boolean isGenericAddress(Account.Id fromId);
 
-  public Address from(Account.Id fromId);
+  Address from(Account.Id fromId);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index c6e59eb..048a4a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.mail;
 
-import com.google.common.collect.Multimap;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.revwalk.FooterKey;
@@ -32,19 +34,18 @@
 import java.util.Set;
 
 public class MailUtil {
-
   public static MailRecipients getRecipientsFromFooters(
-      final AccountResolver accountResolver, final PatchSet ps,
-      final List<FooterLine> footerLines) throws OrmException {
-    final MailRecipients recipients = new MailRecipients();
-    if (!ps.isDraft()) {
-      for (final FooterLine footerLine : footerLines) {
+      ReviewDb db, AccountResolver accountResolver, boolean draftPatchSet,
+      List<FooterLine> footerLines) throws OrmException {
+    MailRecipients recipients = new MailRecipients();
+    if (!draftPatchSet) {
+      for (FooterLine footerLine : footerLines) {
         try {
           if (isReviewer(footerLine)) {
-            recipients.reviewers.add(toAccountId(accountResolver, footerLine
+            recipients.reviewers.add(toAccountId(db, accountResolver, footerLine
                 .getValue().trim()));
           } else if (footerLine.matches(FooterKey.CC)) {
-            recipients.cc.add(toAccountId(accountResolver, footerLine
+            recipients.cc.add(toAccountId(db, accountResolver, footerLine
                 .getValue().trim()));
           }
         } catch (NoSuchAccountException e) {
@@ -56,16 +57,17 @@
   }
 
   public static MailRecipients getRecipientsFromReviewers(
-      Multimap<ReviewerState, Account.Id> reviewers) {
+      ReviewerSet reviewers) {
     MailRecipients recipients = new MailRecipients();
-    recipients.reviewers.addAll(reviewers.get(ReviewerState.REVIEWER));
-    recipients.cc.addAll(reviewers.get(ReviewerState.CC));
+    recipients.reviewers.addAll(reviewers.byState(REVIEWER));
+    recipients.cc.addAll(reviewers.byState(CC));
     return recipients;
   }
 
-  private static Account.Id toAccountId(final AccountResolver accountResolver,
-      final String nameOrEmail) throws OrmException, NoSuchAccountException {
-    final Account a = accountResolver.findByNameOrEmail(nameOrEmail);
+  private static Account.Id toAccountId(ReviewDb db,
+      AccountResolver accountResolver, String nameOrEmail)
+      throws OrmException, NoSuchAccountException {
+    Account a = accountResolver.findByNameOrEmail(db, nameOrEmail);
     if (a == null) {
       throw new NoSuchAccountException("\"" + nameOrEmail
           + "\" is not registered");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java
deleted file mode 100644
index ba75723..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change failing to merged. */
-public class MergeFailSender extends ReplyToChangeSender {
-  public static interface Factory {
-    public MergeFailSender create(Change.Id id);
-  }
-
-  @Inject
-  public MergeFailSender(EmailArguments ea, @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "merge-failed", newChangeData(ea, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccExistingReviewers();
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("MergeFail.vm"));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
index 7fbcf8d..f6c3d0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
@@ -24,22 +24,25 @@
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
-  public static interface Factory {
-    public MergedSender create(Change.Id id);
+  public interface Factory {
+    MergedSender create(Project.NameKey project, Change.Id id);
   }
 
   private final LabelTypes labelTypes;
 
   @Inject
-  public MergedSender(EmailArguments ea, @Assisted Change.Id id)
+  public MergedSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "merged", newChangeData(ea, id));
+    super(ea, "merged", newChangeData(ea, project, id));
     labelTypes = changeData.changeControl().getLabelTypes();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
index e18c7e5..62385d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
@@ -49,8 +49,19 @@
 
     setHeader("Message-ID", getChangeMessageThreadId());
 
-    add(RecipientType.TO, reviewers);
-    add(RecipientType.CC, extraCC);
+    switch (notify) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        add(RecipientType.CC, extraCC);
+        //$FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        add(RecipientType.TO, reviewers);
+        break;
+    }
+
     rcptToAuthors(RecipientType.CC);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
index 8a288e7..6200688 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.mail;
 
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
@@ -58,14 +61,14 @@
   protected String messageClass;
   private final HashSet<Account.Id> rcptTo = new HashSet<>();
   private final Map<String, EmailHeader> headers;
-  private final Set<Address> smtpRcptTo = Sets.newHashSet();
+  private final Set<Address> smtpRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder body;
   protected VelocityContext velocityContext;
 
   protected final EmailArguments args;
   protected Account.Id fromId;
-
+  protected NotifyHandling notify = NotifyHandling.ALL;
 
   protected OutgoingEmail(EmailArguments ea, String mc) {
     args = ea;
@@ -77,12 +80,20 @@
     fromId = id;
   }
 
+  public void setNotify(NotifyHandling notify) {
+    this.notify = notify;
+  }
+
   /**
    * Format and enqueue the message for delivery.
    *
    * @throws EmailException
    */
   public void send() throws EmailException {
+    if (NotifyHandling.NONE.equals(notify)) {
+      return;
+    }
+
     if (!args.emailSender.isEnabled()) {
       // Server has explicitly disabled email sending.
       //
@@ -95,30 +106,30 @@
     if (shouldSendMessage()) {
       if (fromId != null) {
         final Account fromUser = args.accountCache.get(fromId).getAccount();
+        GeneralPreferencesInfo senderPrefs = fromUser.getGeneralPreferencesInfo();
 
-        if (fromUser.getGeneralPreferences().isCopySelfOnEmails()) {
+        if (senderPrefs != null
+            && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
           // If we are impersonating a user, make sure they receive a CC of
           // this message so they can always review and audit what we sent
           // on their behalf to others.
           //
           add(RecipientType.CC, fromId);
-
         } else if (rcptTo.remove(fromId)) {
           // If they don't want a copy, but we queued one up anyway,
           // drop them from the recipient lists.
           //
-          final String fromEmail = fromUser.getPreferredEmail();
-          for (Iterator<Address> i = smtpRcptTo.iterator(); i.hasNext();) {
-            if (i.next().email.equals(fromEmail)) {
-              i.remove();
-            }
-          }
-          for (EmailHeader hdr : headers.values()) {
-            if (hdr instanceof AddressList) {
-              ((AddressList) hdr).remove(fromEmail);
-            }
-          }
+          removeUser(fromUser);
+        }
 
+        // Check the preferences of all recipients. If any user has disabled
+        // his email notifications then drop him from recipients' list
+        for (Account.Id id : rcptTo) {
+          Account thisUser = args.accountCache.get(id).getAccount();
+          GeneralPreferencesInfo prefs = thisUser.getGeneralPreferencesInfo();
+          if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
+            removeUser(thisUser);
+          }
           if (smtpRcptTo.isEmpty()) {
             return;
           }
@@ -290,7 +301,7 @@
     } else if (email != null) {
       return email;
 
-    } else /* (name == null && email == null) */{
+    } else /* (name == null && email == null) */ {
       return args.anonymousCowardName + " #" + accountId;
     }
   }
@@ -474,6 +485,22 @@
     return r.toString();
   }
 
+  protected void removeUser(Account user) {
+    String fromEmail = user.getPreferredEmail();
+    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext();) {
+      if (j.next().email.equals(fromEmail)) {
+        j.remove();
+      }
+    }
+    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
+      // Don't remove fromEmail from the "From" header though!
+      if (entry.getValue() instanceof AddressList
+          && !entry.getKey().equals("From")) {
+        ((AddressList) entry.getValue()).remove(fromEmail);
+      }
+    }
+  }
+
   private static String safeToString(Object obj) {
     return obj != null ? obj.toString() : "";
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
index 95b0219..f19b2a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.mail;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -29,6 +27,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.Predicate;
@@ -41,8 +41,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 public class ProjectWatch {
@@ -63,22 +65,11 @@
 
   /** Returns all watchers that are relevant */
   public final Watchers getWatchers(NotifyType type) throws OrmException {
-    Watchers matching = new Watchers();
-    Set<Account.Id> projectWatchers = new HashSet<>();
-
-    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
-        .byProject(project)) {
-      if (add(matching, w, type)) {
-        // We only want to prevent matching All-Projects if this filter hits
-        projectWatchers.add(w.getAccountId());
-      }
-    }
-
-    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
-        .byProject(args.allProjectsName)) {
-      if (!projectWatchers.contains(w.getAccountId())) {
-        add(matching, w, type);
-      }
+    Watchers matching;
+    if (args.accountIndexes.getSearchIndex() != null) {
+      matching = getWatchersFromIndex(type);
+    } else {
+      matching = getWatchersFromDb(type);
     }
 
     for (ProjectState state : projectState.tree()) {
@@ -98,10 +89,65 @@
     return matching;
   }
 
+  private Watchers getWatchersFromIndex(NotifyType type)
+      throws OrmException {
+    Watchers matching = new Watchers();
+    Set<Account.Id> projectWatchers = new HashSet<>();
+
+    for (AccountState a : args.accountQueryProvider.get()
+        .byWatchedProject(project)) {
+      Account.Id accountId = a.getAccount().getId();
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
+          a.getProjectWatches().entrySet()) {
+        if (project.equals(e.getKey().project())
+                && add(matching, accountId, e.getKey(), e.getValue(), type)) {
+          // We only want to prevent matching All-Projects if this filter hits
+          projectWatchers.add(accountId);
+        }
+      }
+    }
+
+    for (AccountState a : args.accountQueryProvider.get()
+        .byWatchedProject(args.allProjectsName)) {
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
+        a.getProjectWatches().entrySet()) {
+        if (args.allProjectsName.equals(e.getKey().project())) {
+          Account.Id accountId = a.getAccount().getId();
+          if (!projectWatchers.contains(accountId)) {
+            add(matching, accountId, e.getKey(), e.getValue(), type);
+          }
+        }
+      }
+    }
+    return matching;
+  }
+
+  private Watchers getWatchersFromDb(NotifyType type)
+      throws OrmException {
+    Watchers matching = new Watchers();
+    Set<Account.Id> projectWatchers = new HashSet<>();
+
+    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
+        .byProject(project)) {
+      if (add(matching, w, type)) {
+        // We only want to prevent matching All-Projects if this filter hits
+        projectWatchers.add(w.getAccountId());
+      }
+    }
+
+    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
+        .byProject(args.allProjectsName)) {
+      if (!projectWatchers.contains(w.getAccountId())) {
+        add(matching, w, type);
+      }
+    }
+    return matching;
+  }
+
   public static class Watchers {
     static class List {
-      protected final Set<Account.Id> accounts = Sets.newHashSet();
-      protected final Set<Address> emails = Sets.newHashSet();
+      protected final Set<Account.Id> accounts = new HashSet<>();
+      protected final Set<Address> emails = new HashSet<>();
     }
     protected final List to = new List();
     protected final List cc = new List();
@@ -141,8 +187,8 @@
       Watchers.List matching,
       AccountGroup.UUID startUUID) throws OrmException {
     ReviewDb db = args.db.get();
-    Set<AccountGroup.UUID> seen = Sets.newHashSet();
-    List<AccountGroup.UUID> q = Lists.newArrayList();
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    List<AccountGroup.UUID> q = new ArrayList<>();
 
     seen.add(startUUID);
     q.add(startUUID);
@@ -173,10 +219,29 @@
     }
   }
 
+  private boolean add(Watchers matching, Account.Id accountId,
+      ProjectWatchKey key, Set<NotifyType> watchedTypes, NotifyType type)
+      throws OrmException {
+    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
+
+    try {
+      if (filterMatch(user, key.filter())) {
+        // If we are set to notify on this type, add the user.
+        // Otherwise, still return true to stop notifications for this user.
+        if (watchedTypes.contains(type)) {
+          matching.bcc.accounts.add(accountId);
+        }
+        return true;
+      }
+    } catch (QueryParseException e) {
+      // Ignore broken filter expressions.
+    }
+    return false;
+  }
+
   private boolean add(Watchers matching, AccountProjectWatch w, NotifyType type)
       throws OrmException {
-    IdentifiedUser user =
-        args.identifiedUserFactory.create(args.db, w.getAccountId());
+    IdentifiedUser user = args.identifiedUserFactory.create(w.getAccountId());
 
     try {
       if (filterMatch(user, w.getFilter())) {
@@ -213,6 +278,6 @@
         p = Predicate.and(filterPredicate, p);
       }
     }
-    return p == null || p.match(changeData);
+    return p == null || p.asMatchable().match(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
index 3e6bd8b..cfdeb8f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
@@ -23,7 +23,7 @@
 
 public class RegisterNewEmailSender extends OutgoingEmail {
   public interface Factory {
-    public RegisterNewEmailSender create(String address);
+    RegisterNewEmailSender create(String address);
   }
 
   private final EmailTokenVerifier tokenVerifier;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
index 05c7933..df9f20e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -30,17 +31,19 @@
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
-  public static interface Factory {
-    public ReplacePatchSetSender create(Change.Id id);
+  public interface Factory {
+    ReplacePatchSetSender create(Project.NameKey project, Change.Id id);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
 
   @Inject
-  public ReplacePatchSetSender(EmailArguments ea, @Assisted Change.Id id)
+  public ReplacePatchSetSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "newpatchset", newChangeData(ea, id));
+    super(ea, "newpatchset", newChangeData(ea, project, id));
   }
 
   public void addReviewers(final Collection<Account.Id> cc) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
index 62a6c72..dd922d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
@@ -16,13 +16,14 @@
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
 /** Alert a user to a reply to a change, usually commentary made during review. */
 public abstract class ReplyToChangeSender extends ChangeEmail {
-  public static interface Factory<T extends ReplyToChangeSender> {
-    public T create(Change.Id id);
+  public interface Factory<T extends ReplyToChangeSender> {
+    T create(Project.NameKey project, Change.Id id);
   }
 
   protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
index a43c7b6..d946eb2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
@@ -17,22 +17,25 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change being restored by its owner. */
 public class RestoredSender extends ReplyToChangeSender {
-  public static interface Factory extends
+  public interface Factory extends
       ReplyToChangeSender.Factory<RestoredSender> {
     @Override
-    RestoredSender create(Change.Id id);
+    RestoredSender create(Project.NameKey project, Change.Id id);
   }
 
   @Inject
-  public RestoredSender(EmailArguments ea, @Assisted Change.Id id)
+  public RestoredSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "restore", newChangeData(ea, id));
+    super(ea, "restore", newChangeData(ea, project, id));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
index dda68ab..2c9c37e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
@@ -17,20 +17,23 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 /** Send notice about a change being reverted. */
 public class RevertedSender extends ReplyToChangeSender {
-  public static interface Factory {
-    RevertedSender create(Change.Id id);
+  public interface Factory {
+    RevertedSender create(Project.NameKey project, Change.Id id);
   }
 
   @Inject
-  public RevertedSender(EmailArguments ea, @Assisted Change.Id id)
+  public RevertedSender(EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "revert", newChangeData(ea, id));
+    super(ea, "revert", newChangeData(ea, project, id));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
index aa57247..e263c6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
@@ -57,7 +57,7 @@
     }
   }
 
-  public static enum Encryption {
+  public enum Encryption {
     NONE, SSL, TLS
   }
 
@@ -159,10 +159,10 @@
     setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
     setMissingHeader(hdrs, "Content-Disposition", "inline");
     setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
-    if(importance != null) {
+    if (importance != null) {
       setMissingHeader(hdrs, "Importance", importance);
     }
-    if(expiryDays > 0) {
+    if (expiryDays > 0) {
       Date expiry = new Date(TimeUtil.nowMs() +
         expiryDays * 24 * 60 * 60 * 1000L );
       setMissingHeader(hdrs, "Expiry-Date",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index 43d53f0..96486e96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -31,7 +31,7 @@
    *         or cannot be determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which
    *         is an alias for {@code application/octet-stream}.
    */
-  public abstract MimeType getMimeType(final String path, final byte[] content);
+  MimeType getMimeType(final String path, final byte[] content);
 
   /**
    * Is this content type safe to transmit to a browser directly?
@@ -42,6 +42,6 @@
    *         content type and wants it to be protected (typically by wrapping
    *         the data in a ZIP archive).
    */
-  public abstract boolean isSafeInline(final MimeType type);
+  boolean isSafeInline(final MimeType type);
 
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index b1ca9e7..679a9de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -14,11 +14,23 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -28,34 +40,119 @@
 import java.io.IOException;
 
 /** View of contents at a single ref related to some change. **/
-public abstract class AbstractChangeNotes<T> extends VersionedMetaData {
-  protected final GitRepositoryManager repoManager;
-  protected final NotesMigration migration;
+public abstract class AbstractChangeNotes<T> {
+  @VisibleForTesting
+  @Singleton
+  public static class Args {
+    final GitRepositoryManager repoManager;
+    final NotesMigration migration;
+    final AllUsersName allUsers;
+    final ChangeNoteUtil noteUtil;
+    final NoteDbMetrics metrics;
+    final Provider<ReviewDb> db;
+
+    // Providers required to avoid dependency cycles.
+
+    // ChangeRebuilder -> ChangeNotes.Factory -> Args
+    final Provider<ChangeRebuilder> rebuilder;
+
+    // ChangeNoteCache -> Args
+    final Provider<ChangeNotesCache> cache;
+
+    @Inject
+    Args(
+        GitRepositoryManager repoManager,
+        NotesMigration migration,
+        AllUsersName allUsers,
+        ChangeNoteUtil noteUtil,
+        NoteDbMetrics metrics,
+        Provider<ReviewDb> db,
+        Provider<ChangeRebuilder> rebuilder,
+        Provider<ChangeNotesCache> cache) {
+      this.repoManager = repoManager;
+      this.migration = migration;
+      this.allUsers = allUsers;
+      this.noteUtil = noteUtil;
+      this.metrics = metrics;
+      this.db = db;
+      this.rebuilder = rebuilder;
+      this.cache = cache;
+    }
+  }
+
+  @AutoValue
+  public abstract static class LoadHandle implements AutoCloseable {
+    public static LoadHandle create(ChangeNotesRevWalk walk, ObjectId id) {
+      if (ObjectId.zeroId().equals(id)) {
+        id = null;
+      } else if (id != null) {
+        id = id.copy();
+      }
+      return new AutoValue_AbstractChangeNotes_LoadHandle(
+          checkNotNull(walk), id);
+    }
+
+    public static LoadHandle missing() {
+      return new AutoValue_AbstractChangeNotes_LoadHandle(null, null);
+    }
+
+    @Nullable public abstract ChangeNotesRevWalk walk();
+    @Nullable public abstract ObjectId id();
+
+    @Override
+    public void close() {
+      if (walk() != null) {
+        walk().close();
+      }
+    }
+  }
+
+  protected final Args args;
+  protected final boolean autoRebuild;
   private final Change.Id changeId;
 
+  private ObjectId revision;
   private boolean loaded;
 
-  AbstractChangeNotes(GitRepositoryManager repoManager,
-      NotesMigration migration, Change.Id changeId) {
-    this.repoManager = repoManager;
-    this.migration = migration;
-    this.changeId = changeId;
+  AbstractChangeNotes(Args args, Change.Id changeId, boolean autoRebuild) {
+    this.args = checkNotNull(args);
+    this.changeId = checkNotNull(changeId);
+    this.autoRebuild = autoRebuild;
   }
 
   public Change.Id getChangeId() {
     return changeId;
   }
 
+  /** @return revision of the metadata that was loaded. */
+  public ObjectId getRevision() {
+    return revision;
+  }
+
   public T load() throws OrmException {
     if (loaded) {
       return self();
     }
-    if (!migration.enabled()) {
+    boolean read = args.migration.readChanges();
+    boolean readOrWrite = read || args.migration.writeChanges();
+    if (!readOrWrite && !autoRebuild) {
       loadDefaults();
       return self();
     }
-    try (Repository repo = repoManager.openMetadataRepository(getProjectName())) {
-      load(repo);
+    if (args.migration.failOnLoad()) {
+      throw new OrmException("Reading from NoteDb is disabled");
+    }
+    try (Timer1.Context timer = args.metrics.readLatency.start(CHANGES);
+        Repository repo = args.repoManager.openRepository(getProjectName());
+        // Call openHandle even if reading is disabled, to trigger
+        // auto-rebuilding before this object may get passed to a ChangeUpdate.
+        LoadHandle handle = openHandle(repo)) {
+      if (read) {
+        revision = handle.id();
+        onLoad(handle);
+      } else {
+        loadDefaults();
+      }
       loaded = true;
     } catch (ConfigInvalidException | IOException e) {
       throw new OrmException(e);
@@ -63,13 +160,31 @@
     return self();
   }
 
+  protected ObjectId readRef(Repository repo) throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(getRefName());
+    return ref != null ? ref.getObjectId() : null;
+  }
+
+  protected LoadHandle openHandle(Repository repo) throws IOException {
+    return openHandle(repo, readRef(repo));
+  }
+
+  protected LoadHandle openHandle(Repository repo, ObjectId id) {
+    return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), id);
+  }
+
+  public T reload() throws OrmException {
+    loaded = false;
+    return load();
+  }
+
   public ObjectId loadRevision() throws OrmException {
     if (loaded) {
       return getRevision();
-    } else if (!migration.enabled()) {
+    } else if (!args.migration.enabled()) {
       return null;
     }
-    try (Repository repo = repoManager.openMetadataRepository(getProjectName())) {
+    try (Repository repo = args.repoManager.openRepository(getProjectName())) {
       Ref ref = repo.getRefDatabase().exactRef(getRefName());
       return ref != null ? ref.getObjectId() : null;
     } catch (IOException e) {
@@ -77,14 +192,21 @@
     }
   }
 
-  /** Load default values for any instance variables when notedb is disabled. */
+  /** Load default values for any instance variables when NoteDb is disabled. */
   protected abstract void loadDefaults();
 
   /**
    * @return the NameKey for the project where the notes should be stored,
    *    which is not necessarily the same as the change's project.
    */
-  protected abstract Project.NameKey getProjectName();
+  public abstract Project.NameKey getProjectName();
+
+  /** @return name of the reference storing this configuration. */
+  protected abstract String getRefName();
+
+  /** Set up the metadata, parsing any state from the loaded revision. */
+  protected abstract void onLoad(LoadHandle handle)
+      throws IOException, ConfigInvalidException;
 
   @SuppressWarnings("unchecked")
   protected final T self() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index fb41027..70a5f4f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -15,176 +15,244 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
 import java.util.Date;
 
 /** A single delta related to a specific patch-set of a change. */
-public abstract class AbstractChangeUpdate extends VersionedMetaData {
+public abstract class AbstractChangeUpdate {
   protected final NotesMigration migration;
-  protected final GitRepositoryManager repoManager;
-  protected final MetaDataUpdate.User updateFactory;
-  protected final ChangeControl ctl;
+  protected final ChangeNoteUtil noteUtil;
   protected final String anonymousCowardName;
-  protected final PersonIdent serverIdent;
+  protected final Account.Id accountId;
+  protected final PersonIdent authorIdent;
   protected final Date when;
-  protected PatchSet.Id psId;
 
-  AbstractChangeUpdate(NotesMigration migration,
-      GitRepositoryManager repoManager,
-      MetaDataUpdate.User updateFactory, ChangeControl ctl,
+  @Nullable private final ChangeNotes notes;
+  private final Change change;
+  private final PersonIdent serverIdent;
+
+  protected PatchSet.Id psId;
+  private ObjectId result;
+
+  protected AbstractChangeUpdate(
+      NotesMigration migration,
+      ChangeControl ctl,
       PersonIdent serverIdent,
       String anonymousCowardName,
+      ChangeNoteUtil noteUtil,
       Date when) {
     this.migration = migration;
-    this.repoManager = repoManager;
-    this.updateFactory = updateFactory;
-    this.ctl = ctl;
-    this.serverIdent = serverIdent;
+    this.noteUtil = noteUtil;
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.anonymousCowardName = anonymousCowardName;
+    this.notes = ctl.getNotes();
+    this.change = notes.getChange();
+    this.accountId = accountId(ctl.getUser());
+    this.authorIdent =
+        ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
     this.when = when;
   }
 
-  public ChangeNotes getChangeNotes() {
-    return ctl.getNotes();
+  protected AbstractChangeUpdate(
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      PersonIdent serverIdent,
+      String anonymousCowardName,
+      @Nullable ChangeNotes notes,
+      @Nullable Change change,
+      Account.Id accountId,
+      PersonIdent authorIdent,
+      Date when) {
+    checkArgument(
+        (notes != null && change == null)
+            || (notes == null && change != null),
+        "exactly one of notes or change required");
+    this.migration = migration;
+    this.noteUtil = noteUtil;
+    this.serverIdent = new PersonIdent(serverIdent, when);
+    this.anonymousCowardName = anonymousCowardName;
+    this.notes = notes;
+    this.change = change != null ? change : notes.getChange();
+    this.accountId = accountId;
+    this.authorIdent = authorIdent;
+    this.when = when;
+  }
+
+  private static void checkUserType(CurrentUser user) {
+    checkArgument(
+        (user instanceof IdentifiedUser) || (user instanceof InternalUser),
+        "user must be IdentifiedUser or InternalUser: %s", user);
+  }
+
+  private static Account.Id accountId(CurrentUser u) {
+    checkUserType(u);
+    return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
+  }
+
+  private static PersonIdent ident(ChangeNoteUtil noteUtil,
+      PersonIdent serverIdent, String anonymousCowardName, CurrentUser u,
+      Date when) {
+    checkUserType(u);
+    if (u instanceof IdentifiedUser) {
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when,
+          serverIdent, anonymousCowardName);
+    } else if (u instanceof InternalUser) {
+      return serverIdent;
+    }
+    throw new IllegalStateException();
+  }
+
+  public Change.Id getId() {
+    return change.getId();
+  }
+
+  @Nullable
+  public ChangeNotes getNotes() {
+    return notes;
   }
 
   public Change getChange() {
-    return ctl.getChange();
+    return change;
   }
 
   public Date getWhen() {
     return when;
   }
 
-  public IdentifiedUser getUser() {
-    return ctl.getUser().asIdentifiedUser();
-  }
-
   public PatchSet.Id getPatchSetId() {
     return psId;
   }
 
   public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null
-        || psId.getParentKey().equals(getChange().getId()));
+    checkArgument(psId == null || psId.getParentKey().equals(getId()));
     this.psId = psId;
   }
 
-  private void load() throws IOException {
-    if (migration.writeChanges() && getRevision() == null) {
-      try (Repository repo = repoManager.openMetadataRepository(getProjectName())) {
-        load(repo);
-      } catch (ConfigInvalidException e) {
-        throw new IOException(e);
-      }
-    }
+  public Account.Id getAccountId() {
+    checkState(accountId != null,
+        "author identity for %s is not from an IdentifiedUser: %s",
+        getClass().getSimpleName(), authorIdent.toExternalString());
+    return accountId;
   }
 
-  public void setInserter(ObjectInserter inserter) {
-    this.inserter = inserter;
-  }
-
-  @Override
-  public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
-    throw new UnsupportedOperationException("use openUpdate()");
-  }
-
-  public BatchMetaDataUpdate openUpdate() throws IOException {
-    return openUpdateInBatch(null);
-  }
-
-  public BatchMetaDataUpdate openUpdateInBatch(BatchRefUpdate bru)
-      throws IOException {
-    if (migration.writeChanges()) {
-      load();
-      MetaDataUpdate md =
-          updateFactory.create(getProjectName(),
-              repoManager.openMetadataRepository(getProjectName()), getUser(),
-              bru);
-      md.setAllowEmpty(true);
-      return super.openUpdate(md);
-    }
-    return new BatchMetaDataUpdate() {
-      @Override
-      public void write(CommitBuilder commit) {
-        // Do nothing.
-      }
-
-      @Override
-      public void write(VersionedMetaData config, CommitBuilder commit) {
-        // Do nothing.
-      }
-
-      @Override
-      public RevCommit createRef(String refName) {
-        return null;
-      }
-
-      @Override
-      public void removeRef(String refName) {
-        // Do nothing.
-      }
-
-      @Override
-      public RevCommit commit() {
-        return null;
-      }
-
-      @Override
-      public RevCommit commitAt(ObjectId revision) {
-        return null;
-      }
-
-      @Override
-      public void close() {
-        // Do nothing.
-      }
-    };
-  }
-
-  @Override
-  public RevCommit commit(MetaDataUpdate md) throws IOException {
-    throw new UnsupportedOperationException("use commit()");
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    //Do nothing; just reads the current revision.
+  public Account.Id getNullableAccountId() {
+    return accountId;
   }
 
   protected PersonIdent newIdent(Account author, Date when) {
-    return ChangeNoteUtil.newIdent(author, when, serverIdent,
-        anonymousCowardName);
+    return noteUtil.newIdent(author, when, serverIdent, anonymousCowardName);
   }
 
-  /** Writes commit to a BatchMetaDataUpdate without committing the batch. */
-  public abstract void writeCommit(BatchMetaDataUpdate batch)
-      throws OrmException, IOException;
+  /** Whether no updates have been done. */
+  public abstract boolean isEmpty();
 
   /**
    * @return the NameKey for the project where the update will be stored,
    *    which is not necessarily the same as the change's project.
    */
   protected abstract Project.NameKey getProjectName();
+
+  protected abstract String getRefName();
+
+  /**
+   * Apply this update to the given inserter.
+   *
+   * @param rw walk for reading back any objects needed for the update.
+   * @param ins inserter to write to; callers should not flush.
+   * @param curr the current tip of the branch prior to this update.
+   * @return commit ID produced by inserting this update's commit, or null if
+   *     this update is a no-op and should be skipped. The zero ID is a valid
+   *     return value, and indicates the ref should be deleted.
+   * @throws OrmException if a Gerrit-level error occurred.
+   * @throws IOException if a lower-level error occurred.
+   */
+  final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    if (isEmpty()) {
+      return null;
+    }
+
+    // Allow this method to proceed even if migration.failChangeWrites() = true.
+    // This may be used by an auto-rebuilding step that the caller does not plan
+    // to actually store.
+
+    checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    ObjectId z = ObjectId.zeroId();
+    CommitBuilder cb = applyImpl(rw, ins, curr);
+    if (cb == null) {
+      result = z;
+      return z; // Impl intends to delete the ref.
+    } else if (cb == NO_OP_UPDATE) {
+      return null; // Impl is a no-op.
+    }
+    cb.setAuthor(authorIdent);
+    cb.setCommitter(new PersonIdent(serverIdent, when));
+    if (!curr.equals(z)) {
+      cb.setParentId(curr);
+    } else {
+      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+    }
+    if (cb.getTreeId() == null) {
+      if (curr.equals(z)) {
+        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
+      } else {
+        RevCommit p = rw.parseCommit(curr);
+        cb.setTreeId(p.getTree()); // Copy tree from parent.
+      }
+    }
+    result = ins.insert(cb);
+    return result;
+  }
+
+  /**
+   * Create a commit containing the contents of this update.
+   *
+   * @param ins inserter to write to; callers should not flush.
+   * @return a new commit builder representing this commit, or null to indicate
+   *     the meta ref should be deleted as a result of this update. The parent,
+   *     author, and committer fields in the return value are always
+   *     overwritten. The tree ID may be unset by this method, which indicates
+   *     to the caller that it should be copied from the parent commit. To
+   *     indicate that this update is a no-op (but this could not be determined
+   *     by {@link #isEmpty()}), return the sentinel {@link #NO_OP_UPDATE}.
+   * @throws OrmException if a Gerrit-level error occurred.
+   * @throws IOException if a lower-level error occurred.
+   */
+  protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
+      ObjectId curr) throws OrmException, IOException;
+
+  protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
+
+  ObjectId getResult() {
+    return result;
+  }
+
+  public boolean allowWriteToNewRef() {
+    return true;
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    return ins.insert(Constants.OBJ_TREE, new byte[] {});
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
new file mode 100644
index 0000000..e15af9d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -0,0 +1,921 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.common.TimeUtil.roundToSecond;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.server.OrmException;
+
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * A bundle of all entities rooted at a single {@link Change} entity.
+ * <p>
+ * See the {@link Change} Javadoc for a depiction of this tree. Bundles may be
+ * compared using {@link #differencesFrom(ChangeBundle)}, which normalizes out
+ * the minor implementation differences between ReviewDb and NoteDb.
+ */
+public class ChangeBundle {
+  public enum Source {
+    REVIEW_DB, NOTE_DB;
+  }
+
+  public static ChangeBundle fromReviewDb(ReviewDb db, Change.Id id)
+      throws OrmException {
+    db.changes().beginTransaction(id);
+    try {
+      List<PatchSetApproval> approvals =
+          db.patchSetApprovals().byChange(id).toList();
+      return new ChangeBundle(
+          db.changes().get(id),
+          db.changeMessages().byChange(id),
+          db.patchSets().byChange(id),
+          approvals,
+          db.patchComments().byChange(id),
+          ReviewerSet.fromApprovals(approvals),
+          Source.REVIEW_DB);
+    } finally {
+      db.rollback();
+    }
+  }
+
+  public static ChangeBundle fromNotes(PatchLineCommentsUtil plcUtil,
+      ChangeNotes notes) throws OrmException {
+    return new ChangeBundle(
+        notes.getChange(),
+        notes.getChangeMessages(),
+        notes.getPatchSets().values(),
+        notes.getApprovals().values(),
+        Iterables.concat(
+            plcUtil.draftByChange(null, notes),
+            plcUtil.publishedByChange(null, notes)),
+        notes.getReviewers(),
+        Source.NOTE_DB);
+  }
+
+  private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
+      Iterable<ChangeMessage> in) {
+    Map<ChangeMessage.Key, ChangeMessage> out = new TreeMap<>(
+        new Comparator<ChangeMessage.Key>() {
+          @Override
+          public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
+            return ComparisonChain.start()
+                .compare(a.getParentKey().get(), b.getParentKey().get())
+                .compare(a.get(), b.get())
+                .result();
+          }
+        });
+    for (ChangeMessage cm : in) {
+      out.put(cm.getKey(), cm);
+    }
+    return out;
+  }
+
+  // Unlike the *Map comparators, which are intended to make key lists diffable,
+  // this comparator sorts first on timestamp, then on every other field.
+  private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
+      new Ordering<ChangeMessage>() {
+        final Ordering<Comparable<?>> nullsFirst =
+            Ordering.natural().nullsFirst();
+
+        @Override
+        public int compare(ChangeMessage a, ChangeMessage b) {
+          return ComparisonChain.start()
+              .compare(a.getWrittenOn(), b.getWrittenOn())
+              .compare(a.getKey().getParentKey().get(),
+                  b.getKey().getParentKey().get())
+              .compare(psId(a), psId(b), nullsFirst)
+              .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
+              .compare(a.getMessage(), b.getMessage(), nullsFirst)
+              .result();
+        }
+
+        private Integer psId(ChangeMessage m) {
+          return m.getPatchSetId() != null ? m.getPatchSetId().get() : null;
+        }
+      };
+
+  private static ImmutableList<ChangeMessage> changeMessageList(
+      Iterable<ChangeMessage> in) {
+    return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
+  }
+
+  private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
+    TreeMap<PatchSet.Id, PatchSet> out = new TreeMap<>(
+        new Comparator<PatchSet.Id>() {
+          @Override
+          public int compare(PatchSet.Id a, PatchSet.Id b) {
+            return patchSetIdChain(a, b).result();
+          }
+        });
+    for (PatchSet ps : in) {
+      out.put(ps.getId(), ps);
+    }
+    return out;
+  }
+
+  private static Map<PatchSetApproval.Key, PatchSetApproval>
+      patchSetApprovalMap(Iterable<PatchSetApproval> in) {
+    Map<PatchSetApproval.Key, PatchSetApproval> out = new TreeMap<>(
+        new Comparator<PatchSetApproval.Key>() {
+          @Override
+          public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
+            return patchSetIdChain(a.getParentKey(), b.getParentKey())
+                .compare(a.getAccountId().get(), b.getAccountId().get())
+                .compare(a.getLabelId(), b.getLabelId())
+                .result();
+          }
+        });
+    for (PatchSetApproval psa : in) {
+      out.put(psa.getKey(), psa);
+    }
+    return out;
+  }
+
+  private static Map<PatchLineComment.Key, PatchLineComment>
+      patchLineCommentMap(Iterable<PatchLineComment> in) {
+    Map<PatchLineComment.Key, PatchLineComment> out = new TreeMap<>(
+        new Comparator<PatchLineComment.Key>() {
+          @Override
+          public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
+            Patch.Key pka = a.getParentKey();
+            Patch.Key pkb = b.getParentKey();
+            return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
+                .compare(pka.get(), pkb.get())
+                .compare(a.get(), b.get())
+                .result();
+          }
+        });
+    for (PatchLineComment plc : in) {
+      out.put(plc.getKey(), plc);
+    }
+    return out;
+  }
+
+  private static ComparisonChain patchSetIdChain(PatchSet.Id a, PatchSet.Id b) {
+    return ComparisonChain.start()
+        .compare(a.getParentKey().get(), b.getParentKey().get())
+        .compare(a.get(), b.get());
+  }
+
+  private static void checkColumns(Class<?> clazz, Integer... expected) {
+    Set<Integer> ids = new TreeSet<>();
+    for (Field f : clazz.getDeclaredFields()) {
+      Column col = f.getAnnotation(Column.class);
+      if (col != null) {
+        ids.add(col.id());
+      }
+    }
+    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
+    checkState(ids.equals(expectedIds),
+        "Unexpected column set for %s: %s != %s",
+        clazz.getSimpleName(), ids, expectedIds);
+  }
+
+  static {
+    // Initialization-time checks that the column set hasn't changed since the
+    // last time this file was updated.
+    checkColumns(Change.Id.class, 1);
+
+    checkColumns(Change.class,
+        1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18,
+        // TODO(dborowitz): It's potentially possible to compare noteDbState in
+        // the Change with the state implied by a ChangeNotes.
+        101);
+    checkColumns(ChangeMessage.Key.class, 1, 2);
+    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6);
+    checkColumns(PatchSet.Id.class, 1, 2);
+    checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8);
+    checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
+    checkColumns(PatchSetApproval.class, 1, 2, 3, 6);
+    checkColumns(PatchLineComment.Key.class, 1, 2);
+    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
+  }
+
+  private final Change change;
+  private final ImmutableList<ChangeMessage> changeMessages;
+  private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
+  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval>
+      patchSetApprovals;
+  private final ImmutableMap<PatchLineComment.Key, PatchLineComment>
+      patchLineComments;
+  private final ReviewerSet reviewers;
+  private final Source source;
+
+  public ChangeBundle(
+      Change change,
+      Iterable<ChangeMessage> changeMessages,
+      Iterable<PatchSet> patchSets,
+      Iterable<PatchSetApproval> patchSetApprovals,
+      Iterable<PatchLineComment> patchLineComments,
+      ReviewerSet reviewers,
+      Source source) {
+    this.change = checkNotNull(change);
+    this.changeMessages = changeMessageList(changeMessages);
+    this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
+    this.patchSetApprovals =
+        ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
+    this.patchLineComments =
+        ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
+    this.reviewers = checkNotNull(reviewers);
+    this.source = checkNotNull(source);
+
+    for (ChangeMessage m : this.changeMessages) {
+      checkArgument(m.getKey().getParentKey().equals(change.getId()));
+    }
+    for (PatchSet.Id id : this.patchSets.keySet()) {
+      checkArgument(id.getParentKey().equals(change.getId()));
+    }
+    for (PatchSetApproval.Key k : this.patchSetApprovals.keySet()) {
+      checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
+    }
+    for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
+      checkArgument(k.getParentKey().getParentKey().getParentKey()
+          .equals(change.getId()));
+    }
+  }
+
+  public Change getChange() {
+    return change;
+  }
+
+  public ImmutableCollection<ChangeMessage> getChangeMessages() {
+    return changeMessages;
+  }
+
+  public ImmutableCollection<PatchSet> getPatchSets() {
+    return patchSets.values();
+  }
+
+  public ImmutableCollection<PatchSetApproval> getPatchSetApprovals() {
+    return patchSetApprovals.values();
+  }
+
+  public ImmutableCollection<PatchLineComment> getPatchLineComments() {
+    return patchLineComments.values();
+  }
+
+  public ReviewerSet getReviewers() {
+    return reviewers;
+  }
+
+  public Source getSource() {
+    return source;
+  }
+
+  public ImmutableList<String> differencesFrom(ChangeBundle o) {
+    List<String> diffs = new ArrayList<>();
+    diffChanges(diffs, this, o);
+    diffChangeMessages(diffs, this, o);
+    diffPatchSets(diffs, this, o);
+    diffPatchSetApprovals(diffs, this, o);
+    diffReviewers(diffs, this, o);
+    diffPatchLineComments(diffs, this, o);
+    return ImmutableList.copyOf(diffs);
+  }
+
+  private Timestamp getFirstPatchSetTime() {
+    if (patchSets.isEmpty()) {
+      return change.getCreatedOn();
+    }
+    return patchSets.firstEntry().getValue().getCreatedOn();
+  }
+
+  private Timestamp getLatestTimestamp() {
+    Ordering<Timestamp> o = Ordering.natural().nullsFirst();
+    Timestamp ts = null;
+    for (ChangeMessage cm : getChangeMessages()) {
+      ts = o.max(ts, cm.getWrittenOn());
+    }
+    for (PatchSet ps : getPatchSets()) {
+      ts = o.max(ts, ps.getCreatedOn());
+    }
+    for (PatchSetApproval psa : getPatchSetApprovals()) {
+      ts = o.max(ts, psa.getGranted());
+    }
+    for (PatchLineComment plc : getPatchLineComments()) {
+      // Ignore draft comments, as they do not show up in the change meta graph.
+      if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
+        ts = o.max(ts, plc.getWrittenOn());
+      }
+    }
+    return firstNonNull(ts, change.getLastUpdatedOn());
+  }
+
+  private Map<PatchSetApproval.Key, PatchSetApproval>
+      filterPatchSetApprovals() {
+    return limitToValidPatchSets(patchSetApprovals,
+        new Function<PatchSetApproval.Key, PatchSet.Id>() {
+          @Override
+          public PatchSet.Id apply(PatchSetApproval.Key in) {
+            return in.getParentKey();
+          }
+        });
+  }
+
+  private Map<PatchLineComment.Key, PatchLineComment>
+      filterPatchLineComments() {
+    return limitToValidPatchSets(patchLineComments,
+        new Function<PatchLineComment.Key, PatchSet.Id>() {
+          @Override
+          public PatchSet.Id apply(PatchLineComment.Key in) {
+            return in.getParentKey().getParentKey();
+          }
+        });
+  }
+
+  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in,
+      final Function<K, PatchSet.Id> func) {
+    return Maps.filterKeys(
+        in, Predicates.compose(validPatchSetPredicate(), func));
+  }
+
+  private Predicate<PatchSet.Id> validPatchSetPredicate() {
+    final Predicate<PatchSet.Id> upToCurrent = upToCurrentPredicate();
+    return new Predicate<PatchSet.Id>() {
+      @Override
+      public boolean apply(PatchSet.Id in) {
+        return upToCurrent.apply(in) && patchSets.containsKey(in);
+      }
+    };
+  }
+
+  private Collection<ChangeMessage> filterChangeMessages() {
+    final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
+    return Collections2.filter(changeMessages,
+        new Predicate<ChangeMessage>() {
+          @Override
+          public boolean apply(ChangeMessage in) {
+            PatchSet.Id psId = in.getPatchSetId();
+            if (psId == null) {
+              return true;
+            }
+            return validPatchSet.apply(psId);
+          }
+        });
+  }
+
+  private Predicate<PatchSet.Id> upToCurrentPredicate() {
+    PatchSet.Id current = change.currentPatchSetId();
+    if (current == null) {
+      return Predicates.alwaysFalse();
+    }
+    final int max = current.get();
+    return new Predicate<PatchSet.Id>() {
+      @Override
+      public boolean apply(PatchSet.Id in) {
+        return in.get() <= max;
+      }
+    };
+  }
+
+  private Map<PatchSet.Id, PatchSet> filterPatchSets() {
+    return Maps.filterKeys(patchSets, upToCurrentPredicate());
+  }
+
+  private static void diffChanges(List<String> diffs, ChangeBundle bundleA,
+      ChangeBundle bundleB) {
+    Change a = bundleA.change;
+    Change b = bundleB.change;
+    String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
+
+    boolean excludeCreatedOn = false;
+    boolean excludeCurrentPatchSetId = false;
+    boolean excludeTopic = false;
+    Timestamp aUpdated = a.getLastUpdatedOn();
+    Timestamp bUpdated = b.getLastUpdatedOn();
+
+    boolean excludeSubject = false;
+    boolean excludeOrigSubj = false;
+    // Subject is not technically a nullable field, but we observed some null
+    // subjects in the wild on googlesource.com, so treat null as empty.
+    String aSubj = Strings.nullToEmpty(a.getSubject());
+    String bSubj = Strings.nullToEmpty(b.getSubject());
+
+    // Allow created timestamp in NoteDb to be either the created timestamp of
+    // the change, or the timestamp of the first remaining patch set.
+    //
+    // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
+    // The NoteDb subject is read directly from the commit, whereas the ReviewDb
+    // subject historically may have been truncated to fit in a SQL varchar
+    // column.
+    //
+    // Ignore original subject on the ReviewDb side when comparing to NoteDb.
+    // This field may have any number of values:
+    //  - It may be null, if the change has had no new patch sets pushed since
+    //    migrating to schema 103.
+    //  - It may match the first patch set subject, if the change was created
+    //    after migrating to schema 103.
+    //  - It may match the subject of the first patch set that was pushed after
+    //    the migration to schema 103, even though that is neither the subject
+    //    of the first patch set nor the subject of the last patch set. (See
+    //    Change#setCurrentPatchSet as of 43b10f86 for this behavior.) This
+    //    subject of an intermediate patch set is not available to the
+    //    ChangeBundle; we would have to get the subject from the repo, which is
+    //    inconvenient at this point.
+    //
+    // Ignore original subject on the ReviewDb side if it equals the subject of
+    // the current patch set.
+    //
+    // For all of the above subject comparisons, first trim any leading spaces
+    // from the NoteDb strings. (We actually do represent the leading spaces
+    // faithfully during conversion, but JGit's FooterLine parser trims them
+    // when reading.)
+    //
+    // Ignore empty topic on the ReviewDb side if it is null on the NoteDb side.
+    //
+    // Ignore currentPatchSetId on NoteDb side if ReviewDb does not point to a
+    // valid patch set.
+    //
+    // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      excludeCreatedOn = !timestampsDiffer(
+          bundleA, bundleA.getFirstPatchSetTime(), bundleB, b.getCreatedOn());
+      aSubj = cleanReviewDbSubject(aSubj);
+      excludeCurrentPatchSetId =
+          !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
+      excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
+      excludeOrigSubj = true;
+      String aTopic = trimLeadingOrNull(a.getTopic());
+      excludeTopic = Objects.equals(aTopic, b.getTopic())
+          || "".equals(aTopic) && b.getTopic() == null;
+      aUpdated = bundleA.getLatestTimestamp();
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      excludeCreatedOn = !timestampsDiffer(
+          bundleA, a.getCreatedOn(), bundleB, bundleB.getFirstPatchSetTime());
+      bSubj = cleanReviewDbSubject(bSubj);
+      excludeCurrentPatchSetId =
+          !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
+      excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
+      excludeOrigSubj = true;
+      String bTopic = trimLeadingOrNull(b.getTopic());
+      excludeTopic = Objects.equals(bTopic, a.getTopic())
+          || a.getTopic() == null && "".equals(bTopic);
+      bUpdated = bundleB.getLatestTimestamp();
+    }
+
+    String subjectField = "subject";
+    String updatedField = "lastUpdatedOn";
+    List<String> exclude = Lists.newArrayList(
+        subjectField, updatedField, "noteDbState", "rowVersion");
+    if (excludeCreatedOn) {
+      exclude.add("createdOn");
+    }
+    if (excludeCurrentPatchSetId) {
+      exclude.add("currentPatchSetId");
+    }
+    if (excludeOrigSubj) {
+      exclude.add("originalSubject");
+    }
+    if (excludeTopic) {
+      exclude.add("topic");
+    }
+    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b,
+        exclude);
+
+    // Allow last updated timestamps to either be exactly equal (within slop),
+    // or the NoteDb timestamp to be equal to the latest entity timestamp in the
+    // whole ReviewDb bundle (within slop).
+    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(),
+          bundleB, b.getLastUpdatedOn())) {
+      diffTimestamps(diffs, desc, bundleA, aUpdated, bundleB, bUpdated,
+          "effective last updated time");
+    }
+    if (!excludeSubject) {
+      diffValues(diffs, desc, aSubj, bSubj, subjectField);
+    }
+  }
+
+  private static String trimLeadingOrNull(String s) {
+    return s != null ? CharMatcher.whitespace().trimLeadingFrom(s) : null;
+  }
+
+  private static String cleanReviewDbSubject(String s) {
+    s = CharMatcher.is(' ').trimLeadingFrom(s);
+
+    // An old JGit bug failed to extract subjects from commits with "\r\n"
+    // terminators: https://bugs.eclipse.org/bugs/show_bug.cgi?id=400707
+    // Changes created with this bug may have "\r\n" converted to "\r " and the
+    // entire commit in the subject. The version of JGit used to read NoteDb
+    // changes parses these subjects correctly, so we need to clean up old
+    // ReviewDb subjects before comparing.
+    int rn = s.indexOf("\r \r ");
+    if (rn >= 0) {
+      s = s.substring(0, rn);
+    }
+    return s;
+  }
+
+  /**
+   * Set of fields that must always exactly match between ReviewDb and NoteDb.
+   * <p>
+   * Used to limit the worst-case quadratic search when pairing off matching
+   * messages below.
+   */
+  @AutoValue
+  abstract static class ChangeMessageCandidate {
+    static ChangeMessageCandidate create(ChangeMessage cm) {
+      return new AutoValue_ChangeBundle_ChangeMessageCandidate(
+          cm.getAuthor(),
+          cm.getMessage(),
+          cm.getTag());
+    }
+
+    @Nullable abstract Account.Id author();
+    @Nullable abstract String message();
+    @Nullable abstract String tag();
+
+    // Exclude:
+    //  - patch set, which may be null on ReviewDb side but not NoteDb
+    //  - UUID, which is always different between ReviewDb and NoteDb
+    //  - writtenOn, which is fuzzy
+  }
+
+  private static void diffChangeMessages(List<String> diffs,
+      ChangeBundle bundleA, ChangeBundle bundleB) {
+    if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
+      // Both came from ReviewDb: check all fields exactly.
+      Map<ChangeMessage.Key, ChangeMessage> as =
+          changeMessageMap(bundleA.filterChangeMessages());
+      Map<ChangeMessage.Key, ChangeMessage> bs =
+          changeMessageMap(bundleB.filterChangeMessages());
+
+      for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
+        ChangeMessage a = as.get(k);
+        ChangeMessage b = bs.get(k);
+        String desc = describe(k);
+        diffColumns(diffs, ChangeMessage.class, desc, bundleA, a, bundleB, b);
+      }
+      return;
+    }
+    Change.Id id = bundleA.getChange().getId();
+    checkArgument(id.equals(bundleB.getChange().getId()));
+
+    // Try to pair up matching ChangeMessages from each side, and succeed only
+    // if both collections are empty at the end. Quadratic in the worst case,
+    // but easy to reason about.
+    List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
+
+    Multimap<ChangeMessageCandidate, ChangeMessage> bs =
+        LinkedListMultimap.create();
+    for (ChangeMessage b : bundleB.filterChangeMessages()) {
+      bs.put(ChangeMessageCandidate.create(b), b);
+    }
+
+    Iterator<ChangeMessage> ait = as.iterator();
+    A: while (ait.hasNext()) {
+      ChangeMessage a = ait.next();
+      Iterator<ChangeMessage> bit =
+          bs.get(ChangeMessageCandidate.create(a)).iterator();
+      while (bit.hasNext()) {
+        ChangeMessage b = bit.next();
+        if (changeMessagesMatch(bundleA, a, bundleB, b)) {
+          ait.remove();
+          bit.remove();
+          continue A;
+        }
+      }
+    }
+
+    if (as.isEmpty() && bs.isEmpty()) {
+      return;
+    }
+    StringBuilder sb = new StringBuilder("ChangeMessages differ for Change.Id ")
+        .append(id).append('\n');
+    if (!as.isEmpty()) {
+      sb.append("Only in A:");
+      for (ChangeMessage cm : as) {
+        sb.append("\n  ").append(cm);
+      }
+      if (!bs.isEmpty()) {
+        sb.append('\n');
+      }
+    }
+    if (!bs.isEmpty()) {
+      sb.append("Only in B:");
+      for (ChangeMessage cm : CHANGE_MESSAGE_ORDER.sortedCopy(bs.values())) {
+        sb.append("\n  ").append(cm);
+      }
+    }
+    diffs.add(sb.toString());
+  }
+
+  private static boolean changeMessagesMatch(
+      ChangeBundle bundleA, ChangeMessage a,
+      ChangeBundle bundleB, ChangeMessage b) {
+    List<String> tempDiffs = new ArrayList<>();
+    String temp = "temp";
+
+    boolean excludePatchSet = false;
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      excludePatchSet = a.getPatchSetId() == null;
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      excludePatchSet = b.getPatchSetId() == null;
+    }
+
+    List<String> exclude = Lists.newArrayList("key");
+    if (excludePatchSet) {
+      exclude.add("patchset");
+    }
+
+    diffColumnsExcluding(
+        tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
+    return tempDiffs.isEmpty();
+  }
+
+  private static void diffPatchSets(List<String> diffs, ChangeBundle bundleA,
+      ChangeBundle bundleB) {
+    Map<PatchSet.Id, PatchSet> as = bundleA.filterPatchSets();
+    Map<PatchSet.Id, PatchSet> bs = bundleB.filterPatchSets();
+    for (PatchSet.Id id : diffKeySets(diffs, as, bs)) {
+      PatchSet a = as.get(id);
+      PatchSet b = bs.get(id);
+      String desc = describe(id);
+      String pushCertField = "pushCertificate";
+      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b,
+          pushCertField);
+      diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
+    }
+  }
+
+  private static String trimPushCert(PatchSet ps) {
+    if (ps.getPushCertificate() == null) {
+      return null;
+    }
+    return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
+  }
+
+  private static void diffPatchSetApprovals(List<String> diffs,
+      ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchSetApproval.Key, PatchSetApproval> as =
+          bundleA.filterPatchSetApprovals();
+    Map<PatchSetApproval.Key, PatchSetApproval> bs =
+        bundleB.filterPatchSetApprovals();
+    for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
+      PatchSetApproval a = as.get(k);
+      PatchSetApproval b = bs.get(k);
+      String desc = describe(k);
+      diffColumns(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b);
+    }
+  }
+
+  private static void diffReviewers(List<String> diffs,
+      ChangeBundle bundleA, ChangeBundle bundleB) {
+    diffSets(
+        diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
+  }
+
+  private static void diffPatchLineComments(List<String> diffs,
+      ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchLineComment.Key, PatchLineComment> as =
+        bundleA.filterPatchLineComments();
+    Map<PatchLineComment.Key, PatchLineComment> bs =
+        bundleB.filterPatchLineComments();
+    for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
+      PatchLineComment a = as.get(k);
+      PatchLineComment b = bs.get(k);
+      String desc = describe(k);
+      diffColumns(diffs, PatchLineComment.class, desc, bundleA, a, bundleB, b);
+    }
+  }
+
+  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a,
+      Map<T, ?> b) {
+    if (a.isEmpty() && b.isEmpty()) {
+      return a.keySet();
+    }
+    String clazz =
+        keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
+    return diffSets(diffs, a.keySet(), b.keySet(), clazz);
+  }
+
+  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as,
+      Set<T> bs, String desc) {
+    if (as.isEmpty() && bs.isEmpty()) {
+      return as;
+    }
+
+    Set<T> aNotB = Sets.difference(as, bs);
+    Set<T> bNotA = Sets.difference(bs, as);
+    if (aNotB.isEmpty() && bNotA.isEmpty()) {
+      return as;
+    }
+    diffs.add(desc + " sets differ: " + aNotB + " only in A; "
+        + bNotA + " only in B");
+    return Sets.intersection(as, bs);
+  }
+
+  private static <T> void diffColumns(List<String> diffs, Class<T> clazz,
+      String desc, ChangeBundle bundleA, T a, ChangeBundle bundleB, T b) {
+    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
+  }
+
+  private static <T> void diffColumnsExcluding(List<String> diffs,
+      Class<T> clazz, String desc, ChangeBundle bundleA, T a,
+      ChangeBundle bundleB, T b, String... exclude) {
+    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b,
+        Arrays.asList(exclude));
+  }
+
+  private static <T> void diffColumnsExcluding(List<String> diffs,
+      Class<T> clazz, String desc, ChangeBundle bundleA, T a,
+      ChangeBundle bundleB, T b, Iterable<String> exclude) {
+    Set<String> toExclude = Sets.newLinkedHashSet(exclude);
+    for (Field f : clazz.getDeclaredFields()) {
+      Column col = f.getAnnotation(Column.class);
+      if (col == null) {
+        continue;
+      } else if (toExclude.remove(f.getName())) {
+        continue;
+      }
+      f.setAccessible(true);
+      try {
+        if (Timestamp.class.isAssignableFrom(f.getType())) {
+          diffTimestamps(diffs, desc, bundleA, a, bundleB, b, f.getName());
+        } else {
+          diffValues(diffs, desc, f.get(a), f.get(b), f.getName());
+        }
+      } catch (IllegalAccessException e) {
+        throw new IllegalArgumentException(e);
+      }
+    }
+    checkArgument(toExclude.isEmpty(),
+        "requested columns to exclude not present in %s: %s",
+        clazz.getSimpleName(), toExclude);
+  }
+
+  private static void diffTimestamps(List<String> diffs, String desc,
+      ChangeBundle bundleA, Object a, ChangeBundle bundleB, Object b,
+      String field) {
+    checkArgument(a.getClass() == b.getClass());
+    Class<?> clazz = a.getClass();
+
+    Timestamp ta;
+    Timestamp tb;
+    try {
+      Field f = clazz.getDeclaredField(field);
+      checkArgument(f.getAnnotation(Column.class) != null);
+      f.setAccessible(true);
+      ta = (Timestamp) f.get(a);
+      tb = (Timestamp) f.get(b);
+    } catch (IllegalAccessException | NoSuchFieldException
+        | SecurityException e) {
+      throw new IllegalArgumentException(e);
+    }
+    diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
+  }
+
+  private static void diffTimestamps(List<String> diffs, String desc,
+      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb,
+      String fieldDesc) {
+    if (bundleA.source == bundleB.source || ta == null || tb == null) {
+      diffValues(diffs, desc, ta, tb, fieldDesc);
+    } else if (bundleA.source == NOTE_DB) {
+      diffTimestamps(
+          diffs, desc,
+          bundleA.getChange(), ta,
+          bundleB.getChange(), tb,
+          fieldDesc);
+    } else {
+      diffTimestamps(
+          diffs, desc,
+          bundleB.getChange(), tb,
+          bundleA.getChange(), ta,
+          fieldDesc);
+    }
+  }
+
+  private static boolean timestampsDiffer(ChangeBundle bundleA, Timestamp ta,
+      ChangeBundle bundleB, Timestamp tb) {
+    List<String> tempDiffs = new ArrayList<>(1);
+    diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
+    return !tempDiffs.isEmpty();
+  }
+
+  private static void diffTimestamps(List<String> diffs, String desc,
+      Change changeFromNoteDb, Timestamp tsFromNoteDb,
+      Change changeFromReviewDb, Timestamp tsFromReviewDb,
+      String field) {
+    // Because ChangeRebuilder may batch events together that are several
+    // seconds apart, the timestamp in NoteDb may actually be several seconds
+    // *earlier* than the timestamp in ReviewDb that it was converted from.
+    checkArgument(tsFromNoteDb.equals(roundToSecond(tsFromNoteDb)),
+        "%s from NoteDb has non-rounded %s timestamp: %s",
+        desc, field, tsFromNoteDb);
+
+    if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
+        && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
+      // Timestamp predates change creation. These are truncated to change
+      // creation time during NoteDb conversion, so allow this if the timestamp
+      // in NoteDb matches the createdOn time in NoteDb.
+      return;
+    }
+
+
+    long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
+    long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
+    if (delta < 0 || delta > max) {
+      diffs.add(
+          field + " differs for " + desc + " in NoteDb vs. ReviewDb:"
+          + " {" + tsFromNoteDb + "} != {" + tsFromReviewDb + "}");
+    }
+  }
+
+  private static void diffValues(List<String> diffs, String desc, Object va,
+      Object vb, String name) {
+    if (!Objects.equals(va, vb)) {
+      diffs.add(
+          name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
+    }
+  }
+
+  private static String describe(Object key) {
+    return keyClass(key) + " " + key;
+  }
+
+  private static String keyClass(Object obj) {
+    Class<?> clazz = obj.getClass();
+    String name = clazz.getSimpleName();
+    checkArgument(name.endsWith("Key") || name.endsWith("Id"),
+        "not an Id/Key class: %s", name);
+    if (name.equals("Key") || name.equals("Id")) {
+      return clazz.getEnclosingClass().getSimpleName() + "." + name;
+    } else if (name.startsWith("AutoValue_")) {
+      return name.substring(name.lastIndexOf('_') + 1);
+    }
+    return name;
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{id=" + change.getId()
+        + ", ChangeMessage[" + changeMessages.size() + "]"
+        + ", PatchSet[" + patchSets.size() + "]"
+        + ", PatchSetApproval[" + patchSetApprovals.size() + "]"
+        + ", PatchLineComment[" + patchLineComments.size() + "]"
+        + "}";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index bd8f797..7b59a47 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -14,26 +14,21 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.CommentsInNotesUtil.addCommentToMap;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -41,18 +36,19 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * A single delta to apply atomically to a change.
@@ -65,204 +61,176 @@
  */
 public class ChangeDraftUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeDraftUpdate create(ChangeControl ctl, Date when);
+    ChangeDraftUpdate create(ChangeNotes notes, Account.Id accountId,
+        PersonIdent authorIdent, Date when);
+    ChangeDraftUpdate create(Change change, Account.Id accountId,
+        PersonIdent authorIdent, Date when);
+  }
+
+  @AutoValue
+  abstract static class Key {
+    abstract RevId revId();
+    abstract PatchLineComment.Key key();
+  }
+
+  private static Key key(PatchLineComment c) {
+    return new AutoValue_ChangeDraftUpdate_Key(c.getRevId(), c.getKey());
   }
 
   private final AllUsersName draftsProject;
-  private final Account.Id accountId;
-  private final CommentsInNotesUtil commentsUtil;
-  private final ChangeNotes changeNotes;
-  private final DraftCommentNotes draftNotes;
 
-  private List<PatchLineComment> upsertComments;
-  private List<PatchLineComment> deleteComments;
+  private List<PatchLineComment> put = new ArrayList<>();
+  private Set<Key> delete = new HashSet<>();
 
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      GitRepositoryManager repoManager,
       NotesMigration migration,
-      MetaDataUpdate.User updateFactory,
-      DraftCommentNotes.Factory draftNotesFactory,
       AllUsersName allUsers,
-      CommentsInNotesUtil commentsUtil,
-      @Assisted ChangeControl ctl,
-      @Assisted Date when) throws OrmException {
-    super(migration, repoManager, updateFactory, ctl, serverIdent,
-        anonymousCowardName, when);
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted Account.Id accountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
+        accountId, authorIdent, when);
     this.draftsProject = allUsers;
-    this.commentsUtil = commentsUtil;
-    checkState(ctl.getUser().isIdentifiedUser(),
-        "Current user must be identified");
-    IdentifiedUser user = ctl.getUser().asIdentifiedUser();
-    this.accountId = user.getAccountId();
-    this.changeNotes = getChangeNotes().load();
-    this.draftNotes = draftNotesFactory.create(ctl.getChange().getId(),
-        user.getAccountId());
-
-    this.upsertComments = Lists.newArrayList();
-    this.deleteComments = Lists.newArrayList();
   }
 
-  public void insertComment(PatchLineComment c) throws OrmException {
+  @AssistedInject
+  private ChangeDraftUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      AllUsersName allUsers,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted Account.Id accountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
+        accountId, authorIdent, when);
+    this.draftsProject = allUsers;
+  }
+
+  public void putComment(PatchLineComment c) {
     verifyComment(c);
-    checkArgument(c.getStatus() == Status.DRAFT,
+    checkArgument(c.getStatus() == PatchLineComment.Status.DRAFT,
         "Cannot insert a published comment into a ChangeDraftUpdate");
-    if (migration.readChanges()) {
-      checkArgument(!changeNotes.containsComment(c),
-          "A comment already exists with the same key,"
-          + " so the following comment cannot be inserted: %s", c);
-    }
-    upsertComments.add(c);
+    put.add(c);
   }
 
-  public void upsertComment(PatchLineComment c) {
+  public void deleteComment(PatchLineComment c) {
     verifyComment(c);
-    checkArgument(c.getStatus() == Status.DRAFT,
-        "Cannot upsert a published comment into a ChangeDraftUpdate");
-    upsertComments.add(c);
+    delete.add(key(c));
   }
 
-  public void updateComment(PatchLineComment c) throws OrmException {
-    verifyComment(c);
-    checkArgument(c.getStatus() == Status.DRAFT,
-        "Cannot update a published comment into a ChangeDraftUpdate");
-    // Here, we check to see if this comment existed previously as a draft.
-    // However, this could cause a race condition if there is a delete and an
-    // update operation happening concurrently (or two deletes) and they both
-    // believe that the comment exists. If a delete happens first, then
-    // the update will fail. However, this is an acceptable risk since the
-    // caller wanted the comment deleted anyways, so the end result will be the
-    // same either way.
-    if (migration.readChanges()) {
-      checkArgument(draftNotes.load().containsComment(c),
-          "Cannot update this comment because it didn't exist previously");
-    }
-    upsertComments.add(c);
-  }
-
-  public void deleteComment(PatchLineComment c) throws OrmException {
-    verifyComment(c);
-    // See the comment above about potential race condition.
-    if (migration.readChanges()) {
-      checkArgument(draftNotes.load().containsComment(c),
-          "Cannot delete this comment because it didn't previously exist as a"
-          + " draft");
-    }
-    if (migration.writeChanges()) {
-      if (draftNotes.load().containsComment(c)) {
-        deleteComments.add(c);
-      }
-    }
-  }
-
-  /**
-   * Deletes a PatchLineComment from the list of drafts only if it existed
-   * previously as a draft. If it wasn't a draft previously, this is a no-op.
-   */
-  public void deleteCommentIfPresent(PatchLineComment c) throws OrmException {
-    if (draftNotes.load().containsComment(c)) {
-      verifyComment(c);
-      deleteComments.add(c);
-    }
+  public void deleteComment(RevId revId, PatchLineComment.Key key) {
+    delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
   }
 
   private void verifyComment(PatchLineComment comment) {
-    if (migration.writeChanges()) {
-      checkArgument(comment.getRevId() != null);
-    }
     checkArgument(comment.getAuthor().equals(accountId),
         "The author for the following comment does not match the author of"
         + " this ChangeDraftUpdate (%s): %s", accountId, comment);
   }
 
-  /** @return the tree id for the updated tree */
-  private ObjectId storeCommentsInNotes(AtomicBoolean removedAllComments)
-      throws OrmException, IOException {
-    if (isEmpty()) {
-      return null;
+  private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
+      ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs =
+        Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (PatchLineComment c : put) {
+      if (!delete.contains(key(c))) {
+        cache.get(c.getRevId()).putComment(c);
+      }
+    }
+    for (Key k : delete) {
+      cache.get(k.revId()).deleteComment(k.key());
     }
 
-    NoteMap noteMap = draftNotes.load().getNoteMap();
-    if (noteMap == null) {
-      noteMap = NoteMap.newEmptyMap();
-    }
-
-    Map<RevId, List<PatchLineComment>> allComments = new HashMap<>();
-
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
     boolean hasComments = false;
-    int n = deleteComments.size() + upsertComments.size();
-    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(n);
-    Set<PatchLineComment.Key> updatedKeys = Sets.newHashSetWithExpectedSize(n);
-    for (PatchLineComment c : deleteComments) {
-      allComments.put(c.getRevId(), new ArrayList<PatchLineComment>());
-      updatedRevs.add(c.getRevId());
-      updatedKeys.add(c.getKey());
-    }
-
-    for (PatchLineComment c : upsertComments) {
-      hasComments = true;
-      addCommentToMap(allComments, c);
-      updatedRevs.add(c.getRevId());
-      updatedKeys.add(c.getKey());
-    }
-
-    // Re-add old comments for updated revisions so the new note contents
-    // includes both old and new comments merged in the right order.
-    //
-    // writeCommentsToNoteMap doesn't touch notes for SHA-1s that are not
-    // mentioned in the input map, so by omitting comments for those revisions,
-    // we avoid the work of having to re-serialize identical comment data for
-    // those revisions.
-    ListMultimap<RevId, PatchLineComment> existing =
-        draftNotes.getComments();
-    for (Map.Entry<RevId, PatchLineComment> e : existing.entries()) {
-      PatchLineComment c = e.getValue();
-      if (updatedRevs.contains(c.getRevId())
-          && !updatedKeys.contains(c.getKey())) {
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil);
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
         hasComments = true;
-        addCommentToMap(allComments, e.getValue());
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
       }
     }
 
-    // If we touched every revision and there are no comments left, set the flag
-    // for the caller to delete the entire ref.
-    boolean touchedAllRevs = updatedRevs.equals(existing.keySet());
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
     if (touchedAllRevs && !hasComments) {
-      removedAllComments.set(touchedAllRevs && !hasComments);
       return null;
     }
 
-    commentsUtil.writeCommentsToNoteMap(noteMap, allComments, inserter);
-    return noteMap.writeTree(inserter);
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
   }
 
-  public RevCommit commit() throws IOException {
-    BatchMetaDataUpdate batch = openUpdate();
-    try {
-      writeCommit(batch);
-      return batch.commit();
-    } catch (OrmException e) {
-      throw new IOException(e);
-    } finally {
-      batch.close();
+  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old DraftCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        DraftCommentNotes draftNotes =
+            changeNotes.load().getDraftCommentNotes();
+        if (draftNotes != null) {
+          ObjectId idFromNotes =
+              firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap rnm = draftNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
     }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parse(
+        noteUtil, getId(), rw.getObjectReader(), noteMap, true);
   }
 
   @Override
-  public void writeCommit(BatchMetaDataUpdate batch)
-      throws OrmException, IOException {
-    CommitBuilder builder = new CommitBuilder();
-    if (migration.writeChanges()) {
-      AtomicBoolean removedAllComments = new AtomicBoolean();
-      ObjectId treeId = storeCommentsInNotes(removedAllComments);
-      if (removedAllComments.get()) {
-        batch.removeRef(getRefName());
-      } else if (treeId != null) {
-        builder.setTreeId(treeId);
-        batch.write(builder);
-      }
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
+      ObjectId curr) throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update draft comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
     }
   }
 
@@ -273,23 +241,12 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(accountId, getChange().getId());
+    return RefNames.refsDraftComments(getId(), accountId);
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
-    if (isEmpty()) {
-      return false;
-    }
-    commit.setAuthor(newIdent(getUser().getAccount(), when));
-    commit.setCommitter(new PersonIdent(serverIdent, when));
-    commit.setMessage("Update draft comments");
-    return true;
-  }
-
-  private boolean isEmpty() {
-    return deleteComments.isEmpty()
-        && upsertComments.isEmpty();
+  public boolean isEmpty() {
+    return delete.isEmpty()
+        && put.isEmpty();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index c561a0d..4c1a734 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -14,49 +14,564 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.common.data.AccountInfo;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
+import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.CommentRange;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.inject.Inject;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.util.GitDateFormatter;
+import org.eclipse.jgit.util.GitDateFormatter.Format;
+import org.eclipse.jgit.util.GitDateParser;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.QuotedString;
+import org.eclipse.jgit.util.RawParseUtils;
 
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.sql.Timestamp;
+import java.text.ParseException;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 
 public class ChangeNoteUtil {
-  static final String GERRIT_PLACEHOLDER_HOST = "gerrit";
-
+  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
   static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
   static final FooterKey FOOTER_LABEL = new FooterKey("Label");
   static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
   static final FooterKey FOOTER_SUBMITTED_WITH =
       new FooterKey("Submitted-with");
+  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
 
-  public static String changeRefName(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(RefNames.REFS_CHANGES);
-    int n = id.get();
-    int m = n % 100;
-    if (m < 10) {
-      r.append('0');
-    }
-    r.append(m);
-    r.append('/');
-    r.append(n);
-    r.append(RefNames.META_SUFFIX);
-    return r.toString();
+  private static final String AUTHOR = "Author";
+  private static final String BASE_PATCH_SET = "Base-for-patch-set";
+  private static final String COMMENT_RANGE = "Comment-range";
+  private static final String FILE = "File";
+  private static final String LENGTH = "Bytes";
+  private static final String PARENT = "Parent";
+  private static final String PARENT_NUMBER = "Parent-number";
+  private static final String PATCH_SET = "Patch-set";
+  private static final String REVISION = "Revision";
+  private static final String UUID = "UUID";
+  private static final String TAG = FOOTER_TAG.getName();
+
+  public static String formatTime(PersonIdent ident, Timestamp t) {
+    GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
+    // TODO(dborowitz): Use a ThreadLocal or use Joda.
+    PersonIdent newIdent = new PersonIdent(ident, t);
+    return dateFormatter.formatDate(newIdent);
   }
 
-  static PersonIdent newIdent(Account author, Date when,
+  private final AccountCache accountCache;
+  private final PersonIdent serverIdent;
+  private final String anonymousCowardName;
+  private final String serverId;
+
+  @Inject
+  public ChangeNoteUtil(AccountCache accountCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      @GerritServerId String serverId) {
+    this.accountCache = accountCache;
+    this.serverIdent = serverIdent;
+    this.anonymousCowardName = anonymousCowardName;
+    this.serverId = serverId;
+  }
+
+  @VisibleForTesting
+  public PersonIdent newIdent(Account author, Date when,
       PersonIdent serverIdent, String anonymousCowardName) {
     return new PersonIdent(
-        new AccountInfo(author).getName(anonymousCowardName),
-        author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
+        author.getName(anonymousCowardName),
+        author.getId().get() + "@" + serverId,
         when, serverIdent.getTimeZone());
   }
 
-  private ChangeNoteUtil() {
+  public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
+      throws ConfigInvalidException {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      String host = email.substring(at + 1, email.length());
+      if (host.equals(serverId)) {
+        Integer id = Ints.tryParse(email.substring(0, at));
+        if (id != null) {
+          return new Account.Id(id);
+        }
+      }
+    }
+    throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
+        serverId, email);
+  }
+
+  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
+    int m = RawParseUtils.match(note, p.value, expected);
+    return m == p.value + expected.length;
+  }
+
+  public List<PatchLineComment> parseNote(byte[] note, MutableInteger p,
+      Change.Id changeId, Status status) throws ConfigInvalidException {
+    if (p.value >= note.length) {
+      return ImmutableList.of();
+    }
+    Set<PatchLineComment.Key> seen = new HashSet<>();
+    List<PatchLineComment> result = new ArrayList<>();
+    int sizeOfNote = note.length;
+    byte[] psb = PATCH_SET.getBytes(UTF_8);
+    byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
+    byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);
+
+    RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
+    String fileName = null;
+    PatchSet.Id psId = null;
+    boolean isForBase = false;
+    Integer parentNumber = null;
+
+    while (p.value < sizeOfNote) {
+      boolean matchPs = match(note, p, psb);
+      boolean matchBase = match(note, p, bpsb);
+      if (matchPs) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, PATCH_SET);
+        isForBase = false;
+      } else if (matchBase) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
+        isForBase = true;
+        if (match(note, p, bpn)) {
+          parentNumber = parseParentNumber(note, p, changeId);
+        }
+      } else if (psId == null) {
+        throw parseException(changeId, "missing %s or %s header",
+            PATCH_SET, BASE_PATCH_SET);
+      }
+
+      PatchLineComment c = parseComment(
+          note, p, fileName, psId, revId, isForBase, parentNumber, status);
+      fileName = c.getKey().getParentKey().getFileName();
+      if (!seen.add(c.getKey())) {
+        throw parseException(
+            changeId, "multiple comments for %s in note", c.getKey());
+      }
+      result.add(c);
+    }
+    return result;
+  }
+
+  private PatchLineComment parseComment(byte[] note, MutableInteger curr,
+      String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
+      Integer parentNumber, Status status) throws ConfigInvalidException {
+    Change.Id changeId = psId.getParentKey();
+
+    // Check if there is a new file.
+    boolean newFile =
+        (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
+    if (newFile) {
+      // If so, parse the new file name.
+      currentFileName = parseFilename(note, curr, changeId);
+    } else if (currentFileName == null) {
+      throw parseException(changeId, "could not parse %s", FILE);
+    }
+
+    CommentRange range = parseCommentRange(note, curr);
+    if (range == null) {
+      throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
+    }
+
+    Timestamp commentTime = parseTimestamp(note, curr, changeId);
+    Account.Id aId = parseAuthor(note, curr, changeId);
+
+    boolean hasParent =
+        (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
+    String parentUUID = null;
+    if (hasParent) {
+      parentUUID = parseStringField(note, curr, changeId, PARENT);
+    }
+
+    String uuid = parseStringField(note, curr, changeId, UUID);
+
+    boolean hasTag =
+        (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
+    String tag = null;
+    if (hasTag) {
+      tag = parseStringField(note, curr, changeId, TAG);
+    }
+
+    int commentLength = parseCommentLength(note, curr, changeId);
+
+    String message = RawParseUtils.decode(
+        UTF_8, note, curr.value, curr.value + commentLength);
+    checkResult(message, "message contents", changeId);
+
+    PatchLineComment plc = new PatchLineComment(
+        new PatchLineComment.Key(new Patch.Key(psId, currentFileName), uuid),
+        range.getEndLine(), aId, parentUUID, commentTime);
+    plc.setMessage(message);
+    plc.setTag(tag);
+
+    if (isForBase) {
+      plc.setSide((short) (parentNumber == null ? 0 : -parentNumber));
+    } else {
+      plc.setSide((short) 1);
+    }
+
+    if (range.getStartCharacter() != -1) {
+      plc.setRange(range);
+    }
+    plc.setRevId(revId);
+    plc.setStatus(status);
+
+    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return plc;
+  }
+
+  private static String parseStringField(byte[] note, MutableInteger curr,
+      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    curr.value = endOfLine;
+    return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
+  }
+
+  /**
+   * @return a comment range. If the comment range line in the note only has
+   *    one number, we return a CommentRange with that one number as the end
+   *    line and the other fields as -1. If the comment range line in the note
+   *    contains a whole comment range, then we return a CommentRange with all
+   *    fields set. If the line is not correctly formatted, return null.
+   */
+  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
+    CommentRange range = new CommentRange(-1, -1, -1, -1);
+
+    int last = ptr.value;
+    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '\n') {
+      range.setEndLine(startLine);
+      ptr.value += 1;
+      return range;
+    } else if (note[ptr.value] == ':') {
+      range.setStartLine(startLine);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '-') {
+      range.setStartCharacter(startChar);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == ':') {
+      range.setEndLine(endLine);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+
+    last = ptr.value;
+    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
+    if (ptr.value == last) {
+      return null;
+    } else if (note[ptr.value] == '\n') {
+      range.setEndCharacter(endChar);
+      ptr.value += 1;
+    } else {
+      return null;
+    }
+    return range;
+  }
+
+  private static PatchSet.Id parsePsId(byte[] note, MutableInteger curr,
+      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfPsId =
+        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    int patchSetId =
+        RawParseUtils.parseBase10(note, startOfPsId, i);
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    checkResult(patchSetId, "patchset id", changeId);
+    curr.value = endOfLine;
+    return new PatchSet.Id(changeId, patchSetId);
+  }
+
+  private static Integer parseParentNumber(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
+
+    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    int parentNumber = RawParseUtils.parseBase10(note, start, i);
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
+    }
+    checkResult(parentNumber, "parent number", changeId);
+    curr.value = endOfLine;
+    return Integer.valueOf(parentNumber);
+
+  }
+
+  private static String parseFilename(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, FILE, changeId);
+    int startOfFileName =
+        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    curr.value = endOfLine;
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return QuotedString.GIT_PATH.dequote(
+        RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
+  }
+
+  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    Timestamp commentTime;
+    String dateString =
+        RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
+    try {
+      commentTime = new Timestamp(
+          GitDateParser.parse(dateString, null, Locale.US).getTime());
+    } catch (ParseException e) {
+      throw new ConfigInvalidException("could not parse comment timestamp", e);
+    }
+    curr.value = endOfLine;
+    return checkResult(commentTime, "comment timestamp", changeId);
+  }
+
+  private Account.Id parseAuthor(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, AUTHOR, changeId);
+    int startOfAccountId =
+        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    PersonIdent ident =
+        RawParseUtils.parsePersonIdent(note, startOfAccountId);
+    Account.Id aId = parseIdent(ident, changeId);
+    curr.value = RawParseUtils.nextLF(note, curr.value);
+    return checkResult(aId, "comment author", changeId);
+  }
+
+  private static int parseCommentLength(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, LENGTH, changeId);
+    int startOfLength =
+        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    i.value = startOfLength;
+    int commentLength =
+        RawParseUtils.parseBase10(note, startOfLength, i);
+    if (i.value == startOfLength) {
+      throw parseException(changeId, "could not parse %s", LENGTH);
+    }
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", LENGTH);
+    }
+    curr.value = endOfLine;
+    return checkResult(commentLength, "comment length", changeId);
+  }
+
+  private static <T> T checkResult(T o, String fieldName,
+      Change.Id changeId) throws ConfigInvalidException {
+    if (o == null) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    return o;
+  }
+
+  private static int checkResult(int i, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
+    if (i <= 0) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+    return i;
+  }
+
+  private void appendHeaderField(PrintWriter writer,
+      String field, String value) {
+    writer.print(field);
+    writer.print(": ");
+    writer.print(value);
+    writer.print('\n');
+  }
+
+  private static void checkHeaderLineFormat(byte[] note, MutableInteger curr,
+      String fieldName, Change.Id changeId) throws ConfigInvalidException {
+    boolean correct =
+        RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
+    int p = curr.value + fieldName.length();
+    correct &= (p < note.length && note[p] == ':');
+    p++;
+    correct &= (p < note.length && note[p] == ' ');
+    if (!correct) {
+      throw parseException(changeId, "could not parse %s", fieldName);
+    }
+  }
+
+  /**
+   * Build a note that contains the metadata for and the contents of all of the
+   * comments in the given comments.
+   *
+   * @param comments Comments to be written to the output stream, keyed by patch
+   *     set ID; multiple patch sets are allowed since base revisions may be
+   *     shared across patch sets. All of the comments must share the same
+   *     RevId, and all the comments for a given patch set must have the same
+   *     side.
+   * @param out output stream to write to.
+   */
+  void buildNote(Multimap<PatchSet.Id, PatchLineComment> comments,
+      OutputStream out) {
+    if (comments.isEmpty()) {
+      return;
+    }
+
+    List<PatchSet.Id> psIds =
+        ReviewDbUtil.intKeyOrdering().sortedCopy(comments.keySet());
+
+    OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
+    try (PrintWriter writer = new PrintWriter(streamWriter)) {
+      RevId revId = comments.values().iterator().next().getRevId();
+      appendHeaderField(writer, REVISION, revId.get());
+
+      for (PatchSet.Id psId : psIds) {
+        List<PatchLineComment> psComments =
+            PLC_ORDER.sortedCopy(comments.get(psId));
+        PatchLineComment first = psComments.get(0);
+
+        short side = first.getSide();
+        appendHeaderField(writer, side <= 0
+            ? BASE_PATCH_SET
+            : PATCH_SET,
+            Integer.toString(psId.get()));
+        if (side < 0) {
+          appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
+        }
+
+        String currentFilename = null;
+
+        for (PatchLineComment c : psComments) {
+          checkArgument(revId.equals(c.getRevId()),
+              "All comments being added must have all the same RevId. The "
+              + "comment below does not have the same RevId as the others "
+              + "(%s).\n%s", revId, c);
+          checkArgument(side == c.getSide(),
+              "All comments being added must all have the same side. The "
+              + "comment below does not have the same side as the others "
+              + "(%s).\n%s", side, c);
+          String commentFilename = QuotedString.GIT_PATH.quote(
+              c.getKey().getParentKey().getFileName());
+
+          if (!commentFilename.equals(currentFilename)) {
+            currentFilename = commentFilename;
+            writer.print("File: ");
+            writer.print(commentFilename);
+            writer.print("\n\n");
+          }
+
+          appendOneComment(writer, c);
+        }
+      }
+    }
+  }
+
+  private void appendOneComment(PrintWriter writer, PatchLineComment c) {
+    // The CommentRange field for a comment is allowed to be null. If it is
+    // null, then in the first line, we simply use the line number field for a
+    // comment instead. If it isn't null, we write the comment range itself.
+    CommentRange range = c.getRange();
+    if (range != null) {
+      writer.print(range.getStartLine());
+      writer.print(':');
+      writer.print(range.getStartCharacter());
+      writer.print('-');
+      writer.print(range.getEndLine());
+      writer.print(':');
+      writer.print(range.getEndCharacter());
+    } else {
+      writer.print(c.getLine());
+    }
+    writer.print("\n");
+
+    writer.print(formatTime(serverIdent, c.getWrittenOn()));
+    writer.print("\n");
+
+    PersonIdent ident = newIdent(
+        accountCache.get(c.getAuthor()).getAccount(),
+        c.getWrittenOn(), serverIdent, anonymousCowardName);
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(name, ident.getName());
+    name.append(" <");
+    PersonIdent.appendSanitized(name, ident.getEmailAddress());
+    name.append('>');
+    appendHeaderField(writer, AUTHOR, name.toString());
+
+    String parent = c.getParentUuid();
+    if (parent != null) {
+      appendHeaderField(writer, PARENT, parent);
+    }
+
+    appendHeaderField(writer, UUID, c.getKey().get());
+
+    if (c.getTag() != null) {
+      appendHeaderField(writer, TAG, c.getTag());
+    }
+
+    byte[] messageBytes = c.getMessage().getBytes(UTF_8);
+    appendHeaderField(writer, LENGTH,
+        Integer.toString(messageBytes.length));
+
+    writer.print(c.getMessage());
+    writer.print("\n\n");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 063ff5a..6327682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -14,18 +14,29 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
-import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -33,27 +44,44 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
+  private static final Logger log = LoggerFactory.getLogger(ChangeNotes.class);
+
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
       Ordering.natural().onResultOf(
         new Function<PatchSetApproval, Timestamp>() {
@@ -78,73 +106,296 @@
         + String.format(fmt, args));
   }
 
-  public static Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
-      throws ConfigInvalidException {
-    String email = ident.getEmailAddress();
-    int at = email.indexOf('@');
-    if (at >= 0) {
-      String host = email.substring(at + 1, email.length());
-      Integer id = Ints.tryParse(email.substring(0, at));
-      if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
-        return new Account.Id(id);
-      }
-    }
-    throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
-      GERRIT_PLACEHOLDER_HOST, email);
-  }
-
   @Singleton
   public static class Factory {
-    private final GitRepositoryManager repoManager;
-    private final NotesMigration migration;
-    private final AllUsersNameProvider allUsersProvider;
+    private final Args args;
+    private final Provider<InternalChangeQuery> queryProvider;
+    private final ProjectCache projectCache;
 
     @VisibleForTesting
     @Inject
-    public Factory(GitRepositoryManager repoManager,
-        NotesMigration migration,
-        AllUsersNameProvider allUsersProvider) {
-      this.repoManager = repoManager;
-      this.migration = migration;
-      this.allUsersProvider = allUsersProvider;
+    public Factory(Args args,
+        Provider<InternalChangeQuery> queryProvider,
+        ProjectCache projectCache) {
+      this.args = args;
+      this.queryProvider = queryProvider;
+      this.projectCache = projectCache;
     }
 
-    public ChangeNotes create(Change change) {
-      return new ChangeNotes(repoManager, migration, allUsersProvider, change);
+    public ChangeNotes createChecked(ReviewDb db, Change c)
+        throws OrmException, NoSuchChangeException {
+      return createChecked(db, c.getProject(), c.getId());
+    }
+
+    public ChangeNotes createChecked(ReviewDb db, Project.NameKey project,
+        Change.Id changeId) throws OrmException, NoSuchChangeException {
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      if (change == null || !change.getProject().equals(project)) {
+        throw new NoSuchChangeException(changeId);
+      }
+      return new ChangeNotes(args, change).load();
+    }
+
+    public ChangeNotes createChecked(Change.Id changeId)
+        throws OrmException, NoSuchChangeException {
+      InternalChangeQuery query = queryProvider.get().noFields();
+      List<ChangeData> changes = query.byLegacyChangeId(changeId);
+      if (changes.isEmpty()) {
+        throw new NoSuchChangeException(changeId);
+      }
+      if (changes.size() != 1) {
+        log.error(
+            String.format("Multiple changes found for %d", changeId.get()));
+        throw new NoSuchChangeException(changeId);
+      }
+      return changes.get(0).notes();
+    }
+
+    private Change loadChangeFromDb(ReviewDb db, Project.NameKey project,
+        Change.Id changeId) throws OrmException {
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      checkNotNull(change,
+          "change %s not found in ReviewDb", changeId);
+      checkArgument(change.getProject().equals(project),
+          "passed project %s when creating ChangeNotes for %s, but actual"
+          + " project is %s",
+          project, changeId, change.getProject());
+      // TODO: Throw NoSuchChangeException when the change is not found in the
+      // database
+      return change;
+    }
+
+    public ChangeNotes create(ReviewDb db, Project.NameKey project,
+        Change.Id changeId) throws OrmException {
+      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId))
+          .load();
+    }
+
+    public ChangeNotes createWithAutoRebuildingDisabled(ReviewDb db,
+        Project.NameKey project, Change.Id changeId) throws OrmException {
+      return new ChangeNotes(
+          args, loadChangeFromDb(db, project, changeId), false, null).load();
+    }
+
+    /**
+     * Create change notes for a change that was loaded from index. This method
+     * should only be used when database access is harmful and potentially stale
+     * data from the index is acceptable.
+     *
+     * @param change change loaded from secondary index
+     * @return change notes
+     */
+    public ChangeNotes createFromIndexedChange(Change change) {
+      return new ChangeNotes(args, change);
+    }
+
+    public ChangeNotes createForBatchUpdate(Change change) throws OrmException {
+      return new ChangeNotes(args, change, false, null).load();
+    }
+
+    // TODO(dborowitz): Remove when deleting index schemas <27.
+    public ChangeNotes createFromIdOnlyWhenNoteDbDisabled(
+        ReviewDb db, Change.Id changeId) throws OrmException {
+      checkState(!args.migration.readChanges(), "do not call"
+          + " createFromIdOnlyWhenNoteDbDisabled when NoteDb is enabled");
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      checkNotNull(change,
+          "change %s not found in ReviewDb", changeId);
+      return new ChangeNotes(args, change).load();
+    }
+
+    public ChangeNotes createWithAutoRebuildingDisabled(Change change,
+        RefCache refs) throws OrmException {
+      return new ChangeNotes(args, change, false, refs).load();
+    }
+
+    // TODO(ekempin): Remove when database backend is deleted
+    /**
+     * Instantiate ChangeNotes for a change that has been loaded by a batch read
+     * from the database.
+     */
+    private ChangeNotes createFromChangeOnlyWhenNoteDbDisabled(Change change)
+        throws OrmException {
+      checkState(!args.migration.readChanges(), "do not call"
+          + " createFromChangeWhenNoteDbDisabled when NoteDb is enabled");
+      return new ChangeNotes(args, change).load();
+    }
+
+    public List<ChangeNotes> create(ReviewDb db,
+        Collection<Change.Id> changeIds) throws OrmException {
+      List<ChangeNotes> notes = new ArrayList<>();
+      if (args.migration.enabled()) {
+        for (Change.Id changeId : changeIds) {
+          try {
+            notes.add(createChecked(changeId));
+          } catch (NoSuchChangeException e) {
+            // Ignore missing changes to match Access#get(Iterable) behavior.
+          }
+        }
+        return notes;
+      }
+
+      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
+        notes.add(createFromChangeOnlyWhenNoteDbDisabled(c));
+      }
+      return notes;
+    }
+
+    public List<ChangeNotes> create(ReviewDb db, Project.NameKey project,
+        Collection<Change.Id> changeIds, Predicate<ChangeNotes> predicate)
+            throws OrmException {
+      List<ChangeNotes> notes = new ArrayList<>();
+      if (args.migration.enabled()) {
+        for (Change.Id cid : changeIds) {
+          ChangeNotes cn = create(db, project, cid);
+          if (cn.getChange() != null && predicate.apply(cn)) {
+            notes.add(cn);
+          }
+        }
+        return notes;
+      }
+
+      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
+        if (c != null && project.equals(c.getDest().getParentKey())) {
+          ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
+          if (predicate.apply(cn)) {
+            notes.add(cn);
+          }
+        }
+      }
+      return notes;
+    }
+
+    public ListMultimap<Project.NameKey, ChangeNotes> create(ReviewDb db,
+        Predicate<ChangeNotes> predicate) throws IOException, OrmException {
+      ListMultimap<Project.NameKey, ChangeNotes> m = ArrayListMultimap.create();
+      if (args.migration.readChanges()) {
+        for (Project.NameKey project : projectCache.all()) {
+          try (Repository repo = args.repoManager.openRepository(project)) {
+            List<ChangeNotes> changes = scanNoteDb(repo, db, project);
+            for (ChangeNotes cn : changes) {
+              if (predicate.apply(cn)) {
+                m.put(project, cn);
+              }
+            }
+          }
+        }
+      } else {
+        for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
+          ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
+          if (predicate.apply(notes)) {
+            m.put(change.getProject(), notes);
+          }
+        }
+      }
+      return ImmutableListMultimap.copyOf(m);
+    }
+
+    public List<ChangeNotes> scan(Repository repo, ReviewDb db,
+        Project.NameKey project) throws OrmException, IOException {
+      if (!args.migration.readChanges()) {
+        return scanDb(repo, db);
+      }
+
+      return scanNoteDb(repo, db, project);
+    }
+
+    private List<ChangeNotes> scanDb(Repository repo, ReviewDb db)
+        throws OrmException, IOException {
+      Set<Change.Id> ids = scan(repo);
+      List<ChangeNotes> notes = new ArrayList<>(ids.size());
+      // A batch size of N may overload get(Iterable), so use something smaller,
+      // but still >1.
+      for (List<Change.Id> batch : Iterables.partition(ids, 30)) {
+        for (Change change : ReviewDbUtil.unwrapDb(db).changes().get(batch)) {
+          notes.add(createFromChangeOnlyWhenNoteDbDisabled(change));
+        }
+      }
+      return notes;
+    }
+
+    private List<ChangeNotes> scanNoteDb(Repository repo, ReviewDb db,
+        Project.NameKey project) throws OrmException, IOException {
+      Set<Change.Id> ids = scan(repo);
+      List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
+      db = ReviewDbUtil.unwrapDb(db);
+      for (Change.Id id : ids) {
+        Change change = db.changes().get(id);
+        if (change == null) {
+          log.warn("skipping change {} found in project {} " +
+              "but not in ReviewDb",
+              id, project);
+          continue;
+        } else if (!change.getProject().equals(project)) {
+          log.error(
+              "skipping change {} found in project {} " +
+              "because ReviewDb change has project {}",
+              id, project, change.getProject());
+          continue;
+        }
+        log.debug("adding change {} found in project {}", id, project);
+        changeNotes.add(new ChangeNotes(args, change).load());
+
+      }
+      return changeNotes;
+    }
+
+    public static Set<Change.Id> scan(Repository repo) throws IOException {
+      Map<String, Ref> refs =
+          repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES);
+      Set<Change.Id> ids = new HashSet<>(refs.size());
+      for (Ref r : refs.values()) {
+        Change.Id id = Change.Id.fromRef(r.getName());
+        if (id != null) {
+          ids.add(id);
+        }
+      }
+      return ids;
     }
   }
 
-  private final Change change;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableSetMultimap<ReviewerState, Account.Id> reviewers;
-  private ImmutableList<Account.Id> allPastReviewers;
-  private ImmutableList<SubmitRecord> submitRecords;
-  private ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessages;
-  private ImmutableListMultimap<RevId, PatchLineComment> comments;
-  private ImmutableSet<String> hashtags;
-  NoteMap noteMap;
+  private final RefCache refs;
 
-  private final AllUsersName allUsers;
+  private Change change;
+  private ChangeNotesState state;
+
+  // Parsed note map state, used by ChangeUpdate to make in-place editing of
+  // notes easier.
+  RevisionNoteMap revisionNoteMap;
+
+  private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
 
   @VisibleForTesting
-  public ChangeNotes(GitRepositoryManager repoManager, NotesMigration migration,
-      AllUsersNameProvider allUsersProvider, Change change) {
-    super(repoManager, migration, change.getId());
-    this.allUsers = allUsersProvider.get();
+  public ChangeNotes(Args args, Change change) {
+    this(args, change, true, null);
+  }
+
+  private ChangeNotes(Args args, Change change, boolean autoRebuild,
+      @Nullable RefCache refs) {
+    super(args, change.getId(), autoRebuild);
     this.change = new Change(change);
+    this.refs = refs;
   }
 
   public Change getChange() {
     return change;
   }
 
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
-    return approvals;
+  public ImmutableMap<PatchSet.Id, PatchSet> getPatchSets() {
+    return state.patchSets();
   }
 
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers() {
-    return reviewers;
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+    return state.approvals();
+  }
+
+  public ReviewerSet getReviewers() {
+    return state.reviewers();
+  }
+
+  public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
+    return state.reviewerUpdates();
   }
 
   /**
@@ -152,14 +403,14 @@
    * @return a ImmutableSet of all hashtags for this change sorted in alphabetical order.
    */
   public ImmutableSet<String> getHashtags() {
-    return ImmutableSortedSet.copyOf(hashtags);
+    return ImmutableSortedSet.copyOf(state.hashtags());
   }
 
   /**
    * @return a list of all users who have ever been a reviewer on this change.
    */
   public ImmutableList<Account.Id> getAllPastReviewers() {
-    return allPastReviewers;
+    return state.allPastReviewers();
   }
 
   /**
@@ -167,23 +418,51 @@
    *     changes that were actually submitted.
    */
   public ImmutableList<SubmitRecord> getSubmitRecords() {
-    return submitRecords;
+    return state.submitRecords();
   }
 
-  /** @return change messages by patch set, in chronological order. */
-  public ImmutableListMultimap<PatchSet.Id, ChangeMessage> getChangeMessages() {
-    return changeMessages;
+  /** @return all change messages, in chronological order, oldest first. */
+  public ImmutableList<ChangeMessage> getChangeMessages() {
+    return state.allChangeMessages();
+  }
+
+  /**
+   * @return change messages by patch set, in chronological order, oldest
+   *     first.
+   */
+  public ImmutableListMultimap<PatchSet.Id, ChangeMessage>
+      getChangeMessagesByPatchSet() {
+    return state.changeMessagesByPatchSet();
   }
 
   /** @return inline comments on each revision. */
   public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
-    return comments;
+    return state.publishedComments();
   }
 
   public ImmutableListMultimap<RevId, PatchLineComment> getDraftComments(
       Account.Id author) throws OrmException {
     loadDraftComments(author);
-    return draftCommentNotes.getComments();
+    final Multimap<RevId, PatchLineComment> published =
+        state.publishedComments();
+    // Filter out any draft comments that also exist in the published map, in
+    // case the update to All-Users to delete them during the publish operation
+    // failed.
+    Multimap<RevId, PatchLineComment> filtered = Multimaps.filterEntries(
+        draftCommentNotes.getComments(),
+        new Predicate<Map.Entry<RevId, PatchLineComment>>() {
+          @Override
+          public boolean apply(Map.Entry<RevId, PatchLineComment> in) {
+            for (PatchLineComment c : published.get(in.getKey())) {
+              if (c.getKey().equals(in.getValue().getKey())) {
+                return false;
+              }
+            }
+            return true;
+          }
+        });
+    return ImmutableListMultimap.copyOf(
+        filtered);
   }
 
   /**
@@ -196,8 +475,8 @@
       throws OrmException {
     if (draftCommentNotes == null ||
         !author.equals(draftCommentNotes.getAuthor())) {
-      draftCommentNotes = new DraftCommentNotes(repoManager, migration,
-          allUsers, getChangeId(), author);
+      draftCommentNotes = new DraftCommentNotes(
+          args, change, author, autoRebuild, rebuildResult);
       draftCommentNotes.load();
     }
   }
@@ -224,76 +503,114 @@
     return false;
   }
 
-  /** @return the NoteMap */
-  NoteMap getNoteMap() {
-    return noteMap;
-  }
-
   @Override
   protected String getRefName() {
-    return ChangeNoteUtil.changeRefName(getChangeId());
+    return changeMetaRef(getChangeId());
+  }
+
+  public PatchSet getCurrentPatchSet() {
+    PatchSet.Id psId = change.currentPatchSetId();
+    return checkNotNull(state.patchSets().get(psId),
+        "missing current patch set %s", psId.get());
   }
 
   @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    ObjectId rev = getRevision();
+  protected void onLoad(LoadHandle handle)
+      throws IOException, ConfigInvalidException {
+    ObjectId rev = handle.id();
     if (rev == null) {
       loadDefaults();
       return;
     }
-    try (RevWalk walk = new RevWalk(reader);
-        ChangeNotesParser parser =
-          new ChangeNotesParser(change, rev, walk, repoManager)) {
-      parser.parseAll();
 
-      if (parser.status != null) {
-        change.setStatus(parser.status);
-      }
-      approvals = parser.buildApprovals();
-      changeMessages = parser.buildMessages();
-      comments = ImmutableListMultimap.copyOf(parser.comments);
-      noteMap = parser.commentNoteMap;
-
-      if (parser.hashtags != null) {
-        hashtags = ImmutableSet.copyOf(parser.hashtags);
-      } else {
-        hashtags = ImmutableSet.of();
-      }
-      ImmutableSetMultimap.Builder<ReviewerState, Account.Id> reviewers =
-          ImmutableSetMultimap.builder();
-      for (Map.Entry<Account.Id, ReviewerState> e
-          : parser.reviewers.entrySet()) {
-        reviewers.put(e.getValue(), e.getKey());
-      }
-      this.reviewers = reviewers.build();
-      this.allPastReviewers = ImmutableList.copyOf(parser.allPastReviewers);
-
-      submitRecords = ImmutableList.copyOf(parser.submitRecords);
-    }
+    ChangeNotesCache.Value v = args.cache.get().get(
+        getProjectName(), getChangeId(), rev, handle.walk());
+    state = v.state();
+    state.copyColumnsTo(change);
+    revisionNoteMap = v.revisionNoteMap();
   }
 
   @Override
   protected void loadDefaults() {
-    approvals = ImmutableListMultimap.of();
-    reviewers = ImmutableSetMultimap.of();
-    submitRecords = ImmutableList.of();
-    changeMessages = ImmutableListMultimap.of();
-    comments = ImmutableListMultimap.of();
-    hashtags = ImmutableSet.of();
+    state = ChangeNotesState.empty(change);
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) {
-    throw new UnsupportedOperationException(
-        getClass().getSimpleName() + " is read-only");
-  }
-
-  static Project.NameKey getProjectName(Change change) {
+  public Project.NameKey getProjectName() {
     return change.getProject();
   }
 
   @Override
-  protected Project.NameKey getProjectName() {
-    return getProjectName(getChange());
+  protected ObjectId readRef(Repository repo) throws IOException {
+    return refs != null
+        ? refs.get(getRefName()).orNull()
+        : super.readRef(repo);
+  }
+
+  @Override
+  protected LoadHandle openHandle(Repository repo) throws IOException {
+    if (autoRebuild) {
+      NoteDbChangeState state = NoteDbChangeState.parse(change);
+      ObjectId id = readRef(repo);
+      if (state == null && id == null) {
+        return super.openHandle(repo, id);
+      }
+      RefCache refs = this.refs != null ? this.refs : new RepoRefCache(repo);
+      if (!NoteDbChangeState.isChangeUpToDate(state, refs, getChangeId())) {
+        return rebuildAndOpen(repo, id);
+      }
+    }
+    return super.openHandle(repo);
+  }
+
+  private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId)
+      throws IOException {
+    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
+    try {
+      Change.Id cid = getChangeId();
+      ReviewDb db = args.db.get();
+      ChangeRebuilder rebuilder = args.rebuilder.get();
+      NoteDbUpdateManager.Result r;
+      try (NoteDbUpdateManager manager = rebuilder.stage(db, cid)) {
+        if (manager == null) {
+          return super.openHandle(repo, oldId); // May be null in tests.
+        }
+        r = manager.stageAndApplyDelta(change);
+        try {
+          rebuilder.execute(db, cid, manager);
+          repo.scanForRepoChanges();
+        } catch (OrmException | IOException e) {
+          // Rebuilding failed. Most likely cause is contention on one or more
+          // change refs; there are other types of errors that can happen during
+          // rebuilding, but generally speaking they should happen during stage(),
+          // not execute(). Assume that some other worker is going to successfully
+          // store the rebuilt state, which is deterministic given an input
+          // ChangeBundle.
+          //
+          // Parse notes from the staged result so we can return something useful
+          // to the caller instead of throwing.
+          log.debug("Rebuilding change {} failed: {}",
+              getChangeId(), e.getMessage());
+          args.metrics.autoRebuildFailureCount.increment(CHANGES);
+          rebuildResult = checkNotNull(r);
+          checkNotNull(r.newState());
+          checkNotNull(r.staged());
+          return LoadHandle.create(
+              ChangeNotesCommit.newStagedRevWalk(
+                  repo, r.staged().changeObjects()),
+              r.newState().getChangeMetaId());
+        }
+      }
+      return LoadHandle.create(
+          ChangeNotesCommit.newRevWalk(repo), r.newState().getChangeMetaId());
+    } catch (NoSuchChangeException e) {
+      return super.openHandle(repo, oldId);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    } finally {
+      log.debug("Rebuilt change {} in project {} in {} ms",
+          getChangeId(), getProjectName(),
+          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
new file mode 100644
index 0000000..a8f85a4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+import java.io.IOException;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+
+@Singleton
+public class ChangeNotesCache {
+  @VisibleForTesting
+  static final String CACHE_NAME = "change_notes";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ChangeNotesCache.class);
+        cache(CACHE_NAME,
+            Key.class,
+            ChangeNotesState.class)
+          .maximumWeight(1000);
+      }
+    };
+  }
+
+  @AutoValue
+  public abstract static class Key {
+    abstract Project.NameKey project();
+    abstract Change.Id changeId();
+    abstract ObjectId id();
+  }
+
+  @AutoValue
+  abstract static class Value {
+    abstract ChangeNotesState state();
+
+    /**
+     * The {@link RevisionNoteMap} produced while parsing this change.
+     * <p>
+     * These instances are mutable and non-threadsafe, so it is only safe to
+     * return it to the caller that actually incurred the cache miss. It is only
+     * used as an optimization; {@link ChangeNotes} is capable of lazily loading
+     * it as necessary.
+     */
+    @Nullable abstract RevisionNoteMap revisionNoteMap();
+  }
+
+  private class Loader implements Callable<ChangeNotesState> {
+    private final Key key;
+    private final ChangeNotesRevWalk rw;
+
+    private RevisionNoteMap revisionNoteMap;
+
+    private Loader(Key key, ChangeNotesRevWalk rw) {
+      this.key = key;
+      this.rw = rw;
+    }
+
+    @Override
+    public ChangeNotesState call() throws ConfigInvalidException, IOException {
+      ChangeNotesParser parser = new ChangeNotesParser(
+          key.changeId(), key.id(), rw, args.noteUtil, args.metrics);
+      ChangeNotesState result = parser.parseAll();
+      // This assignment only happens if call() was actually called, which only
+      // happens when Cache#get(K, Callable<V>) incurs a cache miss.
+      revisionNoteMap = parser.getRevisionNoteMap();
+      return result;
+    }
+  }
+
+  private final Cache<Key, ChangeNotesState> cache;
+  private final Args args;
+
+  @Inject
+  ChangeNotesCache(
+      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+      Args args) {
+    this.cache = cache;
+    this.args = args;
+  }
+
+  Value get(Project.NameKey project, Change.Id changeId,
+      ObjectId metaId, ChangeNotesRevWalk rw) throws IOException {
+    try {
+      Key key =
+          new AutoValue_ChangeNotesCache_Key(project, changeId, metaId.copy());
+      Loader loader = new Loader(key, rw);
+      ChangeNotesState s = cache.get(key, loader);
+      return new AutoValue_ChangeNotesCache_Value(s, loader.revisionNoteMap);
+    } catch (ExecutionException e) {
+      throw new IOException(String.format(
+              "Error loading %s in %s at %s",
+              RefNames.changeMetaRef(changeId), project, metaId.name()),
+          e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
new file mode 100644
index 0000000..272f3a6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.InsertedObject;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Commit implementation with some optimizations for change notes parsing.
+ * <p>
+ * <ul>
+ *   <li>Caches the result of {@link #getFooterLines()}, which is
+ *     otherwise very wasteful with allocations.</li>
+ * </ul>
+ */
+public class ChangeNotesCommit extends RevCommit {
+  public static ChangeNotesRevWalk newRevWalk(Repository repo) {
+    return new ChangeNotesRevWalk(repo);
+  }
+
+  public static ChangeNotesRevWalk newStagedRevWalk(Repository repo,
+      Iterable<InsertedObject> stagedObjs) {
+    final InMemoryInserter ins = new InMemoryInserter(repo);
+    for (InsertedObject obj : stagedObjs) {
+      ins.insert(obj);
+    }
+    return new ChangeNotesRevWalk(ins.newReader()) {
+      @Override
+      public void close() {
+        ins.close();
+        super.close();
+      }
+    };
+  }
+
+  public static class ChangeNotesRevWalk extends RevWalk {
+    private ChangeNotesRevWalk(Repository repo) {
+      super(repo);
+    }
+
+    private ChangeNotesRevWalk(ObjectReader reader) {
+      super(reader);
+    }
+
+    @Override
+    protected ChangeNotesCommit createCommit(AnyObjectId id) {
+      return new ChangeNotesCommit(id);
+    }
+
+    @Override
+    public ChangeNotesCommit next() throws MissingObjectException,
+         IncorrectObjectTypeException, IOException {
+      return (ChangeNotesCommit) super.next();
+    }
+
+    @Override
+    public void markStart(RevCommit c) throws MissingObjectException,
+        IncorrectObjectTypeException, IOException {
+      checkArgument(c instanceof ChangeNotesCommit);
+      super.markStart(c);
+    }
+
+    @Override
+    public void markUninteresting(RevCommit c) throws MissingObjectException,
+        IncorrectObjectTypeException, IOException {
+      checkArgument(c instanceof ChangeNotesCommit);
+      super.markUninteresting(c);
+    }
+
+    @Override
+    public ChangeNotesCommit lookupCommit(AnyObjectId id) {
+      return (ChangeNotesCommit) super.lookupCommit(id);
+    }
+
+    @Override
+    public ChangeNotesCommit parseCommit(AnyObjectId id)
+        throws MissingObjectException, IncorrectObjectTypeException,
+        IOException {
+      return (ChangeNotesCommit) super.parseCommit(id);
+    }
+  }
+
+  private ListMultimap<String, String> footerLines;
+
+  public ChangeNotesCommit(AnyObjectId id) {
+    super(id);
+  }
+
+  public List<String> getFooterLineValues(FooterKey key) {
+    if (footerLines == null) {
+      List<FooterLine> src = getFooterLines();
+      footerLines = ArrayListMultimap.create(src.size(), 1);
+      for (FooterLine fl : src) {
+        footerLines.put(fl.getKey().toLowerCase(), fl.getValue());
+      }
+    }
+    return footerLines.get(key.getName().toLowerCase());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 6f8cb2b..8272aaf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -14,19 +14,29 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.base.Enums;
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.Lists;
@@ -36,7 +46,9 @@
 import com.google.common.collect.Table;
 import com.google.common.collect.Tables;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -44,135 +56,398 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.RawParseUtils;
 
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.NavigableSet;
+import java.util.Objects;
 import java.util.Set;
+import java.util.TreeMap;
 
-class ChangeNotesParser implements AutoCloseable {
-  final Map<Account.Id, ReviewerState> reviewers;
-  final List<Account.Id> allPastReviewers;
-  final List<SubmitRecord> submitRecords;
-  final Multimap<RevId, PatchLineComment> comments;
-  NoteMap commentNoteMap;
-  Change.Status status;
-  Set<String> hashtags;
+class ChangeNotesParser {
+  // Sentinel RevId indicating a mutable field on a patch set was parsed, but
+  // the parser does not yet know its commit SHA-1.
+  private static final RevId PARTIAL_PATCH_SET =
+      new RevId("INVALID PARTIAL PATCH SET");
 
-  private final Change.Id changeId;
+  // Private final members initialized in the constructor.
+  private final ChangeNoteUtil noteUtil;
+  private final NoteDbMetrics metrics;
+  private final Change.Id id;
   private final ObjectId tip;
-  private final RevWalk walk;
-  private final Repository repo;
-  private final Map<PatchSet.Id,
-      Table<Account.Id, String, Optional<PatchSetApproval>>> approvals;
-  private final Multimap<PatchSet.Id, ChangeMessage> changeMessages;
+  private final ChangeNotesRevWalk walk;
 
-  ChangeNotesParser(Change change, ObjectId tip, RevWalk walk,
-      GitRepositoryManager repoManager) throws RepositoryNotFoundException,
-      IOException {
-    this.changeId = change.getId();
+  // Private final but mutable members initialized in the constructor and filled
+  // in during the parsing process.
+  private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
+  private final List<Account.Id> allPastReviewers;
+  private final List<ReviewerStatusUpdate> reviewerUpdates;
+  private final List<SubmitRecord> submitRecords;
+  private final Multimap<RevId, PatchLineComment> comments;
+  private final TreeMap<PatchSet.Id, PatchSet> patchSets;
+  private final Set<PatchSet.Id> deletedPatchSets;
+  private final Map<PatchSet.Id, PatchSetState> patchSetStates;
+  private final Map<PatchSet.Id,
+      Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>> approvals;
+  private final List<ChangeMessage> allChangeMessages;
+  private final Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
+
+  // Non-final private members filled in during the parsing process.
+  private String branch;
+  private Change.Status status;
+  private String topic;
+  private Set<String> hashtags;
+  private Timestamp createdOn;
+  private Timestamp lastUpdatedOn;
+  private Account.Id ownerId;
+  private String changeId;
+  private String subject;
+  private String originalSubject;
+  private String submissionId;
+  private String tag;
+  private PatchSet.Id currentPatchSetId;
+  private RevisionNoteMap revisionNoteMap;
+
+  ChangeNotesParser(Change.Id changeId, ObjectId tip, ChangeNotesRevWalk walk,
+      ChangeNoteUtil noteUtil, NoteDbMetrics metrics) {
+    this.id = changeId;
     this.tip = tip;
     this.walk = walk;
-    this.repo =
-        repoManager.openMetadataRepository(ChangeNotes.getProjectName(change));
-    approvals = Maps.newHashMap();
-    reviewers = Maps.newLinkedHashMap();
-    allPastReviewers = Lists.newArrayList();
+    this.noteUtil = noteUtil;
+    this.metrics = metrics;
+    approvals = new HashMap<>();
+    reviewers = HashBasedTable.create();
+    allPastReviewers = new ArrayList<>();
+    reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
-    changeMessages = LinkedListMultimap.create();
+    allChangeMessages = new ArrayList<>();
+    changeMessagesByPatchSet = LinkedListMultimap.create();
     comments = ArrayListMultimap.create();
+    patchSets = Maps.newTreeMap(ReviewDbUtil.intKeyOrdering());
+    deletedPatchSets = new HashSet<>();
+    patchSetStates = new HashMap<>();
   }
 
-  @Override
-  public void close() {
-    repo.close();
-  }
-
-  void parseAll() throws ConfigInvalidException, IOException {
+  ChangeNotesState parseAll()
+      throws ConfigInvalidException, IOException {
+    // Don't include initial parse in timer, as this might do more I/O to page
+    // in the block containing most commits. Later reads are not guaranteed to
+    // avoid I/O, but often should.
+    walk.reset();
     walk.markStart(walk.parseCommit(tip));
-    for (RevCommit commit : walk) {
-      parse(commit);
+
+    try (Timer1.Context timer = metrics.parseLatency.start(CHANGES)) {
+      ChangeNotesCommit commit;
+      while ((commit = walk.next()) != null) {
+        parse(commit);
+      }
+      parseNotes();
+      allPastReviewers.addAll(reviewers.rowKeySet());
+      pruneReviewers();
+      updatePatchSetStates();
+      checkMandatoryFooters();
     }
-    parseComments();
-    allPastReviewers.addAll(reviewers.keySet());
-    pruneReviewers();
+
+    return buildState();
   }
 
-  ImmutableListMultimap<PatchSet.Id, PatchSetApproval>
-      buildApprovals() {
+  RevisionNoteMap getRevisionNoteMap() {
+    return revisionNoteMap;
+  }
+
+  private ChangeNotesState buildState() {
+    return ChangeNotesState.create(
+        id,
+        new Change.Key(changeId),
+        createdOn,
+        lastUpdatedOn,
+        ownerId,
+        branch,
+        currentPatchSetId,
+        subject,
+        topic,
+        originalSubject,
+        submissionId,
+        status,
+
+        hashtags,
+        patchSets,
+        buildApprovals(),
+        ReviewerSet.fromTable(Tables.transpose(reviewers)),
+        allPastReviewers,
+        buildReviewerUpdates(),
+        submitRecords,
+        buildAllMessages(),
+        buildMessagesByPatchSet(),
+        comments);
+  }
+
+  private Multimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
     Multimap<PatchSet.Id, PatchSetApproval> result =
         ArrayListMultimap.create(approvals.keySet().size(), 3);
-    for (Table<?, ?, Optional<PatchSetApproval>> curr
-        : approvals.values()) {
-      for (PatchSetApproval psa : Optional.presentInstances(curr.values())) {
-        result.put(psa.getPatchSetId(), psa);
+    for (Table<?, ?, Optional<PatchSetApproval>> curr : approvals.values()) {
+      for (Optional<PatchSetApproval> psa : curr.values()) {
+        if (psa.isPresent()) {
+          result.put(psa.get().getPatchSetId(), psa.get());
+        }
       }
     }
     for (Collection<PatchSetApproval> v : result.asMap().values()) {
       Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
     }
-    return ImmutableListMultimap.copyOf(result);
+    return result;
   }
 
-  ImmutableListMultimap<PatchSet.Id, ChangeMessage> buildMessages() {
-    for (Collection<ChangeMessage> v : changeMessages.asMap().values()) {
-      Collections.sort((List<ChangeMessage>) v, ChangeNotes.MESSAGE_BY_TIME);
+  private List<ReviewerStatusUpdate> buildReviewerUpdates() {
+    List<ReviewerStatusUpdate> result = new ArrayList<>();
+    HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
+    for (ReviewerStatusUpdate u : Lists.reverse(reviewerUpdates)) {
+      if (!Objects.equals(ownerId, u.reviewer()) &&
+          lastState.get(u.reviewer()) != u.state()) {
+        result.add(u);
+        lastState.put(u.reviewer(), u.state());
+      }
     }
-    return ImmutableListMultimap.copyOf(changeMessages);
+    return result;
   }
 
-  private void parse(RevCommit commit) throws ConfigInvalidException {
+  private List<ChangeMessage> buildAllMessages() {
+    return Lists.reverse(allChangeMessages);
+  }
+
+  private Multimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
+    for (Collection<ChangeMessage> v :
+        changeMessagesByPatchSet.asMap().values()) {
+      Collections.reverse((List<ChangeMessage>) v);
+    }
+    return changeMessagesByPatchSet;
+  }
+
+  private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
+    Timestamp ts =
+        new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+
+    createdOn = ts;
+    parseTag(commit);
+
+    if (branch == null) {
+      branch = parseBranch(commit);
+    }
     if (status == null) {
       status = parseStatus(commit);
     }
+
     PatchSet.Id psId = parsePatchSetId(commit);
+    if (currentPatchSetId == null || psId.get() > currentPatchSetId.get()) {
+      currentPatchSetId = psId;
+    }
+
+    PatchSetState psState = parsePatchSetState(commit);
+    if (psState != null) {
+      if (!patchSetStates.containsKey(psId)) {
+        patchSetStates.put(psId, psState);
+      }
+      if (psState == PatchSetState.DELETED) {
+        deletedPatchSets.add(psId);
+      }
+    }
+
     Account.Id accountId = parseIdent(commit);
-    parseChangeMessage(psId, accountId, commit);
+    if (accountId != null) {
+      ownerId = accountId;
+    }
+
+    if (changeId == null) {
+      changeId = parseChangeId(commit);
+    }
+
+    String currSubject = parseSubject(commit);
+    if (currSubject != null) {
+      if (subject == null) {
+        subject = currSubject;
+      }
+      originalSubject = currSubject;
+    }
+
+    parseChangeMessage(psId, accountId, commit, ts);
+    if (topic == null) {
+      topic = parseTopic(commit);
+    }
+
     parseHashtags(commit);
 
+    if (submissionId == null) {
+      submissionId = parseSubmissionId(commit);
+    }
+
+    ObjectId currRev = parseRevision(commit);
+    if (currRev != null) {
+      parsePatchSet(psId, currRev, accountId, ts);
+    }
+    parseGroups(psId, commit);
 
     if (submitRecords.isEmpty()) {
       // Only parse the most recent set of submit records; any older ones are
       // still there, but not currently used.
-      parseSubmitRecords(commit.getFooterLines(FOOTER_SUBMITTED_WITH));
+      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
     }
 
-    for (String line : commit.getFooterLines(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, commit, line);
+    for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
+      parseApproval(psId, accountId, ts, line);
     }
 
-    for (ReviewerState state : ReviewerState.values()) {
-      for (String line : commit.getFooterLines(state.getFooterKey())) {
-        parseReviewer(state, line);
+    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+      for (String line : commit.getFooterLineValues(state.getFooterKey())) {
+        parseReviewer(ts, state, line);
       }
+      // Don't update timestamp when a reviewer was added, matching RevewDb
+      // behavior.
+    }
+
+    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
+      lastUpdatedOn = ts;
     }
   }
 
-  private void parseHashtags(RevCommit commit) throws ConfigInvalidException {
-    // Commits are parsed in reverse order and only the last set of hashtags should be used.
+  private String parseSubmissionId(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
+  }
+
+  private String parseBranch(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String branch = parseOneFooter(commit, FOOTER_BRANCH);
+    return branch != null ? RefNames.fullName(branch) : null;
+  }
+
+  private String parseChangeId(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    return parseOneFooter(commit, FOOTER_CHANGE_ID);
+  }
+
+  private String parseSubject(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    return parseOneFooter(commit, FOOTER_SUBJECT);
+  }
+
+  private String parseTopic(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    return parseOneFooter(commit, FOOTER_TOPIC);
+  }
+
+  private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
+      throws ConfigInvalidException {
+    List<String> footerLines = commit.getFooterLineValues(footerKey);
+    if (footerLines.isEmpty()) {
+      return null;
+    } else if (footerLines.size() > 1) {
+      throw expectedOneFooter(footerKey, footerLines);
+    }
+    return footerLines.get(0);
+  }
+
+  private String parseExactlyOneFooter(ChangeNotesCommit commit,
+      FooterKey footerKey) throws ConfigInvalidException {
+    String line = parseOneFooter(commit, footerKey);
+    if (line == null) {
+      throw expectedOneFooter(footerKey, Collections.<String> emptyList());
+    }
+    return line;
+  }
+
+  private ObjectId parseRevision(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String sha = parseOneFooter(commit, FOOTER_COMMIT);
+    if (sha == null) {
+      return null;
+    }
+    try {
+      return ObjectId.fromString(sha);
+    } catch (InvalidObjectIdException e) {
+      ConfigInvalidException cie = invalidFooter(FOOTER_COMMIT, sha);
+      cie.initCause(e);
+      throw cie;
+    }
+  }
+
+  private void parsePatchSet(PatchSet.Id psId, ObjectId rev,
+      Account.Id accountId, Timestamp ts) throws ConfigInvalidException {
+    if (accountId == null) {
+      throw parseException(
+          "patch set %s requires an identified user as uploader", psId.get());
+    }
+    PatchSet ps = patchSets.get(psId);
+    if (ps == null) {
+      ps = new PatchSet(psId);
+      patchSets.put(psId, ps);
+    } else if (!ps.getRevision().equals(PARTIAL_PATCH_SET)) {
+      if (deletedPatchSets.contains(psId)) {
+        // Do not update PS details as PS was deleted and this meta data is of
+        // no relevance
+        return;
+      }
+      throw new ConfigInvalidException(
+          String.format(
+              "Multiple revisions parsed for patch set %s: %s and %s",
+              psId.get(), patchSets.get(psId).getRevision(), rev.name()));
+    }
+    ps.setRevision(new RevId(rev.name()));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(ts);
+  }
+
+  private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String groupsStr = parseOneFooter(commit, FOOTER_GROUPS);
+    if (groupsStr == null) {
+      return;
+    }
+    PatchSet ps = patchSets.get(psId);
+    if (ps == null) {
+      ps = new PatchSet(psId);
+      ps.setRevision(PARTIAL_PATCH_SET);
+      patchSets.put(psId, ps);
+    } else if (!ps.getGroups().isEmpty()) {
+      return;
+    }
+    ps.setGroups(PatchSet.splitGroups(groupsStr));
+  }
+
+  private void parseHashtags(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    // Commits are parsed in reverse order and only the last set of hashtags
+    // should be used.
     if (hashtags != null) {
       return;
     }
-    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
+    List<String> hashtagsLines = commit.getFooterLineValues(FOOTER_HASHTAGS);
     if (hashtagsLines.isEmpty()) {
       return;
     } else if (hashtagsLines.size() > 1) {
@@ -184,9 +459,22 @@
     }
   }
 
-  private Change.Status parseStatus(RevCommit commit)
+  private void parseTag(ChangeNotesCommit commit)
       throws ConfigInvalidException {
-    List<String> statusLines = commit.getFooterLines(FOOTER_STATUS);
+    tag = null;
+    List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
+    if (tagLines.isEmpty()) {
+      return;
+    } else if (tagLines.size() == 1) {
+      tag = tagLines.get(0);
+    } else {
+      throw expectedOneFooter(FOOTER_TAG, tagLines);
+    }
+  }
+
+  private Change.Status parseStatus(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     if (statusLines.isEmpty()) {
       return null;
     } else if (statusLines.size() > 1) {
@@ -200,21 +488,38 @@
     return status.get();
   }
 
-  private PatchSet.Id parsePatchSetId(RevCommit commit)
+  private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit)
       throws ConfigInvalidException {
-    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
-    if (psIdLines.size() != 1) {
-      throw expectedOneFooter(FOOTER_PATCH_SET, psIdLines);
-    }
-    Integer psId = Ints.tryParse(psIdLines.get(0));
+    String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
+    int s = psIdLine.indexOf(' ');
+    String psIdStr = s < 0 ? psIdLine : psIdLine.substring(0, s);
+    Integer psId = Ints.tryParse(psIdStr);
     if (psId == null) {
-      throw invalidFooter(FOOTER_PATCH_SET, psIdLines.get(0));
+      throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     }
-    return new PatchSet.Id(changeId, psId);
+    return new PatchSet.Id(id, psId);
   }
 
-  private void parseChangeMessage(PatchSet.Id psId, Account.Id accountId,
-      RevCommit commit) {
+  private PatchSetState parsePatchSetState(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
+    int s = psIdLine.indexOf(' ');
+    if (s < 0) {
+      return null;
+    }
+    String withParens = psIdLine.substring(s + 1);
+    if (withParens.startsWith("(") && withParens.endsWith(")")) {
+      Optional<PatchSetState> state = Enums.getIfPresent(PatchSetState.class,
+          withParens.substring(1, withParens.length() - 1).toUpperCase());
+      if (state.isPresent()) {
+        return state.get();
+      }
+    }
+    throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
+  }
+
+  private void parseChangeMessage(PatchSet.Id psId,
+      Account.Id accountId, ChangeNotesCommit commit, Timestamp ts) {
     byte[] raw = commit.getRawBuffer();
     int size = raw.length;
     Charset enc = RawParseUtils.parseEncoding(raw);
@@ -241,7 +546,7 @@
 
     int ptr = size - 1;
     int changeMessageEnd = -1;
-    while(ptr > changeMessageStart) {
+    while (ptr > changeMessageStart) {
       ptr = RawParseUtils.prevLF(raw, ptr, '\r');
       if (ptr == -1) {
         break;
@@ -264,61 +569,144 @@
     ChangeMessage changeMessage = new ChangeMessage(
         new ChangeMessage.Key(psId.getParentKey(), commit.name()),
         accountId,
-        new Timestamp(commit.getCommitterIdent().getWhen().getTime()),
+        ts,
         psId);
     changeMessage.setMessage(changeMsgString);
-    changeMessages.put(psId, changeMessage);
+    changeMessage.setTag(tag);
+    changeMessagesByPatchSet.put(psId, changeMessage);
+    allChangeMessages.add(changeMessage);
   }
 
-  private void parseComments()
+  private void parseNotes()
       throws IOException, ConfigInvalidException {
-    commentNoteMap = CommentsInNotesUtil.parseCommentsFromNotes(repo,
-        ChangeNoteUtil.changeRefName(changeId), walk, changeId,
-        comments, PatchLineComment.Status.PUBLISHED);
+    ObjectReader reader = walk.getObjectReader();
+    ChangeNotesCommit tipCommit = walk.parseCommit(tip);
+    revisionNoteMap = RevisionNoteMap.parse(
+        noteUtil, id, reader, NoteMap.read(reader, tipCommit), false);
+    Map<RevId, RevisionNote> rns = revisionNoteMap.revisionNotes;
+
+    for (Map.Entry<RevId, RevisionNote> e : rns.entrySet()) {
+      for (PatchLineComment plc : e.getValue().comments) {
+        comments.put(e.getKey(), plc);
+      }
+    }
+
+    for (PatchSet ps : patchSets.values()) {
+      RevisionNote rn = rns.get(ps.getRevision());
+      if (rn != null && rn.pushCert != null) {
+        ps.setPushCertificate(rn.pushCert);
+      }
+    }
   }
 
   private void parseApproval(PatchSet.Id psId, Account.Id accountId,
-      RevCommit commit, String line) throws ConfigInvalidException {
-    Table<Account.Id, String, Optional<PatchSetApproval>> curr =
+      Timestamp ts, String line) throws ConfigInvalidException {
+    if (accountId == null) {
+      throw parseException(
+          "patch set %s requires an identified user as uploader", psId.get());
+    }
+    if (line.startsWith("-")) {
+      parseRemoveApproval(psId, accountId, line);
+    } else {
+      parseAddApproval(psId, accountId, ts, line);
+    }
+  }
+
+  private void parseAddApproval(PatchSet.Id psId, Account.Id committerId,
+      Timestamp ts, String line) throws ConfigInvalidException {
+    Account.Id accountId;
+    String labelVoteStr;
+    int s = line.indexOf(' ');
+    if (s > 0) {
+      labelVoteStr = line.substring(0, s);
+      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
+      checkFooter(ident != null, FOOTER_LABEL, line);
+      accountId = noteUtil.parseIdent(ident, id);
+    } else {
+      labelVoteStr = line;
+      accountId = committerId;
+    }
+
+    LabelVote l;
+    try {
+      l = LabelVote.parseWithEquals(labelVoteStr);
+    } catch (IllegalArgumentException e) {
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, line);
+      pe.initCause(e);
+      throw pe;
+    }
+
+    Entry<String, String> label = Maps.immutableEntry(l.label(), tag);
+    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
+        getApprovalsTableIfNoVotePresent(psId, accountId, label);
+    if (curr != null) {
+      PatchSetApproval psa = new PatchSetApproval(
+          new PatchSetApproval.Key(
+              psId,
+              accountId,
+              new LabelId(l.label())),
+          l.value(),
+          ts);
+      psa.setTag(tag);
+      curr.put(accountId, label, Optional.of(psa));
+    }
+  }
+
+  private void parseRemoveApproval(PatchSet.Id psId, Account.Id committerId,
+      String line) throws ConfigInvalidException {
+    Account.Id accountId;
+    Entry<String, String> label;
+    int s = line.indexOf(' ');
+    if (s > 0) {
+      label = Maps.immutableEntry(line.substring(1, s), tag);
+      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
+      checkFooter(ident != null, FOOTER_LABEL, line);
+      accountId = noteUtil.parseIdent(ident, id);
+    } else {
+      label = Maps.immutableEntry(line.substring(1), tag);
+      accountId = committerId;
+    }
+
+    try {
+      LabelType.checkNameInternal(label.getKey());
+    } catch (IllegalArgumentException e) {
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, line);
+      pe.initCause(e);
+      throw pe;
+    }
+
+    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
+        getApprovalsTableIfNoVotePresent(psId, accountId, label);
+    if (curr != null) {
+      curr.put(accountId, label, Optional.<PatchSetApproval> absent());
+    }
+  }
+
+  private Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>
+      getApprovalsTableIfNoVotePresent(PatchSet.Id psId, Account.Id accountId,
+        Entry<String, String> label) {
+
+    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
         approvals.get(psId);
-    if (curr == null) {
+    if (curr != null) {
+      if (curr.contains(accountId, label)) {
+        return null;
+      }
+    } else {
       curr = Tables.newCustomTable(
-          Maps.<Account.Id, Map<String, Optional<PatchSetApproval>>>
+          Maps.<Account.Id, Map<Entry<String, String>, Optional<PatchSetApproval>>>
               newHashMapWithExpectedSize(2),
-          new Supplier<Map<String, Optional<PatchSetApproval>>>() {
+          new Supplier<Map<Entry<String, String>, Optional<PatchSetApproval>>>() {
             @Override
-            public Map<String, Optional<PatchSetApproval>> get() {
-              return Maps.newLinkedHashMap();
+            public Map<Entry<String, String>, Optional<PatchSetApproval>> get() {
+              return new LinkedHashMap<>();
             }
           });
       approvals.put(psId, curr);
     }
-
-    if (line.startsWith("-")) {
-      String label = line.substring(1);
-      if (!curr.contains(accountId, label)) {
-        curr.put(accountId, label, Optional.<PatchSetApproval> absent());
-      }
-    } else {
-      LabelVote l;
-      try {
-        l = LabelVote.parseWithEquals(line);
-      } catch (IllegalArgumentException e) {
-        ConfigInvalidException pe =
-            parseException("invalid %s: %s", FOOTER_LABEL, line);
-        pe.initCause(e);
-        throw pe;
-      }
-      if (!curr.contains(accountId, l.label())) {
-        curr.put(accountId, l.label(), Optional.of(new PatchSetApproval(
-            new PatchSetApproval.Key(
-                psId,
-                accountId,
-                new LabelId(l.label())),
-            l.value(),
-            new Timestamp(commit.getCommitterIdent().getWhen().getTime()))));
-      }
-    }
+    return curr;
   }
 
   private void parseSubmitRecords(List<String> lines)
@@ -343,7 +731,7 @@
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
         SubmitRecord.Label label = new SubmitRecord.Label();
         if (rec.labels == null) {
-          rec.labels = Lists.newArrayList();
+          rec.labels = new ArrayList<>();
         }
         rec.labels.add(label);
 
@@ -357,7 +745,7 @@
           PersonIdent ident =
               RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
           checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
-          label.appliedBy = parseIdent(ident);
+          label.appliedBy = noteUtil.parseIdent(ident, id);
         } else {
           label.label = line.substring(c + 2);
         }
@@ -365,52 +753,131 @@
     }
   }
 
-  private Account.Id parseIdent(RevCommit commit)
+  private Account.Id parseIdent(ChangeNotesCommit commit)
       throws ConfigInvalidException {
-    return parseIdent(commit.getAuthorIdent());
-  }
-
-  private Account.Id parseIdent(PersonIdent ident)
-      throws ConfigInvalidException {
-    String email = ident.getEmailAddress();
-    int at = email.indexOf('@');
-    if (at >= 0) {
-      String host = email.substring(at + 1, email.length());
-      Integer id = Ints.tryParse(email.substring(0, at));
-      if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
-        return new Account.Id(id);
-      }
+    // Check if the author name/email is the same as the committer name/email,
+    // i.e. was the server ident at the time this commit was made.
+    PersonIdent a = commit.getAuthorIdent();
+    PersonIdent c = commit.getCommitterIdent();
+    if (a.getName().equals(c.getName())
+        && a.getEmailAddress().equals(c.getEmailAddress())) {
+      return null;
     }
-    throw parseException("invalid identity, expected <id>@%s: %s",
-      GERRIT_PLACEHOLDER_HOST, email);
+    return noteUtil.parseIdent(commit.getAuthorIdent(), id);
   }
 
-  private void parseReviewer(ReviewerState state, String line)
-      throws ConfigInvalidException {
+  private void parseReviewer(Timestamp ts, ReviewerStateInternal state,
+      String line) throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
       throw invalidFooter(state.getFooterKey(), line);
     }
-    Account.Id accountId = parseIdent(ident);
-    if (!reviewers.containsKey(accountId)) {
-      reviewers.put(accountId, state);
+    Account.Id accountId = noteUtil.parseIdent(ident, id);
+    reviewerUpdates.add(
+        ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
+    if (!reviewers.containsRow(accountId)) {
+      reviewers.put(accountId, state, ts);
     }
   }
 
   private void pruneReviewers() {
-    Iterator<Map.Entry<Account.Id, ReviewerState>> rit =
-        reviewers.entrySet().iterator();
+    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
+        reviewers.cellSet().iterator();
     while (rit.hasNext()) {
-      Map.Entry<Account.Id, ReviewerState> e = rit.next();
-      if (e.getValue() == ReviewerState.REMOVED) {
+      Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
         for (Table<Account.Id, ?, ?> curr : approvals.values()) {
-          curr.rowKeySet().remove(e.getKey());
+          curr.rowKeySet().remove(e.getRowKey());
         }
       }
     }
   }
 
+  private void updatePatchSetStates() throws ConfigInvalidException {
+    for (PatchSet ps : patchSets.values()) {
+      if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
+        throw parseException("No %s found for patch set %s",
+            FOOTER_COMMIT, ps.getPatchSetId());
+      }
+    }
+    if (patchSetStates.isEmpty()) {
+      return;
+    }
+
+    boolean deleted = false;
+    for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
+      switch (e.getValue()) {
+        case PUBLISHED:
+        default:
+          break;
+
+        case DELETED:
+          deleted = true;
+          patchSets.remove(e.getKey());
+          break;
+
+        case DRAFT:
+          PatchSet ps = patchSets.get(e.getKey());
+          if (ps != null) {
+            ps.setDraft(true);
+          }
+          break;
+      }
+    }
+    if (!deleted) {
+      return;
+    }
+
+    // Post-process other collections to remove items corresponding to deleted
+    // patch sets. This is safer than trying to prevent insertion, as it will
+    // also filter out items racily added after the patch set was deleted.
+    NavigableSet<PatchSet.Id> all = patchSets.navigableKeySet();
+    if (!all.isEmpty()) {
+      currentPatchSetId = all.last();
+    } else {
+      currentPatchSetId = null;
+    }
+    approvals.keySet().retainAll(all);
+    changeMessagesByPatchSet.keys().retainAll(all);
+
+    for (Iterator<ChangeMessage> it = allChangeMessages.iterator();
+        it.hasNext();) {
+      if (!all.contains(it.next().getPatchSetId())) {
+        it.remove();
+      }
+    }
+    for (Iterator<PatchLineComment> it = comments.values().iterator();
+        it.hasNext();) {
+      PatchSet.Id psId = it.next().getKey().getParentKey().getParentKey();
+      if (!all.contains(psId)) {
+        it.remove();
+      }
+    }
+  }
+
+  private void checkMandatoryFooters() throws ConfigInvalidException {
+    List<FooterKey> missing = new ArrayList<>();
+    if (branch == null) {
+      missing.add(FOOTER_BRANCH);
+    }
+    if (changeId == null) {
+      missing.add(FOOTER_CHANGE_ID);
+    }
+    if (originalSubject == null || subject == null) {
+      missing.add(FOOTER_SUBJECT);
+    }
+    if (!missing.isEmpty()) {
+      throw parseException("Missing footers: " + Joiner.on(", ")
+          .join(Lists.transform(missing, new Function<FooterKey, String>() {
+            @Override
+            public String apply(FooterKey input) {
+              return input.getName();
+            }
+          })));
+    }
+  }
+
   private ConfigInvalidException expectedOneFooter(FooterKey footer,
       List<String> actual) {
     return parseException("missing or multiple %s: %s",
@@ -430,6 +897,6 @@
   }
 
   private ConfigInvalidException parseException(String fmt, Object... args) {
-    return ChangeNotes.parseException(changeId, fmt, args);
+    return ChangeNotes.parseException(id, fmt, args);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
new file mode 100644
index 0000000..988184f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Immutable state associated with a change meta ref at a given commit.
+ * <p>
+ * One instance is the output of a single {@link ChangeNotesParser}, and
+ * contains types required to support public methods on {@link ChangeNotes}. It
+ * is intended to be cached in-process.
+ * <p>
+ * Note that {@link ChangeNotes} contains more than just a single {@code
+ * ChangeNoteState}, such as per-draft information, so that class is not cached
+ * directly.
+ */
+@AutoValue
+public abstract class ChangeNotesState {
+  static ChangeNotesState empty(Change change) {
+    return new AutoValue_ChangeNotesState(
+        change.getId(),
+        null,
+        ImmutableSet.<String>of(),
+        ImmutableSortedMap.<PatchSet.Id, PatchSet>of(),
+        ImmutableListMultimap.<PatchSet.Id, PatchSetApproval>of(),
+        ReviewerSet.empty(),
+        ImmutableList.<Account.Id>of(),
+        ImmutableList.<ReviewerStatusUpdate>of(),
+        ImmutableList.<SubmitRecord>of(),
+        ImmutableList.<ChangeMessage>of(),
+        ImmutableListMultimap.<PatchSet.Id, ChangeMessage>of(),
+        ImmutableListMultimap.<RevId, PatchLineComment>of());
+  }
+
+  static ChangeNotesState create(
+      Change.Id changeId,
+      Change.Key changeKey,
+      Timestamp createdOn,
+      Timestamp lastUpdatedOn,
+      Account.Id owner,
+      String branch,
+      @Nullable PatchSet.Id currentPatchSetId,
+      String subject,
+      @Nullable String topic,
+      @Nullable String originalSubject,
+      @Nullable String submissionId,
+      @Nullable Change.Status status,
+      @Nullable Set<String> hashtags,
+      Map<PatchSet.Id, PatchSet> patchSets,
+      Multimap<PatchSet.Id, PatchSetApproval> approvals,
+      ReviewerSet reviewers,
+      List<Account.Id> allPastReviewers,
+      List<ReviewerStatusUpdate> reviewerUpdates,
+      List<SubmitRecord> submitRecords,
+      List<ChangeMessage> allChangeMessages,
+      Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
+      Multimap<RevId, PatchLineComment> publishedComments) {
+    if (hashtags == null) {
+      hashtags = ImmutableSet.of();
+    }
+    return new AutoValue_ChangeNotesState(
+        changeId,
+        new AutoValue_ChangeNotesState_ChangeColumns(
+            changeKey,
+            createdOn,
+            lastUpdatedOn,
+            owner,
+            branch,
+            currentPatchSetId,
+            subject,
+            topic,
+            originalSubject,
+            submissionId,
+            status),
+        ImmutableSet.copyOf(hashtags),
+        ImmutableSortedMap.copyOf(patchSets, ReviewDbUtil.intKeyOrdering()),
+        ImmutableListMultimap.copyOf(approvals),
+        reviewers,
+        ImmutableList.copyOf(allPastReviewers),
+        ImmutableList.copyOf(reviewerUpdates),
+        ImmutableList.copyOf(submitRecords),
+        ImmutableList.copyOf(allChangeMessages),
+        ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
+        ImmutableListMultimap.copyOf(publishedComments));
+  }
+
+
+  /**
+   * Subset of Change columns that can be represented in NoteDb.
+   * <p>
+   * Notable exceptions include rowVersion and noteDbState, which are only make
+   * sense when read from NoteDb, so they cannot be cached.
+   * <p>
+   * Fields are in listed column order.
+   */
+  @AutoValue
+  abstract static class ChangeColumns {
+    abstract Change.Key changeKey();
+    abstract Timestamp createdOn();
+    abstract Timestamp lastUpdatedOn();
+    abstract Account.Id owner();
+    abstract String branch(); // Project not included.
+    @Nullable abstract PatchSet.Id currentPatchSetId();
+    abstract String subject();
+    @Nullable abstract String topic();
+    @Nullable abstract String originalSubject();
+    @Nullable abstract String submissionId();
+    // TODO(dborowitz): Use a sensible default other than null
+    @Nullable abstract Change.Status status();
+  }
+
+  abstract Change.Id changeId();
+
+  @Nullable abstract ChangeColumns columns();
+
+  // Other related to this Change.
+  abstract ImmutableSet<String> hashtags();
+  abstract ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets();
+  abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals();
+
+  abstract ReviewerSet reviewers();
+  abstract ImmutableList<Account.Id> allPastReviewers();
+  abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
+
+  abstract ImmutableList<SubmitRecord> submitRecords();
+  abstract ImmutableList<ChangeMessage> allChangeMessages();
+  abstract ImmutableListMultimap<PatchSet.Id, ChangeMessage>
+      changeMessagesByPatchSet();
+  abstract ImmutableListMultimap<RevId, PatchLineComment> publishedComments();
+
+  void copyColumnsTo(Change change) {
+    ChangeColumns c = checkNotNull(columns());
+    if (c.status() != null) {
+      change.setStatus(c.status());
+    }
+    change.setKey(c.changeKey());
+    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
+    change.setTopic(Strings.emptyToNull(c.topic()));
+    change.setCreatedOn(c.createdOn());
+    change.setLastUpdatedOn(c.lastUpdatedOn());
+    change.setOwner(c.owner());
+    change.setSubmissionId(c.submissionId());
+
+    if (!patchSets().isEmpty()) {
+      change.setCurrentPatchSet(
+          c.currentPatchSetId(), c.subject(), c.originalSubject());
+    } else {
+      // TODO(dborowitz): This should be an error, but for now it's required for
+      // some tests to pass.
+      change.clearCurrentPatchSet();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
index d715947..679b5e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,319 +14,69 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
+import com.google.gwtorm.server.SchemaFactory;
 
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Objects;
 import java.util.concurrent.Callable;
-import java.util.concurrent.TimeUnit;
 
-public class ChangeRebuilder {
-  private static final long TS_WINDOW_MS =
-      TimeUnit.MILLISECONDS.convert(1, TimeUnit.SECONDS);
+public abstract class ChangeRebuilder {
+  public static class NoPatchSetsException extends OrmException {
+    private static final long serialVersionUID = 1L;
 
-  private final Provider<ReviewDb> dbProvider;
-  private final ChangeControl.GenericFactory controlFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final PatchListCache patchListCache;
-  private final ChangeUpdate.Factory updateFactory;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-
-  @Inject
-  ChangeRebuilder(Provider<ReviewDb> dbProvider,
-      ChangeControl.GenericFactory controlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      PatchListCache patchListCache,
-      ChangeUpdate.Factory updateFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory) {
-    this.dbProvider = dbProvider;
-    this.controlFactory = controlFactory;
-    this.userFactory = userFactory;
-    this.patchListCache = patchListCache;
-    this.updateFactory = updateFactory;
-    this.draftUpdateFactory = draftUpdateFactory;
+    NoPatchSetsException(Change.Id changeId) {
+      super("Change " + changeId
+          + " cannot be rebuilt because it has no patch sets");
+    }
   }
 
-  public ListenableFuture<?> rebuildAsync(final Change change,
-      ListeningExecutorService executor, final BatchRefUpdate bru,
-      final BatchRefUpdate bruForDrafts, final Repository changeRepo,
-      final Repository allUsersRepo) {
-    return executor.submit(new Callable<Void>() {
+  private final SchemaFactory<ReviewDb> schemaFactory;
+
+  protected ChangeRebuilder(SchemaFactory<ReviewDb> schemaFactory) {
+    this.schemaFactory = schemaFactory;
+  }
+
+  public final ListenableFuture<Result> rebuildAsync(
+      final Change.Id id, ListeningExecutorService executor) {
+    return executor.submit(new Callable<Result>() {
         @Override
-      public Void call() throws Exception {
-        rebuild(change, bru, bruForDrafts, changeRepo, allUsersRepo);
-        return null;
+      public Result call() throws Exception {
+        try (ReviewDb db = schemaFactory.open()) {
+          return rebuild(db, id);
+        }
       }
     });
   }
 
-  public void rebuild(Change change, BatchRefUpdate bru,
-      BatchRefUpdate bruForDrafts, Repository changeRepo,
-      Repository allUsersRepo) throws NoSuchChangeException, IOException,
-      OrmException {
-    deleteRef(change, changeRepo);
-    ReviewDb db = dbProvider.get();
-    Change.Id changeId = change.getId();
+  public abstract Result rebuild(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException;
 
-    // We will rebuild all events, except for draft comments, in buckets based
-    // on author and timestamp. However, all draft comments for a given change
-    // and author will be written as one commit in the notedb.
-    List<Event> events = Lists.newArrayList();
-    Multimap<Account.Id, PatchLineCommentEvent> draftCommentEvents =
-        ArrayListMultimap.create();
+  public abstract Result rebuild(NoteDbUpdateManager manager,
+      ChangeBundle bundle) throws NoSuchChangeException, IOException,
+      OrmException, ConfigInvalidException;
 
-    for (PatchSet ps : db.patchSets().byChange(changeId)) {
-      events.add(new PatchSetEvent(ps));
-      for (PatchLineComment c : db.patchComments().byPatchSet(ps.getId())) {
-        PatchLineCommentEvent e =
-            new PatchLineCommentEvent(c, change, ps, patchListCache);
-        if (c.getStatus() == Status.PUBLISHED) {
-          events.add(e);
-        } else {
-          draftCommentEvents.put(c.getAuthor(), e);
-        }
-      }
-    }
+  public abstract boolean rebuildProject(ReviewDb db,
+      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project, Repository allUsersRepo)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException;
 
-    for (PatchSetApproval psa : db.patchSetApprovals().byChange(changeId)) {
-      events.add(new ApprovalEvent(psa));
-    }
+  public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException;
 
-
-    Collections.sort(events);
-    BatchMetaDataUpdate batch = null;
-    ChangeUpdate update = null;
-    for (Event e : events) {
-      if (!sameUpdate(e, update)) {
-        if (update != null) {
-          writeToBatch(batch, update, changeRepo);
-        }
-        IdentifiedUser user = userFactory.create(dbProvider, e.who);
-        update = updateFactory.create(
-            controlFactory.controlFor(change, user), e.when);
-        update.setPatchSetId(e.psId);
-        if (batch == null) {
-          batch = update.openUpdateInBatch(bru);
-        }
-      }
-      e.apply(update);
-    }
-    if (batch != null) {
-      if (update != null) {
-        writeToBatch(batch, update, changeRepo);
-      }
-
-      // Since the BatchMetaDataUpdates generated by all ChangeRebuilders on a
-      // given project are backed by the same BatchRefUpdate, we need to
-      // synchronize on the BatchRefUpdate. Therefore, since commit on a
-      // BatchMetaDataUpdate is the only method that modifies a BatchRefUpdate,
-      // we can just synchronize this call.
-      synchronized (bru) {
-        batch.commit();
-      }
-    }
-
-    for (Account.Id author : draftCommentEvents.keys()) {
-      IdentifiedUser user = userFactory.create(dbProvider, author);
-      ChangeDraftUpdate draftUpdate = null;
-      BatchMetaDataUpdate batchForDrafts = null;
-      for (PatchLineCommentEvent e : draftCommentEvents.get(author)) {
-        if (draftUpdate == null) {
-          draftUpdate = draftUpdateFactory.create(
-              controlFactory.controlFor(change, user), e.when);
-          draftUpdate.setPatchSetId(e.psId);
-          batchForDrafts = draftUpdate.openUpdateInBatch(bruForDrafts);
-        }
-        e.applyDraft(draftUpdate);
-      }
-      writeToBatch(batchForDrafts, draftUpdate, allUsersRepo);
-      synchronized(bruForDrafts) {
-        batchForDrafts.commit();
-      }
-    }
-  }
-
-  private void deleteRef(Change change, Repository changeRepo)
-      throws IOException {
-    String refName = ChangeNoteUtil.changeRefName(change.getId());
-    RefUpdate ru = changeRepo.updateRef(refName, true);
-    ru.setForceUpdate(true);
-    RefUpdate.Result result = ru.delete();
-    switch (result) {
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        break;
-      default:
-        throw new IOException(
-            String.format("Failed to delete ref %s: %s", refName, result));
-    }
-  }
-
-  private void writeToBatch(BatchMetaDataUpdate batch,
-      AbstractChangeUpdate update, Repository repo) throws IOException,
-      OrmException {
-    try (ObjectInserter inserter = repo.newObjectInserter()) {
-      update.setInserter(inserter);
-      update.writeCommit(batch);
-    }
-  }
-
-  private static long round(Date when) {
-    return when.getTime() / TS_WINDOW_MS;
-  }
-
-  private static boolean sameUpdate(Event event, ChangeUpdate update) {
-    return update != null
-        && round(event.when) == round(update.getWhen())
-        && event.who.equals(update.getUser().getAccountId())
-        && event.psId.equals(update.getPatchSetId());
-  }
-
-  private abstract static class Event implements Comparable<Event> {
-    final PatchSet.Id psId;
-    final Account.Id who;
-    final Timestamp when;
-
-    protected Event(PatchSet.Id psId, Account.Id who, Timestamp when) {
-      this.psId = psId;
-      this.who = who;
-      this.when = when;
-    }
-
-    protected void checkUpdate(AbstractChangeUpdate update) {
-      checkState(Objects.equals(update.getPatchSetId(), psId),
-          "cannot apply event for %s to update for %s",
-          update.getPatchSetId(), psId);
-      checkState(when.getTime() - update.getWhen().getTime() <= TS_WINDOW_MS,
-          "event at %s outside update window starting at %s",
-          when, update.getWhen());
-      checkState(Objects.equals(update.getUser().getAccountId(), who),
-          "cannot apply event by %s to update by %s",
-          who, update.getUser().getAccountId());
-    }
-
-    abstract void apply(ChangeUpdate update) throws OrmException;
-
-    @Override
-    public int compareTo(Event other) {
-      return ComparisonChain.start()
-          // TODO(dborowitz): Smarter bucketing: pick a bucket start time T and
-          // include all events up to T + TS_WINDOW_MS but no further.
-          // Interleaving different authors complicates things.
-          .compare(round(when), round(other.when))
-          .compare(who.get(), other.who.get())
-          .compare(psId.get(), other.psId.get())
-          .result();
-    }
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("psId", psId)
-          .add("who", who)
-          .add("when", when)
-          .toString();
-    }
-  }
-
-  private static class ApprovalEvent extends Event {
-    private PatchSetApproval psa;
-
-    ApprovalEvent(PatchSetApproval psa) {
-      super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted());
-      this.psa = psa;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      checkUpdate(update);
-      update.putApproval(psa.getLabel(), psa.getValue());
-    }
-  }
-
-  private static class PatchSetEvent extends Event {
-    private final PatchSet ps;
-
-    PatchSetEvent(PatchSet ps) {
-      super(ps.getId(), ps.getUploader(), ps.getCreatedOn());
-      this.ps = ps;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      checkUpdate(update);
-      if (ps.getPatchSetId() == 1) {
-        update.setSubject("Create change");
-      } else {
-        update.setSubject("Create patch set " + ps.getPatchSetId());
-      }
-    }
-  }
-
-  private static class PatchLineCommentEvent extends Event {
-    public final PatchLineComment c;
-    private final Change change;
-    private final PatchSet ps;
-    private final PatchListCache cache;
-
-    PatchLineCommentEvent(PatchLineComment c, Change change, PatchSet ps,
-        PatchListCache cache) {
-      super(PatchLineCommentsUtil.getCommentPsId(c), c.getAuthor(), c.getWrittenOn());
-      this.c = c;
-      this.change = change;
-      this.ps = ps;
-      this.cache = cache;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      checkUpdate(update);
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      update.insertComment(c);
-    }
-
-    void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      draftUpdate.insertComment(c);
-    }
-  }
+  public abstract Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
new file mode 100644
index 0000000..08acbad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
@@ -0,0 +1,1060 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.FormatUtil;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.git.ChainedReceiveCommands;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class ChangeRebuilderImpl extends ChangeRebuilder {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeRebuilderImpl.class);
+
+  /**
+   * The maximum amount of time between the ReviewDb timestamp of the first and
+   * last events batched together into a single NoteDb update.
+   * <p>
+   * Used to account for the fact that different records with their own
+   * timestamps (e.g. {@link PatchSetApproval} and {@link ChangeMessage})
+   * historically didn't necessarily use the same timestamp, and tended to call
+   * {@code System.currentTimeMillis()} independently.
+   */
+  static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
+
+  /**
+   * The maximum amount of time between two consecutive events to consider them
+   * to be in the same batch.
+   */
+  private static final long MAX_DELTA_MS = SECONDS.toMillis(1);
+
+  private final AccountCache accountCache;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final ChangeNoteUtil changeNoteUtil;
+  private final ChangeUpdate.Factory updateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PersonIdent serverIdent;
+  private final ProjectCache projectCache;
+  private final String anonymousCowardName;
+
+  @Inject
+  ChangeRebuilderImpl(SchemaFactory<ReviewDb> schemaFactory,
+      AccountCache accountCache,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeNoteUtil changeNoteUtil,
+      ChangeUpdate.Factory updateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration migration,
+      PatchListCache patchListCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Nullable ProjectCache projectCache,
+      @AnonymousCowardName String anonymousCowardName) {
+    super(schemaFactory);
+    this.accountCache = accountCache;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.changeNoteUtil = changeNoteUtil;
+    this.updateFactory = updateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.serverIdent = serverIdent;
+    this.projectCache = projectCache;
+    this.anonymousCowardName = anonymousCowardName;
+  }
+
+  @Override
+  public Result rebuild(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    try (NoteDbUpdateManager manager =
+        updateManagerFactory.create(change.getProject())) {
+      buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
+      return execute(db, changeId, manager);
+    }
+  }
+
+  private static class AbortUpdateException extends OrmRuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    AbortUpdateException() {
+      super("aborted");
+    }
+  }
+
+  private static class ConflictingUpdateException extends OrmRuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    ConflictingUpdateException(Change change, String expectedNoteDbState) {
+      super(String.format(
+          "Expected change %s to have noteDbState %s but was %s",
+          change.getId(), expectedNoteDbState, change.getNoteDbState()));
+    }
+  }
+
+  @Override
+  public Result rebuild(NoteDbUpdateManager manager,
+      ChangeBundle bundle) throws NoSuchChangeException, IOException,
+      OrmException, ConfigInvalidException {
+    Change change = new Change(bundle.getChange());
+    buildUpdates(manager, bundle);
+    return manager.stageAndApplyDelta(change);
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    NoteDbUpdateManager manager =
+        updateManagerFactory.create(change.getProject());
+    buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
+    manager.stage();
+    return manager;
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    final String oldNoteDbState = change.getNoteDbState();
+    Result r = manager.stageAndApplyDelta(change);
+    final String newNoteDbState = change.getNoteDbState();
+    try {
+      db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
+        @Override
+        public Change update(Change change) {
+          String currNoteDbState = change.getNoteDbState();
+          if (Objects.equals(currNoteDbState, newNoteDbState)) {
+            // Another thread completed the same rebuild we were about to.
+            throw new AbortUpdateException();
+          } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
+            // Another thread updated the state to something else.
+            throw new ConflictingUpdateException(change, oldNoteDbState);
+          }
+          change.setNoteDbState(newNoteDbState);
+          return change;
+        }
+      });
+    } catch (ConflictingUpdateException e) {
+      // Rethrow as an OrmException so the caller knows to use staged results.
+      // Strictly speaking they are not completely up to date, but result we
+      // send to the caller is the same as if this rebuild had executed before
+      // the other thread.
+      throw new OrmException(e.getMessage());
+    } catch (AbortUpdateException e) {
+      if (NoteDbChangeState.parse(changeId, newNoteDbState).isUpToDate(
+          manager.getChangeRepo().cmds.getRepoRefCache(),
+          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+        // If the state in ReviewDb matches NoteDb at this point, it means
+        // another thread successfully completed this rebuild. It's ok to not
+        // execute the update in this case, since the object referenced in the
+        // Result was flushed to the repo by whatever thread won the race.
+        return r;
+      }
+      // If the state doesn't match, that means another thread attempted this
+      // rebuild, but failed. Fall through and try to update the ref again.
+    }
+    if (migration.failChangeWrites()) {
+      // Don't even attempt to execute if read-only, it would fail anyway. But
+      // do throw an exception to the caller so they know to use the staged
+      // results instead of reading from the repo.
+      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+    }
+    manager.execute();
+    return r;
+  }
+
+  @Override
+  public boolean rebuildProject(ReviewDb db,
+      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project, Repository allUsersRepo)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException {
+    checkArgument(allChanges.containsKey(project));
+    boolean ok = true;
+    ProgressMonitor pm = new TextProgressMonitor(new PrintWriter(System.out));
+    pm.beginTask(
+        FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
+        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
+        RevWalk allUsersRw = new RevWalk(allUsersInserter.newReader())) {
+      manager.setAllUsersRepo(allUsersRepo, allUsersRw, allUsersInserter,
+          new ChainedReceiveCommands(allUsersRepo));
+      for (Change.Id changeId : allChanges.get(project)) {
+        try {
+          buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
+        } catch (NoPatchSetsException e) {
+          log.warn(e.getMessage());
+        } catch (Throwable t) {
+          log.error("Failed to rebuild change " + changeId, t);
+          ok = false;
+        }
+        pm.update(1);
+      }
+      manager.execute();
+    } finally {
+      pm.endTask();
+    }
+    return ok;
+  }
+
+  private void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    manager.setCheckExpectedState(false);
+    Change change = new Change(bundle.getChange());
+    if (bundle.getPatchSets().isEmpty()) {
+      throw new NoPatchSetsException(change.getId());
+    }
+
+    PatchSet.Id currPsId = change.currentPatchSetId();
+    // We will rebuild all events, except for draft comments, in buckets based
+    // on author and timestamp.
+    List<Event> events = new ArrayList<>();
+    Multimap<Account.Id, PatchLineCommentEvent> draftCommentEvents =
+        ArrayListMultimap.create();
+
+    events.addAll(getHashtagsEvents(change, manager));
+
+    // Delete ref only after hashtags have been read
+    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
+    deleteDraftRefs(change, manager.getAllUsersRepo());
+
+    Integer minPsNum = getMinPatchSetNum(bundle);
+    Set<PatchSet.Id> psIds =
+        Sets.newHashSetWithExpectedSize(bundle.getPatchSets().size());
+
+    for (PatchSet ps : bundle.getPatchSets()) {
+      if (ps.getId().get() > currPsId.get()) {
+        log.info(
+            "Skipping patch set {}, which is higher than current patch set {}",
+            ps.getId(), currPsId);
+        continue;
+      }
+      psIds.add(ps.getId());
+      events.add(new PatchSetEvent(
+          change, ps, manager.getChangeRepo().rw));
+      for (PatchLineComment c : getPatchLineComments(bundle, ps)) {
+        PatchLineCommentEvent e =
+            new PatchLineCommentEvent(c, change, ps, patchListCache);
+        if (c.getStatus() == Status.PUBLISHED) {
+          events.add(e);
+        } else {
+          draftCommentEvents.put(c.getAuthor(), e);
+        }
+      }
+    }
+
+    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
+      if (psIds.contains(psa.getPatchSetId())) {
+        events.add(new ApprovalEvent(psa, change.getCreatedOn()));
+      }
+    }
+
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
+        bundle.getReviewers().asTable().cellSet()) {
+      events.add(new ReviewerEvent(r, change.getCreatedOn()));
+    }
+
+    Change noteDbChange = new Change(null, null, null, null, null);
+    for (ChangeMessage msg : bundle.getChangeMessages()) {
+      if (msg.getPatchSetId() == null || psIds.contains(msg.getPatchSetId())) {
+        events.add(
+            new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
+      }
+    }
+
+    sortAndFillEvents(change, noteDbChange, events, minPsNum);
+
+    EventList<Event> el = new EventList<>();
+    for (Event e : events) {
+      if (!el.canAdd(e)) {
+        flushEventsToUpdate(manager, el, change);
+        checkState(el.canAdd(e));
+      }
+      el.add(e);
+    }
+    flushEventsToUpdate(manager, el, change);
+
+    EventList<PatchLineCommentEvent> plcel = new EventList<>();
+    for (Account.Id author : draftCommentEvents.keys()) {
+      for (PatchLineCommentEvent e :
+          EVENT_ORDER.sortedCopy(draftCommentEvents.get(author))) {
+        if (!plcel.canAdd(e)) {
+          flushEventsToDraftUpdate(manager, plcel, change);
+          checkState(plcel.canAdd(e));
+        }
+        plcel.add(e);
+      }
+      flushEventsToDraftUpdate(manager, plcel, change);
+    }
+  }
+
+  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
+    Integer minPsNum = null;
+    for (PatchSet ps : bundle.getPatchSets()) {
+      int n = ps.getId().get();
+      if (minPsNum == null || n < minPsNum) {
+        minPsNum = n;
+      }
+    }
+    return minPsNum;
+  }
+
+  private static List<PatchLineComment> getPatchLineComments(ChangeBundle bundle,
+      final PatchSet ps) {
+    return FluentIterable.from(bundle.getPatchLineComments())
+        .filter(new Predicate<PatchLineComment>() {
+          @Override
+          public boolean apply(PatchLineComment in) {
+            return in.getPatchSetId().equals(ps.getId());
+          }
+        }).toSortedList(PatchLineCommentsUtil.PLC_ORDER);
+  }
+
+  private void sortAndFillEvents(Change change, Change noteDbChange,
+      List<Event> events, Integer minPsNum) {
+    Collections.sort(events, EVENT_ORDER);
+    events.add(new FinalUpdatesEvent(change, noteDbChange));
+
+    // Ensure the first event in the list creates the change, setting the author
+    // and any required footers.
+    Event first = events.get(0);
+    if (first instanceof PatchSetEvent && change.getOwner().equals(first.who)) {
+      ((PatchSetEvent) first).createChange = true;
+    } else {
+      events.add(0, new CreateChangeEvent(change, minPsNum));
+    }
+
+    // Fill in any missing patch set IDs using the latest patch set of the
+    // change at the time of the event, because NoteDb can't represent actions
+    // with no associated patch set ID. This workaround is as if a user added a
+    // ChangeMessage on the change by replying from the latest patch set.
+    //
+    // Start with the first patch set that actually exists. If there are no
+    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
+    // patch set ID. The corresponding patch set won't exist, but this change is
+    // probably corrupt anyway, as deleting the last draft patch set should have
+    // deleted the whole change.
+    int ps = firstNonNull(minPsNum, 1);
+    for (Event e : events) {
+      if (e.psId == null) {
+        e.psId = new PatchSet.Id(change.getId(), ps);
+      } else {
+        ps = Math.max(ps, e.psId.get());
+      }
+    }
+  }
+
+  private void flushEventsToUpdate(NoteDbUpdateManager manager,
+      EventList<Event> events, Change change) throws OrmException, IOException {
+    if (events.isEmpty()) {
+      return;
+    }
+    Comparator<String> labelNameComparator;
+    if (projectCache != null) {
+      labelNameComparator = projectCache.get(change.getProject())
+          .getLabelTypes().nameComparator();
+    } else {
+      // No project cache available, bail and use natural ordering; there's no
+      // semantic difference anyway difference.
+      labelNameComparator = Ordering.natural();
+    }
+    ChangeUpdate update = updateFactory.create(
+        change,
+        events.getAccountId(),
+        events.newAuthorIdent(),
+        events.getWhen(),
+        labelNameComparator);
+    update.setAllowWriteToNewRef(true);
+    update.setPatchSetId(events.getPatchSetId());
+    update.setTag(events.getTag());
+    for (Event e : events) {
+      e.apply(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private void flushEventsToDraftUpdate(NoteDbUpdateManager manager,
+      EventList<PatchLineCommentEvent> events, Change change)
+      throws OrmException {
+    if (events.isEmpty()) {
+      return;
+    }
+    ChangeDraftUpdate update = draftUpdateFactory.create(
+        change,
+        events.getAccountId(),
+        events.newAuthorIdent(),
+        events.getWhen());
+    update.setPatchSetId(events.getPatchSetId());
+    for (PatchLineCommentEvent e : events) {
+      e.applyDraft(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private List<HashtagsEvent> getHashtagsEvents(Change change,
+      NoteDbUpdateManager manager) throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
+    if (!old.isPresent()) {
+      return Collections.emptyList();
+    }
+
+    RevWalk rw = manager.getChangeRepo().rw;
+    List<HashtagsEvent> events = new ArrayList<>();
+    rw.reset();
+    rw.markStart(rw.parseCommit(old.get()));
+    for (RevCommit commit : rw) {
+      Account.Id authorId;
+      try {
+        authorId =
+            changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
+      } catch (ConfigInvalidException e) {
+        continue; // Corrupt data, no valid hashtags in this commit.
+      }
+      PatchSet.Id psId = parsePatchSetId(change, commit);
+      Set<String> hashtags = parseHashtags(commit);
+      if (authorId == null || psId == null || hashtags == null) {
+        continue;
+      }
+
+      Timestamp commitTime =
+          new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags,
+            change.getCreatedOn()));
+    }
+    return events;
+  }
+
+  private Set<String> parseHashtags(RevCommit commit) {
+    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
+    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
+      return null;
+    }
+
+    if (hashtagsLines.get(0).isEmpty()) {
+      return ImmutableSet.of();
+    }
+    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+  }
+
+  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
+    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+    if (psIdLines.size() != 1) {
+      return null;
+    }
+    Integer psId = Ints.tryParse(psIdLines.get(0));
+    if (psId == null) {
+      return null;
+    }
+    return new PatchSet.Id(change.getId(), psId);
+  }
+
+  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds)
+      throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = cmds.get(refName);
+    if (old.isPresent()) {
+      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
+    }
+  }
+
+  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo)
+      throws IOException {
+    for (Ref r : allUsersRepo.repo.getRefDatabase()
+        .getRefs(RefNames.refsDraftCommentsPrefix(change.getId())).values()) {
+      allUsersRepo.cmds.add(
+          new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
+    }
+  }
+
+  private static final Ordering<Event> EVENT_ORDER = new Ordering<Event>() {
+    @Override
+    public int compare(Event a, Event b) {
+      return ComparisonChain.start()
+          .compare(a.when, b.when)
+          .compareTrueFirst(isPatchSet(a), isPatchSet(b))
+          .compareTrueFirst(a.predatesChange, b.predatesChange)
+          .compare(a.who, b.who, ReviewDbUtil.intKeyOrdering())
+          .compare(a.psId, b.psId, ReviewDbUtil.intKeyOrdering().nullsLast())
+          .result();
+    }
+
+    private boolean isPatchSet(Event e) {
+      return e instanceof PatchSetEvent;
+    }
+  };
+
+  private abstract static class Event {
+    // NOTE: EventList only supports direct subclasses, not an arbitrary
+    // hierarchy.
+
+    final Account.Id who;
+    final Timestamp when;
+    final String tag;
+    final boolean predatesChange;
+    PatchSet.Id psId;
+
+    protected Event(PatchSet.Id psId, Account.Id who, Timestamp when,
+        Timestamp changeCreatedOn, String tag) {
+      this.psId = psId;
+      this.who = who;
+      this.tag = tag;
+      // Truncate timestamps at the change's createdOn timestamp.
+      predatesChange = when.before(changeCreatedOn);
+      this.when = predatesChange ? changeCreatedOn : when;
+    }
+
+    protected void checkUpdate(AbstractChangeUpdate update) {
+      checkState(Objects.equals(update.getPatchSetId(), psId),
+          "cannot apply event for %s to update for %s",
+          update.getPatchSetId(), psId);
+      checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
+          "event at %s outside update window starting at %s",
+          when, update.getWhen());
+      checkState(Objects.equals(update.getNullableAccountId(), who),
+          "cannot apply event by %s to update by %s",
+          who, update.getNullableAccountId());
+    }
+
+    /**
+     * @return whether this event type must be unique per {@link ChangeUpdate},
+     *     i.e. there may be at most one of this type.
+     */
+    abstract boolean uniquePerUpdate();
+
+    abstract void apply(ChangeUpdate update) throws OrmException, IOException;
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("psId", psId)
+          .add("who", who)
+          .add("when", when)
+          .toString();
+    }
+  }
+
+  private class EventList<E extends Event> extends ArrayList<E> {
+    private static final long serialVersionUID = 1L;
+
+    private E getLast() {
+      return get(size() - 1);
+    }
+
+    private long getLastTime() {
+      return getLast().when.getTime();
+    }
+
+    private long getFirstTime() {
+      return get(0).when.getTime();
+    }
+
+    boolean canAdd(E e) {
+      if (isEmpty()) {
+        return true;
+      }
+      if (e instanceof FinalUpdatesEvent) {
+        return false; // FinalUpdatesEvent always gets its own update.
+      }
+
+      Event last = getLast();
+      if (!Objects.equals(e.who, last.who)
+          || !e.psId.equals(last.psId)
+          || !Objects.equals(e.tag, last.tag)) {
+        return false; // Different patch set, author, or tag.
+      }
+
+      long t = e.when.getTime();
+      long tFirst = getFirstTime();
+      long tLast = getLastTime();
+      checkArgument(t >= tLast,
+          "event %s is before previous event in list %s", e, last);
+      if (t - tLast > MAX_DELTA_MS || t - tFirst > MAX_WINDOW_MS) {
+        return false; // Too much time elapsed.
+      }
+
+      if (!e.uniquePerUpdate()) {
+        return true;
+      }
+      for (Event o : this) {
+        if (e.getClass() == o.getClass()) {
+          return false; // Only one event of this type allowed per update.
+        }
+      }
+
+      // TODO(dborowitz): Additional heuristics, like keeping events separate if
+      // they affect overlapping fields within a single entity.
+
+      return true;
+    }
+
+    Timestamp getWhen() {
+      return get(0).when;
+    }
+
+    PatchSet.Id getPatchSetId() {
+      PatchSet.Id id = checkNotNull(get(0).psId);
+      for (int i = 1; i < size(); i++) {
+        checkState(get(i).psId.equals(id),
+            "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
+      }
+      return id;
+    }
+
+    Account.Id getAccountId() {
+      Account.Id id = get(0).who;
+      for (int i = 1; i < size(); i++) {
+        checkState(Objects.equals(id, get(i).who),
+            "mismatched users in EventList: %s != %s", id, get(i).who);
+      }
+      return id;
+    }
+
+    PersonIdent newAuthorIdent() {
+      Account.Id id = getAccountId();
+      if (id == null) {
+        return new PersonIdent(serverIdent, getWhen());
+      }
+      return changeNoteUtil.newIdent(
+          accountCache.get(id).getAccount(), getWhen(), serverIdent,
+          anonymousCowardName);
+    }
+
+    String getTag() {
+      return getLast().tag;
+    }
+  }
+
+  private static void createChange(ChangeUpdate update, Change change) {
+    update.setSubjectForCommit("Create change");
+    update.setChangeId(change.getKey().get());
+    update.setBranch(change.getDest().get());
+    update.setSubject(change.getOriginalSubject());
+  }
+
+  private static class CreateChangeEvent extends Event {
+    private final Change change;
+
+    private static PatchSet.Id psId(Change change, Integer minPsNum) {
+      int n;
+      if (minPsNum == null) {
+        // There were no patch sets for the change at all, so something is very
+        // wrong. Bail and use 1 as the patch set.
+        n = 1;
+      } else {
+        n = minPsNum;
+      }
+      return new PatchSet.Id(change.getId(), n);
+    }
+
+    CreateChangeEvent(Change change, Integer minPsNum) {
+      super(psId(change, minPsNum), change.getOwner(), change.getCreatedOn(),
+          change.getCreatedOn(), null);
+      this.change = change;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return true;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws IOException, OrmException {
+      checkUpdate(update);
+      createChange(update, change);
+    }
+  }
+
+  private static class ApprovalEvent extends Event {
+    private PatchSetApproval psa;
+
+    ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
+      super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted(),
+          changeCreatedOn, psa.getTag());
+      this.psa = psa;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) {
+      checkUpdate(update);
+      update.putApproval(psa.getLabel(), psa.getValue());
+    }
+  }
+
+  private static class ReviewerEvent extends Event {
+    private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;
+
+    ReviewerEvent(
+        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
+        Timestamp changeCreatedOn) {
+      super(
+          // Reviewers aren't generally associated with a particular patch set
+          // (although as an implementation detail they were in ReviewDb). Just
+          // use the latest patch set at the time of the event.
+          null,
+          reviewer.getColumnKey(), reviewer.getValue(), changeCreatedOn, null);
+      this.reviewer = reviewer;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws IOException, OrmException {
+      checkUpdate(update);
+      update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
+    }
+  }
+
+  private static class PatchSetEvent extends Event {
+    private final Change change;
+    private final PatchSet ps;
+    private final RevWalk rw;
+    private boolean createChange;
+
+    PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
+      super(ps.getId(), ps.getUploader(), ps.getCreatedOn(),
+          change.getCreatedOn(), null);
+      this.change = change;
+      this.ps = ps;
+      this.rw = rw;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return true;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws IOException, OrmException {
+      checkUpdate(update);
+      if (createChange) {
+        createChange(update, change);
+      } else {
+        update.setSubject(change.getSubject());
+        update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
+      }
+      setRevision(update, ps);
+      List<String> groups = ps.getGroups();
+      if (!groups.isEmpty()) {
+        update.setGroups(ps.getGroups());
+      }
+      if (ps.isDraft()) {
+        update.setPatchSetState(PatchSetState.DRAFT);
+      }
+    }
+
+    private void setRevision(ChangeUpdate update, PatchSet ps)
+        throws IOException {
+      String rev = ps.getRevision().get();
+      String cert = ps.getPushCertificate();
+      ObjectId id;
+      try {
+        id = ObjectId.fromString(rev);
+      } catch (InvalidObjectIdException e) {
+        update.setRevisionForMissingCommit(rev, cert);
+        return;
+      }
+      try {
+        update.setCommit(rw, id, cert);
+      } catch (MissingObjectException e) {
+        update.setRevisionForMissingCommit(rev, cert);
+        return;
+      }
+    }
+  }
+
+  private static class PatchLineCommentEvent extends Event {
+    public final PatchLineComment c;
+    private final Change change;
+    private final PatchSet ps;
+    private final PatchListCache cache;
+
+    PatchLineCommentEvent(PatchLineComment c, Change change, PatchSet ps,
+        PatchListCache cache) {
+      super(PatchLineCommentsUtil.getCommentPsId(c), c.getAuthor(),
+          c.getWrittenOn(), change.getCreatedOn(), c.getTag());
+      this.c = c;
+      this.change = change;
+      this.ps = ps;
+      this.cache = cache;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws OrmException {
+      checkUpdate(update);
+      if (c.getRevId() == null) {
+        setCommentRevId(c, cache, change, ps);
+      }
+      update.putComment(c);
+    }
+
+    void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
+      if (c.getRevId() == null) {
+        setCommentRevId(c, cache, change, ps);
+      }
+      draftUpdate.putComment(c);
+    }
+  }
+
+  private static class HashtagsEvent extends Event {
+    private final Set<String> hashtags;
+
+    HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when,
+        Set<String> hashtags, Timestamp changeCreatdOn) {
+      super(psId, who, when, changeCreatdOn,
+          // Somewhat confusingly, hashtags do not use the setTag method on
+          // AbstractChangeUpdate, so pass null as the tag.
+          null);
+      this.hashtags = hashtags;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      // Since these are produced from existing commits in the old NoteDb graph,
+      // we know that there must be one per commit in the rebuilt graph.
+      return true;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws OrmException {
+      update.setHashtags(hashtags);
+    }
+  }
+
+  private static class ChangeMessageEvent extends Event {
+    private static final Pattern TOPIC_SET_REGEXP =
+        Pattern.compile("^Topic set to (.+)$");
+    private static final Pattern TOPIC_CHANGED_REGEXP =
+        Pattern.compile("^Topic changed from (.+) to (.+)$");
+    private static final Pattern TOPIC_REMOVED_REGEXP =
+        Pattern.compile("^Topic (.+) removed$");
+
+    private static final Pattern STATUS_ABANDONED_REGEXP =
+        Pattern.compile("^Abandoned(\n.*)*$");
+    private static final Pattern STATUS_RESTORED_REGEXP =
+        Pattern.compile("^Restored(\n.*)*$");
+
+    private final ChangeMessage message;
+    private final Change noteDbChange;
+
+    ChangeMessageEvent(ChangeMessage message, Change noteDbChange,
+        Timestamp changeCreatedOn) {
+      super(message.getPatchSetId(), message.getAuthor(),
+          message.getWrittenOn(), changeCreatedOn, message.getTag());
+      this.message = message;
+      this.noteDbChange = noteDbChange;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return true;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws OrmException {
+      checkUpdate(update);
+      update.setChangeMessage(message.getMessage());
+      setTopic(update);
+      setStatus(update);
+    }
+
+    private void setTopic(ChangeUpdate update) {
+      String msg = message.getMessage();
+      if (msg == null) {
+        return;
+      }
+      Matcher m = TOPIC_SET_REGEXP.matcher(msg);
+      if (m.matches()) {
+        String topic = m.group(1);
+        update.setTopic(topic);
+        noteDbChange.setTopic(topic);
+        return;
+      }
+
+      m = TOPIC_CHANGED_REGEXP.matcher(msg);
+      if (m.matches()) {
+        String topic = m.group(2);
+        update.setTopic(topic);
+        noteDbChange.setTopic(topic);
+        return;
+      }
+
+      if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
+        update.setTopic(null);
+        noteDbChange.setTopic(null);
+      }
+    }
+
+    private void setStatus(ChangeUpdate update) {
+      String msg = message.getMessage();
+      if (msg == null) {
+        return;
+      }
+      if (STATUS_ABANDONED_REGEXP.matcher(msg).matches()) {
+        update.setStatus(Change.Status.ABANDONED);
+        noteDbChange.setStatus(Change.Status.ABANDONED);
+        return;
+      }
+
+      if (STATUS_RESTORED_REGEXP.matcher(msg).matches()) {
+        update.setStatus(Change.Status.NEW);
+        noteDbChange.setStatus(Change.Status.NEW);
+      }
+    }
+  }
+
+  private static class FinalUpdatesEvent extends Event {
+    private final Change change;
+    private final Change noteDbChange;
+
+    FinalUpdatesEvent(Change change, Change noteDbChange) {
+      super(change.currentPatchSetId(), change.getOwner(),
+          change.getLastUpdatedOn(), change.getCreatedOn(), null);
+      this.change = change;
+      this.noteDbChange = noteDbChange;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return true;
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    void apply(ChangeUpdate update) throws OrmException {
+      if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
+        update.setTopic(change.getTopic());
+      }
+      if (!Objects.equals(change.getStatus(), noteDbChange.getStatus())) {
+        // TODO(dborowitz): Stamp approximate approvals at this time.
+        update.fixStatus(change.getStatus());
+      }
+      if (change.getSubmissionId() != null) {
+        update.setSubmissionId(change.getSubmissionId());
+      }
+      if (!update.isEmpty()) {
+        update.setSubjectForCommit("Final NoteDb migration updates");
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index cb35619..77b8dc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -14,51 +14,68 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.CommentsInNotesUtil.addCommentToMap;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
+import com.google.common.collect.Table;
+import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -79,325 +96,417 @@
   public interface Factory {
     ChangeUpdate create(ChangeControl ctl);
     ChangeUpdate create(ChangeControl ctl, Date when);
+    ChangeUpdate create(Change change, @Nullable Account.Id accountId,
+        PersonIdent authorIdent, Date when,
+        Comparator<String> labelNameComparator);
+
     @VisibleForTesting
     ChangeUpdate create(ChangeControl ctl, Date when,
         Comparator<String> labelNameComparator);
   }
 
   private final AccountCache accountCache;
-  private final Map<String, Optional<Short>> approvals;
-  private final Map<Account.Id, ReviewerState> reviewers;
-  private Change.Status status;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+
+  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final List<PatchLineComment> comments = new ArrayList<>();
+
+  private String commitSubject;
   private String subject;
+  private String changeId;
+  private String branch;
+  private Change.Status status;
   private List<SubmitRecord> submitRecords;
-  private final CommentsInNotesUtil commentsUtil;
-  private List<PatchLineComment> comments;
+  private String submissionId;
+  private String topic;
+  private String commit;
   private Set<String> hashtags;
   private String changeMessage;
-  private ChangeNotes notes;
+  private String tag;
+  private PatchSetState psState;
+  private Iterable<String> groups;
+  private String pushCert;
+  private boolean isAllowWriteToNewtRef;
 
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private ChangeDraftUpdate draftUpdate;
 
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      GitRepositoryManager repoManager,
       NotesMigration migration,
       AccountCache accountCache,
-      MetaDataUpdate.User updateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
-      CommentsInNotesUtil commentsUtil) {
-    this(serverIdent, anonymousCowardName, repoManager, migration, accountCache,
-        updateFactory, draftUpdateFactory,
-        projectCache, ctl, serverIdent.getWhen(), commentsUtil);
+      ChangeNoteUtil noteUtil) {
+    this(serverIdent, anonymousCowardName, migration, accountCache,
+        updateManagerFactory, draftUpdateFactory,
+        projectCache, ctl, serverIdent.getWhen(), noteUtil);
   }
 
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      GitRepositoryManager repoManager,
       NotesMigration migration,
       AccountCache accountCache,
-      MetaDataUpdate.User updateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
-      CommentsInNotesUtil commentsUtil) {
-    this(serverIdent, anonymousCowardName, repoManager, migration, accountCache,
-        updateFactory, draftUpdateFactory, ctl,
+      ChangeNoteUtil noteUtil) {
+    this(serverIdent, anonymousCowardName, migration, accountCache,
+        updateManagerFactory, draftUpdateFactory, ctl,
         when,
         projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
-        commentsUtil);
+        noteUtil);
   }
 
   private static Project.NameKey getProjectName(ChangeControl ctl) {
-    return ctl.getChange().getDest().getParentKey();
+    return ctl.getProject().getNameKey();
+  }
+
+  private static Table<String, Account.Id, Optional<Short>> approvals(
+      Comparator<String> nameComparator) {
+    return TreeBasedTable.create(nameComparator, ReviewDbUtil.intKeyOrdering());
   }
 
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      GitRepositoryManager repoManager,
       NotesMigration migration,
       AccountCache accountCache,
-      MetaDataUpdate.User updateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
-      CommentsInNotesUtil commentsUtil) {
-    super(migration, repoManager, updateFactory, ctl, serverIdent,
-        anonymousCowardName, when);
-    this.draftUpdateFactory = draftUpdateFactory;
+      ChangeNoteUtil noteUtil) {
+    super(migration, ctl, serverIdent,
+        anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
-    this.commentsUtil = commentsUtil;
-    this.approvals = Maps.newTreeMap(labelNameComparator);
-    this.reviewers = Maps.newLinkedHashMap();
-    this.comments = Lists.newArrayList();
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.approvals = approvals(labelNameComparator);
+  }
+
+  @AssistedInject
+  private ChangeUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      AccountCache accountCache,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted @Nullable Account.Id accountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when,
+      @Assisted Comparator<String> labelNameComparator) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
+        accountId, authorIdent, when);
+    this.accountCache = accountCache;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.approvals = approvals(labelNameComparator);
+  }
+
+  public ObjectId commit() throws IOException, OrmException {
+    try (NoteDbUpdateManager updateManager =
+        updateManagerFactory.create(getProjectName())) {
+      updateManager.add(this);
+      updateManager.stageAndApplyDelta(getChange());
+      updateManager.execute();
+    }
+    return getResult();
+  }
+
+  public void setChangeId(String changeId) {
+    String old = getChange().getKey().get();
+    checkArgument(old.equals(changeId),
+        "The Change-Id was already set to %s, so we cannot set this Change-Id: %s",
+        old, changeId);
+    this.changeId = changeId;
+  }
+
+  public void setBranch(String branch) {
+    this.branch = branch;
   }
 
   public void setStatus(Change.Status status) {
     checkArgument(status != Change.Status.MERGED,
-        "use submit(Iterable<PatchSetApproval>)");
+        "use merge(Iterable<SubmitRecord>)");
+    this.status = status;
+  }
+
+  public void fixStatus(Change.Status status) {
     this.status = status;
   }
 
   public void putApproval(String label, short value) {
-    approvals.put(label, Optional.of(value));
+    putApprovalFor(getAccountId(), label, value);
+  }
+
+  public void putApprovalFor(Account.Id reviewer, String label, short value) {
+    approvals.put(label, reviewer, Optional.of(value));
   }
 
   public void removeApproval(String label) {
-    approvals.put(label, Optional.<Short> absent());
+    removeApprovalFor(getAccountId(), label);
   }
 
-  public void merge(Iterable<SubmitRecord> submitRecords) {
+  public void removeApprovalFor(Account.Id reviewer, String label) {
+    approvals.put(label, reviewer, Optional.<Short> absent());
+  }
+
+  public void merge(RequestId submissionId,
+      Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
+    this.submissionId = submissionId.toStringForStorage();
     this.submitRecords = ImmutableList.copyOf(submitRecords);
     checkArgument(!this.submitRecords.isEmpty(),
         "no submit records specified at submit time");
   }
 
-  public void setSubject(String subject) {
+  @Deprecated // Only until we improve ChangeRebuilder to call merge().
+  public void setSubmissionId(String submissionId) {
+    this.submissionId = submissionId;
+  }
+
+  public void setSubjectForCommit(String commitSubject) {
+    this.commitSubject = commitSubject;
+  }
+
+  void setSubject(String subject) {
     this.subject = subject;
   }
 
+  @VisibleForTesting
+  ObjectId getCommit() {
+    return ObjectId.fromString(commit);
+  }
+
   public void setChangeMessage(String changeMessage) {
     this.changeMessage = changeMessage;
   }
 
-  public void insertComment(PatchLineComment comment) throws OrmException {
-    if (comment.getStatus() == Status.DRAFT) {
-      insertDraftComment(comment);
-    } else {
-      insertPublishedComment(comment);
-    }
+  public void setTag(String tag) {
+    this.tag = tag;
   }
 
-  public void upsertComment(PatchLineComment comment) throws OrmException {
-    if (comment.getStatus() == Status.DRAFT) {
-      upsertDraftComment(comment);
-    } else {
-      deleteDraftCommentIfPresent(comment);
-      upsertPublishedComment(comment);
-    }
-  }
-
-  public void updateComment(PatchLineComment comment) throws OrmException {
-    if (comment.getStatus() == Status.DRAFT) {
-      updateDraftComment(comment);
-    } else {
-      deleteDraftCommentIfPresent(comment);
-      updatePublishedComment(comment);
-    }
-  }
-
-  public void deleteComment(PatchLineComment comment) throws OrmException {
-    if (comment.getStatus() == Status.DRAFT) {
-      deleteDraftComment(comment);
-    } else {
-      throw new IllegalArgumentException("Cannot delete a published comment.");
-    }
-  }
-
-  private void insertPublishedComment(PatchLineComment c) throws OrmException {
+  public void putComment(PatchLineComment c) {
     verifyComment(c);
-    if (notes == null) {
-      notes = getChangeNotes().load();
-    }
-    if (migration.readChanges()) {
-      checkArgument(!notes.containsComment(c),
-          "A comment already exists with the same key as the following comment,"
-          + " so we cannot insert this comment: %s", c);
-    }
-    comments.add(c);
-  }
-
-  private void insertDraftComment(PatchLineComment c) throws OrmException {
     createDraftUpdateIfNull();
-    draftUpdate.insertComment(c);
+    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
+      draftUpdate.putComment(c);
+    } else {
+      comments.add(c);
+      // Always delete the corresponding comment from drafts. Published comments
+      // are immutable, meaning in normal operation we only hit this path when
+      // publishing a comment. It's exactly in that case that we have to delete
+      // the draft.
+      draftUpdate.deleteComment(c);
+    }
   }
 
-  private void upsertPublishedComment(PatchLineComment c) throws OrmException {
+  public void deleteComment(PatchLineComment c) {
     verifyComment(c);
-    if (notes == null) {
-      notes = getChangeNotes().load();
+    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
+      createDraftUpdateIfNull().deleteComment(c);
+    } else {
+      throw new IllegalArgumentException(
+          "Cannot delete published comment " + c);
     }
-    // This could allow callers to update a published comment if migration.write
-    // is on and migration.readComments is off because we will not be able to
-    // verify that the comment didn't already exist as a published comment
-    // since we don't have a ReviewDb.
-    if (migration.readChanges()) {
-      checkArgument(!notes.containsCommentPublished(c),
-          "Cannot update a comment that has already been published and saved");
-    }
-    comments.add(c);
   }
 
-  private void upsertDraftComment(PatchLineComment c) {
-    createDraftUpdateIfNull();
-    draftUpdate.upsertComment(c);
-  }
-
-  private void updatePublishedComment(PatchLineComment c) throws OrmException {
-    verifyComment(c);
-    if (notes == null) {
-      notes = getChangeNotes().load();
-    }
-    // See comment above in upsertPublishedComment() about potential risk with
-    // this check.
-    if (migration.readChanges()) {
-      checkArgument(!notes.containsCommentPublished(c),
-          "Cannot update a comment that has already been published and saved");
-    }
-    comments.add(c);
-  }
-
-  private void updateDraftComment(PatchLineComment c) throws OrmException {
-    createDraftUpdateIfNull();
-    draftUpdate.updateComment(c);
-  }
-
-  private void deleteDraftComment(PatchLineComment c) throws OrmException {
-    createDraftUpdateIfNull();
-    draftUpdate.deleteComment(c);
-  }
-
-  private void deleteDraftCommentIfPresent(PatchLineComment c)
-      throws OrmException {
-    createDraftUpdateIfNull();
-    draftUpdate.deleteCommentIfPresent(c);
-  }
-
-  private void createDraftUpdateIfNull() {
+  @VisibleForTesting
+  ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
-      draftUpdate = draftUpdateFactory.create(ctl, when);
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        draftUpdate =
+            draftUpdateFactory.create(notes, accountId, authorIdent, when);
+      } else {
+        draftUpdate = draftUpdateFactory.create(
+            getChange(), accountId, authorIdent, when);
+      }
     }
+    return draftUpdate;
   }
 
   private void verifyComment(PatchLineComment c) {
-    checkArgument(c.getRevId() != null);
-    checkArgument(c.getStatus() == Status.PUBLISHED,
-        "Cannot add a draft comment to a ChangeUpdate. Use a ChangeDraftUpdate"
-        + " for draft comments");
-    checkArgument(c.getAuthor().equals(getUser().getAccountId()),
+    checkArgument(c.getRevId() != null, "RevId required for comment: %s", c);
+    checkArgument(c.getAuthor().equals(getAccountId()),
         "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", getUser().getAccountId(), c);
+        + " this ChangeDraftUpdate (%s): %s", getAccountId(), c);
 
   }
 
+  public void setTopic(String topic) {
+    this.topic = Strings.nullToEmpty(topic);
+  }
+
+  public void setCommit(RevWalk rw, ObjectId id) throws IOException {
+    setCommit(rw, id, null);
+  }
+
+  public void setCommit(RevWalk rw, ObjectId id, String pushCert)
+      throws IOException {
+    RevCommit commit = rw.parseCommit(id);
+    rw.parseBody(commit);
+    this.commit = commit.name();
+    subject = commit.getShortMessage();
+    this.pushCert = pushCert;
+  }
+
+  /**
+   * Set the revision without depending on the commit being present in the
+   * repository; should only be used for converting old corrupt commits.
+   */
+  public void setRevisionForMissingCommit(String id, String pushCert) {
+    commit = id;
+    this.pushCert = pushCert;
+  }
+
   public void setHashtags(Set<String> hashtags) {
     this.hashtags = hashtags;
   }
 
-  public void putReviewer(Account.Id reviewer, ReviewerState type) {
-    checkArgument(type != ReviewerState.REMOVED, "invalid ReviewerType");
+  public Map<Account.Id, ReviewerStateInternal> getReviewers() {
+    return reviewers;
+  }
+
+  public void putReviewer(Account.Id reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
     reviewers.put(reviewer, type);
   }
 
   public void removeReviewer(Account.Id reviewer) {
-    reviewers.put(reviewer, ReviewerState.REMOVED);
+    reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
+  public void setPatchSetState(PatchSetState psState) {
+    this.psState = psState;
+  }
+
+  public void setGroups(List<String> groups) {
+    checkNotNull(groups, "groups may not be null");
+    this.groups = groups;
   }
 
   /** @return the tree id for the updated tree */
-  private ObjectId storeCommentsInNotes() throws OrmException, IOException {
-    ChangeNotes notes = ctl.getNotes().load();
-    NoteMap noteMap = notes.getNoteMap();
-    if (noteMap == null) {
-      noteMap = NoteMap.newEmptyMap();
-    }
-    if (comments.isEmpty()) {
+  private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter,
+      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
+    if (comments.isEmpty() && pushCert == null) {
       return null;
     }
+    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
 
-    Map<RevId, List<PatchLineComment>> allComments = Maps.newHashMap();
-    for (Map.Entry<RevId, Collection<PatchLineComment>> e
-        : notes.getComments().asMap().entrySet()) {
-      List<PatchLineComment> comments = new ArrayList<>();
-      for (PatchLineComment c : e.getValue()) {
-        comments.add(c);
-      }
-      allComments.put(e.getKey(), comments);
-    }
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (PatchLineComment c : comments) {
-      addCommentToMap(allComments, c);
+      c.setTag(tag);
+      cache.get(c.getRevId()).putComment(c);
     }
-    commentsUtil.writeCommentsToNoteMap(noteMap, allComments, inserter);
-    return noteMap.writeTree(inserter);
+    if (pushCert != null) {
+      checkState(commit != null);
+      cache.get(new RevId(commit)).setPushCertificate(pushCert);
+    }
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    checkComments(rnm.revisionNotes, builders);
+
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      ObjectId data = inserter.insert(
+          OBJ_BLOB, e.getValue().build(noteUtil));
+      rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
+    }
+
+    return rnm.noteMap.writeTree(inserter);
   }
 
-  public RevCommit commit() throws IOException {
-    BatchMetaDataUpdate batch = openUpdate();
-    try {
-      writeCommit(batch);
-      if (draftUpdate != null) {
-        draftUpdate.commit();
-      }
-      RevCommit c = batch.commit();
-      return c;
-    } catch (OrmException e) {
-      throw new IOException(e);
-    } finally {
-      batch.close();
+  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
     }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old ChangeNotes may have
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes notes = getNotes();
+      if (notes != null && notes.revisionNoteMap != null) {
+        ObjectId idFromNotes =
+            firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
+        if (idFromNotes.equals(curr)) {
+          return notes.revisionNoteMap;
+        }
+      }
+    }
+    NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parse(
+        noteUtil, getId(), rw.getObjectReader(), noteMap, false);
   }
 
-  @Override
-  public void writeCommit(BatchMetaDataUpdate batch) throws OrmException,
-      IOException {
-    CommitBuilder builder = new CommitBuilder();
-    if (migration.writeChanges()) {
-      ObjectId treeId = storeCommentsInNotes();
-      if (treeId != null) {
-        builder.setTreeId(treeId);
+  private void checkComments(Map<RevId, RevisionNote> existingNotes,
+      Map<RevId, RevisionNoteBuilder> toUpdate) throws OrmException {
+    // Prohibit various kinds of illegal operations on comments.
+    Set<PatchLineComment.Key> existing = new HashSet<>();
+    for (RevisionNote rn : existingNotes.values()) {
+      for (PatchLineComment c : rn.comments) {
+        existing.add(c.getKey());
+        if (draftUpdate != null) {
+          // Take advantage of an existing update on All-Users to prune any
+          // published comments from drafts. NoteDbUpdateManager takes care of
+          // ensuring that this update is applied before its dependent draft
+          // update.
+          //
+          // Deleting aggressively in this way, combined with filtering out
+          // duplicate published/draft comments in ChangeNotes#getDraftComments,
+          // makes up for the fact that updates between the change repo and
+          // All-Users are not atomic.
+          //
+          // TODO(dborowitz): We might want to distinguish between deleted
+          // drafts that we're fixing up after the fact by putting them in a
+          // separate commit. But note that we don't care much about the commit
+          // graph of the draft ref, particularly because the ref is completely
+          // deleted when all drafts are gone.
+          draftUpdate.deleteComment(c.getRevId(), c.getKey());
+        }
       }
     }
-    batch.write(this, builder);
+
+    for (RevisionNoteBuilder b : toUpdate.values()) {
+      for (PatchLineComment c : b.put.values()) {
+        if (existing.contains(c.getKey())) {
+          throw new OrmException(
+              "Cannot update existing published comment: " + c);
+        }
+      }
+    }
   }
 
   @Override
   protected String getRefName() {
-    return ChangeNoteUtil.changeRefName(getChange().getId());
+    return changeMetaRef(getId());
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) {
-    if (isEmpty()) {
-      return false;
-    }
-    commit.setAuthor(newIdent(getUser().getAccount(), when));
-    commit.setCommitter(new PersonIdent(serverIdent, when));
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
+      ObjectId curr) throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
 
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
     StringBuilder msg = new StringBuilder();
-    if (subject != null) {
-      msg.append(subject);
+    if (commitSubject != null) {
+      msg.append(commitSubject);
     } else {
       msg.append("Update patch set ").append(ps);
     }
@@ -408,31 +517,68 @@
       msg.append("\n\n");
     }
 
+    addPatchSetFooter(msg, ps);
 
-    addFooter(msg, FOOTER_PATCH_SET, ps);
+    if (changeId != null) {
+      addFooter(msg, FOOTER_CHANGE_ID, changeId);
+    }
+
+    if (subject != null) {
+      addFooter(msg, FOOTER_SUBJECT, subject);
+    }
+
+    if (branch != null) {
+      addFooter(msg, FOOTER_BRANCH, branch);
+    }
+
     if (status != null) {
       addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
     }
 
+    if (topic != null) {
+      addFooter(msg, FOOTER_TOPIC, topic);
+    }
+
+    if (commit != null) {
+      addFooter(msg, FOOTER_COMMIT, commit);
+    }
+
+    Joiner comma = Joiner.on(',');
     if (hashtags != null) {
-      addFooter(msg, FOOTER_HASHTAGS, Joiner.on(",").join(hashtags));
+      addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
     }
 
-    for (Map.Entry<Account.Id, ReviewerState> e : reviewers.entrySet()) {
-      Account account = accountCache.get(e.getKey()).getAccount();
-      PersonIdent ident = newIdent(account, when);
-      addFooter(msg, e.getValue().getFooterKey())
-          .append(ident.getName())
-          .append(" <").append(ident.getEmailAddress()).append(">\n");
+    if (tag != null) {
+      addFooter(msg, FOOTER_TAG, tag);
     }
 
-    for (Map.Entry<String, Optional<Short>> e : approvals.entrySet()) {
-      if (!e.getValue().isPresent()) {
-        addFooter(msg, FOOTER_LABEL, '-', e.getKey());
+    if (groups != null) {
+      addFooter(msg, FOOTER_GROUPS, comma.join(groups));
+    }
+
+    for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
+      addFooter(msg, e.getValue().getFooterKey());
+      addIdent(msg, e.getKey()).append('\n');
+    }
+
+    for (Table.Cell<String, Account.Id, Optional<Short>> c
+        : approvals.cellSet()) {
+      addFooter(msg, FOOTER_LABEL);
+      if (!c.getValue().isPresent()) {
+        msg.append('-').append(c.getRowKey());
       } else {
-        addFooter(msg, FOOTER_LABEL, LabelVote.create(
-            e.getKey(), e.getValue().get()).formatWithEquals());
+        msg.append(LabelVote.create(
+            c.getRowKey(), c.getValue().get()).formatWithEquals());
       }
+      Account.Id id = c.getColumnKey();
+      if (!id.equals(getAccountId())) {
+        addIdent(msg.append(' '), id);
+      }
+      msg.append('\n');
+    }
+
+    if (submissionId != null) {
+      addFooter(msg, FOOTER_SUBMISSION_ID, submissionId);
     }
 
     if (submitRecords != null) {
@@ -460,24 +606,62 @@
       }
     }
 
-    commit.setMessage(msg.toString());
-    return true;
+    cb.setMessage(msg.toString());
+    try {
+      ObjectId treeId = storeRevisionNotes(rw, ins, curr);
+      if (treeId != null) {
+        cb.setTreeId(treeId);
+      }
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+    return cb;
+  }
+
+  private void addPatchSetFooter(StringBuilder sb, int ps) {
+    addFooter(sb, FOOTER_PATCH_SET).append(ps);
+    if (psState != null) {
+      sb.append(" (").append(psState.name().toLowerCase()).append(')');
+    }
+    sb.append('\n');
   }
 
   @Override
   protected Project.NameKey getProjectName() {
-    return getProjectName(ctl);
+    return getChange().getProject();
   }
 
-  private boolean isEmpty() {
-    return approvals.isEmpty()
+  @Override
+  public boolean isEmpty() {
+    return commitSubject == null
+        && approvals.isEmpty()
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
+        && changeId == null
+        && branch == null
         && status == null
-        && subject == null
+        && submissionId == null
         && submitRecords == null
-        && hashtags == null;
+        && hashtags == null
+        && topic == null
+        && commit == null
+        && psState == null
+        && groups == null
+        && tag == null;
+  }
+
+  ChangeDraftUpdate getDraftUpdate() {
+    return draftUpdate;
+  }
+
+  public void setAllowWriteToNewRef(boolean allow) {
+    isAllowWriteToNewtRef = allow;
+  }
+
+  @Override
+  public boolean allowWriteToNewRef() {
+    return isAllowWriteToNewtRef;
   }
 
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
@@ -493,6 +677,17 @@
     sb.append('\n');
   }
 
+  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
+    Account account = accountCache.get(accountId).getAccount();
+    PersonIdent ident = newIdent(account, when);
+
+    PersonIdent.appendSanitized(sb, ident.getName());
+    sb.append(" <");
+    PersonIdent.appendSanitized(sb, ident.getEmailAddress());
+    sb.append('>');
+    return sb;
+  }
+
   private static String sanitizeFooter(String value) {
     return value.replace('\n', ' ').replace('\0', ' ');
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java
deleted file mode 100644
index e65f8e82..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java
+++ /dev/null
@@ -1,564 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
-import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.AccountInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.GitDateFormatter;
-import org.eclipse.jgit.util.GitDateFormatter.Format;
-import org.eclipse.jgit.util.GitDateParser;
-import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.QuotedString;
-import org.eclipse.jgit.util.RawParseUtils;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.nio.charset.Charset;
-import java.sql.Timestamp;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-
-/**
- * Utility functions to parse PatchLineComments out of a note byte array and
- * store a list of PatchLineComments in the form of a note (in a byte array).
- **/
-@Singleton
-public class CommentsInNotesUtil {
-  private static final String AUTHOR = "Author";
-  private static final String BASE_PATCH_SET = "Base-for-patch-set";
-  private static final String COMMENT_RANGE = "Comment-range";
-  private static final String FILE = "File";
-  private static final String LENGTH = "Bytes";
-  private static final String PARENT = "Parent";
-  private static final String PATCH_SET = "Patch-set";
-  private static final String REVISION = "Revision";
-  private static final String UUID = "UUID";
-  private static final int MAX_NOTE_SZ = 25 << 20;
-
-  public static NoteMap parseCommentsFromNotes(Repository repo, String refName,
-      RevWalk walk, Change.Id changeId,
-      Multimap<RevId, PatchLineComment> comments,
-      Status status)
-      throws IOException, ConfigInvalidException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref == null) {
-      return null;
-    }
-
-    ObjectReader reader = walk.getObjectReader();
-    RevCommit commit = walk.parseCommit(ref.getObjectId());
-    NoteMap noteMap = NoteMap.read(reader, commit);
-
-    for (Note note : noteMap) {
-      byte[] bytes =
-          reader.open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-      List<PatchLineComment> result = parseNote(bytes, changeId, status);
-      if (result == null || result.isEmpty()) {
-        continue;
-      }
-      comments.putAll(new RevId(note.name()), result);
-    }
-    return noteMap;
-  }
-
-  public static List<PatchLineComment> parseNote(byte[] note,
-      Change.Id changeId, Status status) throws ConfigInvalidException {
-    List<PatchLineComment> result = Lists.newArrayList();
-    int sizeOfNote = note.length;
-    Charset enc = RawParseUtils.parseEncoding(note);
-    MutableInteger curr = new MutableInteger();
-    curr.value = 0;
-
-    boolean isForBase =
-        (RawParseUtils.match(note, curr.value, PATCH_SET.getBytes(UTF_8))) < 0;
-
-    PatchSet.Id psId = parsePsId(note, curr, changeId, enc,
-        isForBase ? BASE_PATCH_SET : PATCH_SET);
-
-    RevId revId =
-        new RevId(parseStringField(note, curr, changeId, enc, REVISION));
-
-    PatchLineComment c = null;
-    while (curr.value < sizeOfNote) {
-      String previousFileName = c == null ?
-          null : c.getKey().getParentKey().getFileName();
-      c = parseComment(note, curr, previousFileName, psId, revId,
-          isForBase, enc, status);
-      result.add(c);
-    }
-    return result;
-  }
-
-  public static String formatTime(PersonIdent ident, Timestamp t) {
-    GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
-    // TODO(dborowitz): Use a ThreadLocal or use Joda.
-    PersonIdent newIdent = new PersonIdent(ident, t);
-    return dateFormatter.formatDate(newIdent);
-  }
-
-  private static PatchLineComment parseComment(byte[] note, MutableInteger curr,
-      String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
-      Charset enc, Status status)
-          throws ConfigInvalidException {
-    Change.Id changeId = psId.getParentKey();
-
-    // Check if there is a new file.
-    boolean newFile =
-        (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
-    if (newFile) {
-      // If so, parse the new file name.
-      currentFileName = parseFilename(note, curr, changeId, enc);
-    } else if (currentFileName == null) {
-      throw parseException(changeId, "could not parse %s", FILE);
-    }
-
-    CommentRange range = parseCommentRange(note, curr);
-    if (range == null) {
-      throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
-    }
-
-    Timestamp commentTime = parseTimestamp(note, curr, changeId, enc);
-    Account.Id aId = parseAuthor(note, curr, changeId, enc);
-
-    boolean hasParent =
-        (RawParseUtils.match(note, curr.value, PARENT.getBytes(enc))) != -1;
-    String parentUUID = null;
-    if (hasParent) {
-      parentUUID = parseStringField(note, curr, changeId, enc, PARENT);
-    }
-
-    String uuid = parseStringField(note, curr, changeId, enc, UUID);
-    int commentLength = parseCommentLength(note, curr, changeId, enc);
-
-    String message = RawParseUtils.decode(
-        enc, note, curr.value, curr.value + commentLength);
-    checkResult(message, "message contents", changeId);
-
-    PatchLineComment plc = new PatchLineComment(
-        new PatchLineComment.Key(new Patch.Key(psId, currentFileName), uuid),
-        range.getEndLine(), aId, parentUUID, commentTime);
-    plc.setMessage(message);
-    plc.setSide((short) (isForBase ? 0 : 1));
-    if (range.getStartCharacter() != -1) {
-      plc.setRange(range);
-    }
-    plc.setRevId(revId);
-    plc.setStatus(status);
-
-    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return plc;
-  }
-
-  private static String parseStringField(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc, String fieldName)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    checkHeaderLineFormat(note, curr, fieldName, enc, changeId);
-    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    curr.value = endOfLine;
-    return RawParseUtils.decode(enc, note, startOfField, endOfLine - 1);
-  }
-
-  /**
-   * @return a comment range. If the comment range line in the note only has
-   *    one number, we return a CommentRange with that one number as the end
-   *    line and the other fields as -1. If the comment range line in the note
-   *    contains a whole comment range, then we return a CommentRange with all
-   *    fields set. If the line is not correctly formatted, return null.
-   */
-  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
-    CommentRange range = new CommentRange(-1, -1, -1, -1);
-
-    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (startLine == 0) {
-      range.setEndLine(0);
-      ptr.value += 1;
-      return range;
-    }
-
-    if (note[ptr.value] == '\n') {
-      range.setEndLine(startLine);
-      ptr.value += 1;
-      return range;
-    } else if (note[ptr.value] == ':') {
-      range.setStartLine(startLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (startChar == 0) {
-      return null;
-    }
-    if (note[ptr.value] == '-') {
-      range.setStartCharacter(startChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (endLine == 0) {
-      return null;
-    }
-    if (note[ptr.value] == ':') {
-      range.setEndLine(endLine);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-
-    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (endChar == 0) {
-      return null;
-    }
-    if (note[ptr.value] == '\n') {
-      range.setEndCharacter(endChar);
-      ptr.value += 1;
-    } else {
-      return null;
-    }
-    return range;
-  }
-
-  private static PatchSet.Id parsePsId(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc, String fieldName)
-      throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, fieldName, enc, changeId);
-    int startOfPsId =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int patchSetId =
-        RawParseUtils.parseBase10(note, startOfPsId, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine - 1) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    checkResult(patchSetId, "patchset id", changeId);
-    curr.value = endOfLine;
-    return new PatchSet.Id(changeId, patchSetId);
-  }
-
-  private static String parseFilename(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, FILE, enc, changeId);
-    int startOfFileName =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    curr.value = endOfLine;
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return QuotedString.GIT_PATH.dequote(
-        RawParseUtils.decode(enc, note, startOfFileName, endOfLine - 1));
-  }
-
-  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc)
-      throws ConfigInvalidException {
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    Timestamp commentTime;
-    String dateString =
-        RawParseUtils.decode(enc, note, curr.value, endOfLine - 1);
-    try {
-      commentTime = new Timestamp(
-          GitDateParser.parse(dateString, null, Locale.US).getTime());
-    } catch (ParseException e) {
-      throw new ConfigInvalidException("could not parse comment timestamp", e);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentTime, "comment timestamp", changeId);
-  }
-
-  private static Account.Id parseAuthor(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, AUTHOR, enc, changeId);
-    int startOfAccountId =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    PersonIdent ident =
-        RawParseUtils.parsePersonIdent(note, startOfAccountId);
-    Account.Id aId = parseIdent(ident, changeId);
-    curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, "comment author", changeId);
-  }
-
-  private static int parseCommentLength(byte[] note, MutableInteger curr,
-      Change.Id changeId, Charset enc) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, LENGTH, enc, changeId);
-    int startOfLength =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
-    MutableInteger i = new MutableInteger();
-    int commentLength =
-        RawParseUtils.parseBase10(note, startOfLength, i);
-    int endOfLine = RawParseUtils.nextLF(note, curr.value);
-    if (i.value != endOfLine-1) {
-      throw parseException(changeId, "could not parse %s", PATCH_SET);
-    }
-    curr.value = endOfLine;
-    return checkResult(commentLength, "comment length", changeId);
-  }
-
-  private static <T> T checkResult(T o, String fieldName,
-      Change.Id changeId) throws ConfigInvalidException {
-    if (o == null) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return o;
-  }
-
-  private static int checkResult(int i, String fieldName, Change.Id changeId)
-      throws ConfigInvalidException {
-    if (i <= 0) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-    return i;
-  }
-
-  private PersonIdent newIdent(Account author, Date when) {
-    return new PersonIdent(
-        new AccountInfo(author).getName(anonymousCowardName),
-        author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
-        when, serverIdent.getTimeZone());
-  }
-
-  private static Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
-      throws ConfigInvalidException {
-    String email = ident.getEmailAddress();
-    int at = email.indexOf('@');
-    if (at >= 0) {
-      String host = email.substring(at + 1, email.length());
-      Integer id = Ints.tryParse(email.substring(0, at));
-      if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
-        return new Account.Id(id);
-      }
-    }
-    throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
-      GERRIT_PLACEHOLDER_HOST, email);
-  }
-
-  private void appendHeaderField(PrintWriter writer,
-      String field, String value) {
-    writer.print(field);
-    writer.print(": ");
-    writer.print(value);
-    writer.print('\n');
-  }
-
-  private static void checkHeaderLineFormat(byte[] note, MutableInteger curr,
-      String fieldName, Charset enc, Change.Id changeId)
-      throws ConfigInvalidException {
-    boolean correct =
-        RawParseUtils.match(note, curr.value, fieldName.getBytes(enc)) != -1;
-    correct &= (note[curr.value + fieldName.length()] == ':');
-    correct &= (note[curr.value + fieldName.length() + 1] == ' ');
-    if (!correct) {
-      throw parseException(changeId, "could not parse %s", fieldName);
-    }
-  }
-
-  private final AccountCache accountCache;
-  private final PersonIdent serverIdent;
-  private final String anonymousCowardName;
-
-  @Inject
-  public CommentsInNotesUtil(AccountCache accountCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @AnonymousCowardName String anonymousCowardName) {
-    this.accountCache = accountCache;
-    this.serverIdent = serverIdent;
-    this.anonymousCowardName = anonymousCowardName;
-  }
-
-  /**
-   * Build a note that contains the metadata for and the contents of all of the
-   * comments in the given list of comments.
-   *
-   * @param comments
-   *            A list of the comments to be written to the returned note
-   *            byte array.
-   *            All of the comments in this list must have the same side and
-   *            must share the same PatchSet.Id.
-   *            This list must not be empty because we cannot build a note
-   *            for no comments.
-   * @return the note. Null if there are no comments in the list.
-   */
-  public byte[] buildNote(List<PatchLineComment> comments) {
-    ByteArrayOutputStream buf = new ByteArrayOutputStream();
-    OutputStreamWriter streamWriter = new OutputStreamWriter(buf, UTF_8);
-    try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      PatchLineComment first = comments.get(0);
-
-      short side = first.getSide();
-      PatchSet.Id psId = PatchLineCommentsUtil.getCommentPsId(first);
-      appendHeaderField(writer, side == 0
-          ? BASE_PATCH_SET
-          : PATCH_SET,
-          Integer.toString(psId.get()));
-      appendHeaderField(writer, REVISION, first.getRevId().get());
-
-      String currentFilename = null;
-
-      for (PatchLineComment c : comments) {
-        PatchSet.Id currentPsId = PatchLineCommentsUtil.getCommentPsId(c);
-        checkArgument(psId.equals(currentPsId),
-            "All comments being added must all have the same PatchSet.Id. The"
-            + "comment below does not have the same PatchSet.Id as the others "
-            + "(%s).\n%s", psId.toString(), c.toString());
-        checkArgument(side == c.getSide(),
-            "All comments being added must all have the same side. The"
-            + "comment below does not have the same side as the others "
-            + "(%s).\n%s", side, c.toString());
-        String commentFilename =
-            QuotedString.GIT_PATH.quote(c.getKey().getParentKey().getFileName());
-
-        if (!commentFilename.equals(currentFilename)) {
-          currentFilename = commentFilename;
-          writer.print("File: ");
-          writer.print(commentFilename);
-          writer.print("\n\n");
-        }
-
-        // The CommentRange field for a comment is allowed to be null.
-        // If it is indeed null, then in the first line, we simply use the line
-        // number field for a comment instead. If it isn't null, we write the
-        // comment range itself.
-        CommentRange range = c.getRange();
-        if (range != null) {
-          writer.print(range.getStartLine());
-          writer.print(':');
-          writer.print(range.getStartCharacter());
-          writer.print('-');
-          writer.print(range.getEndLine());
-          writer.print(':');
-          writer.print(range.getEndCharacter());
-        } else {
-          writer.print(c.getLine());
-        }
-        writer.print("\n");
-
-        writer.print(formatTime(serverIdent, c.getWrittenOn()));
-        writer.print("\n");
-
-        PersonIdent ident =
-            newIdent(accountCache.get(c.getAuthor()).getAccount(),
-                c.getWrittenOn());
-        String nameString = ident.getName() + " <" + ident.getEmailAddress()
-            + ">";
-        appendHeaderField(writer, AUTHOR, nameString);
-
-        String parent = c.getParentUuid();
-        if (parent != null) {
-          appendHeaderField(writer, PARENT, parent);
-        }
-
-        appendHeaderField(writer, UUID, c.getKey().get());
-
-        byte[] messageBytes = c.getMessage().getBytes(UTF_8);
-        appendHeaderField(writer, LENGTH,
-            Integer.toString(messageBytes.length));
-
-        writer.print(c.getMessage());
-        writer.print("\n\n");
-      }
-    }
-    return buf.toByteArray();
-  }
-
-  /**
-   * Write comments for multiple revisions to a note map.
-   * <p>
-   * Mutates the map in-place. only notes for SHA-1s found as keys in the map
-   * are modified; all other notes are left untouched.
-   *
-   * @param noteMap note map to modify.
-   * @param allComments map of revision to all comments for that revision;
-   *     callers are responsible for reading the original comments and applying
-   *     any changes. Differs from a multimap in that present-but-empty values
-   *     are significant, and indicate the note for that SHA-1 should be
-   *     deleted.
-   * @param inserter object inserter for writing notes.
-   * @throws IOException if an error occurred.
-   */
-  public void writeCommentsToNoteMap(NoteMap noteMap,
-      Map<RevId, List<PatchLineComment>> allComments, ObjectInserter inserter)
-      throws IOException {
-    for (Map.Entry<RevId, List<PatchLineComment>> e : allComments.entrySet()) {
-      List<PatchLineComment> comments = e.getValue();
-      ObjectId commit = ObjectId.fromString(e.getKey().get());
-      if (comments.isEmpty()) {
-        noteMap.remove(commit);
-        continue;
-      }
-      Collections.sort(comments, PLC_ORDER);
-      // We allow comments for multiple commits to be written in the same
-      // update, even though the rest of the metadata update is associated with
-      // a single patch set.
-      noteMap.set(commit, inserter.insert(OBJ_BLOB, buildNote(comments)));
-    }
-  }
-
-  static void addCommentToMap(Map<RevId, List<PatchLineComment>> map,
-      PatchLineComment c) {
-    List<PatchLineComment> list = map.get(c.getRevId());
-    if (list == null) {
-      list = new ArrayList<>();
-      map.put(c.getRevId(), list);
-    }
-    list.add(c);
-  }
-
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
new file mode 100644
index 0000000..802359c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.NoteDbTable.ACCOUNTS;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Implement NoteDb migration stages using {@code gerrit.config}.
+ * <p>
+ * This class controls the state of the migration according to options in
+ * {@code gerrit.config}. In general, any changes to these options should only
+ * be made by adventurous administrators, who know what they're doing, on
+ * non-production data, for the purposes of testing the NoteDb implementation.
+ * Changing options quite likely requires re-running {@code RebuildNoteDb}. For
+ * these reasons, the options remain undocumented.
+ */
+@Singleton
+public class ConfigNotesMigration extends NotesMigration {
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(NotesMigration.class).to(ConfigNotesMigration.class);
+    }
+  }
+
+  private static final String NOTE_DB = "noteDb";
+  private static final String READ = "read";
+  private static final String WRITE = "write";
+  private static final String SEQUENCE = "sequence";
+
+  private static void checkConfig(Config cfg) {
+    Set<String> keys = new HashSet<>();
+    for (NoteDbTable t : NoteDbTable.values()) {
+      keys.add(t.key());
+    }
+    Set<String> allowed = ImmutableSet.of(READ, WRITE, SEQUENCE);
+    for (String t : cfg.getSubsections(NOTE_DB)) {
+      checkArgument(keys.contains(t.toLowerCase()),
+          "invalid NoteDb table: %s", t);
+      for (String key : cfg.getNames(NOTE_DB, t)) {
+        checkArgument(allowed.contains(key.toLowerCase()),
+            "invalid NoteDb key: %s.%s", t, key);
+      }
+    }
+  }
+
+  public static Config allEnabledConfig() {
+    Config cfg = new Config();
+    for (NoteDbTable t : NoteDbTable.values()) {
+      cfg.setBoolean(NOTE_DB, t.key(), WRITE, true);
+      cfg.setBoolean(NOTE_DB, t.key(), READ, true);
+    }
+    return cfg;
+  }
+
+  private final boolean writeChanges;
+  private final boolean readChanges;
+  private final boolean readChangeSequence;
+
+  private final boolean writeAccounts;
+  private final boolean readAccounts;
+
+  @Inject
+  ConfigNotesMigration(@GerritServerConfig Config cfg) {
+    checkConfig(cfg);
+
+    writeChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), WRITE, false);
+    readChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), READ, false);
+
+    // Reading change sequence numbers from NoteDb is not the default even if
+    // reading changes themselves is. Once this is enabled, it's not easy to
+    // undo: ReviewDb might hand out numbers that have already been assigned by
+    // NoteDb. This decision for the default may be reevaluated later.
+    readChangeSequence = cfg.getBoolean(NOTE_DB, CHANGES.key(), SEQUENCE, false);
+
+    writeAccounts = cfg.getBoolean(NOTE_DB, ACCOUNTS.key(), WRITE, false);
+    readAccounts = cfg.getBoolean(NOTE_DB, ACCOUNTS.key(), READ, false);
+  }
+
+  @Override
+  protected boolean writeChanges() {
+    return writeChanges;
+  }
+
+  @Override
+  public boolean readChanges() {
+    return readChanges;
+  }
+
+  @Override
+  public boolean readChangeSequence() {
+    return readChangeSequence;
+  }
+
+  @Override
+  public boolean writeAccounts() {
+    return writeAccounts;
+  }
+
+  @Override
+  public boolean readAccounts() {
+    return readAccounts;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index a02c24d..08195e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -14,70 +14,95 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.StagedResult;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.concurrent.TimeUnit;
 
 /**
  * View of the draft comments for a single {@link Change} based on the log of
  * its drafts branch.
  */
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
-  @Singleton
-  public static class Factory {
-    private final GitRepositoryManager repoManager;
-    private final NotesMigration migration;
-    private final AllUsersName draftsProject;
+  private static final Logger log =
+      LoggerFactory.getLogger(DraftCommentNotes.class);
 
-    @VisibleForTesting
-    @Inject
-    public Factory(GitRepositoryManager repoManager,
-        NotesMigration migration,
-        AllUsersNameProvider allUsers) {
-      this.repoManager = repoManager;
-      this.migration = migration;
-      this.draftsProject = allUsers.get();
-    }
-
-    public DraftCommentNotes create(Change.Id changeId, Account.Id accountId) {
-      return new DraftCommentNotes(repoManager, migration, draftsProject,
-          changeId, accountId);
-    }
+  public interface Factory {
+    DraftCommentNotes create(Change change, Account.Id accountId);
+    DraftCommentNotes createWithAutoRebuildingDisabled(
+        Change.Id changeId, Account.Id accountId);
   }
 
-  private final AllUsersName draftsProject;
+  private final Change change;
   private final Account.Id author;
+  private final NoteDbUpdateManager.Result rebuildResult;
 
   private ImmutableListMultimap<RevId, PatchLineComment> comments;
-  private NoteMap noteMap;
+  private RevisionNoteMap revisionNoteMap;
 
-  DraftCommentNotes(GitRepositoryManager repoManager, NotesMigration migration,
-      AllUsersName draftsProject, Change.Id changeId, Account.Id author) {
-    super(repoManager, migration, changeId);
-    this.draftsProject = draftsProject;
-    this.author = author;
+  @AssistedInject
+  DraftCommentNotes(
+      Args args,
+      @Assisted Change change,
+      @Assisted Account.Id author) {
+    this(args, change, author, true, null);
   }
 
-  public NoteMap getNoteMap() {
-    return noteMap;
+  @AssistedInject
+  DraftCommentNotes(
+      Args args,
+      @Assisted Change.Id changeId,
+      @Assisted Account.Id author) {
+    super(args, changeId, true);
+    this.change = null;
+    this.author = author;
+    this.rebuildResult = null;
+  }
+
+  DraftCommentNotes(
+      Args args,
+      Change change,
+      Account.Id author,
+      boolean autoRebuild,
+      NoteDbUpdateManager.Result rebuildResult) {
+    super(args, change.getId(), autoRebuild);
+    this.change = change;
+    this.author = author;
+    this.rebuildResult = rebuildResult;
+  }
+
+  RevisionNoteMap getRevisionNoteMap() {
+    return revisionNoteMap;
   }
 
   public Account.Id getAuthor() {
@@ -85,7 +110,6 @@
   }
 
   public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
-    // TODO(dborowitz): Defensive copy?
     return comments;
   }
 
@@ -100,32 +124,30 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(author, getChangeId());
+    return RefNames.refsDraftComments(getChangeId(), author);
   }
 
   @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    ObjectId rev = getRevision();
+  protected void onLoad(LoadHandle handle)
+      throws IOException, ConfigInvalidException {
+    ObjectId rev = handle.id();
     if (rev == null) {
       loadDefaults();
       return;
     }
 
-    try (RevWalk walk = new RevWalk(reader);
-        DraftCommentNotesParser parser = new DraftCommentNotesParser(
-          getChangeId(), walk, rev, repoManager, draftsProject, author)) {
-      parser.parseDraftComments();
-
-      comments = ImmutableListMultimap.copyOf(parser.comments);
-      noteMap = parser.noteMap;
+    RevCommit tipCommit = handle.walk().parseCommit(rev);
+    ObjectReader reader = handle.walk().getObjectReader();
+    revisionNoteMap = RevisionNoteMap.parse(
+        args.noteUtil, getChangeId(), reader, NoteMap.read(reader, tipCommit),
+        true);
+    Multimap<RevId, PatchLineComment> cs = ArrayListMultimap.create();
+    for (RevisionNote rn : revisionNoteMap.revisionNotes.values()) {
+      for (PatchLineComment c : rn.comments) {
+        cs.put(c.getRevId(), c);
+      }
     }
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
-    throw new UnsupportedOperationException(
-        getClass().getSimpleName() + " is read-only");
+    comments = ImmutableListMultimap.copyOf(cs);
   }
 
   @Override
@@ -134,7 +156,89 @@
   }
 
   @Override
-  protected Project.NameKey getProjectName() {
-    return draftsProject;
+  public Project.NameKey getProjectName() {
+    return args.allUsers;
+  }
+
+  @Override
+  protected LoadHandle openHandle(Repository repo) throws IOException {
+    if (rebuildResult != null) {
+      StagedResult sr = checkNotNull(rebuildResult.staged());
+      return LoadHandle.create(
+          ChangeNotesCommit.newStagedRevWalk(repo, sr.allUsersObjects()),
+          findNewId(sr.allUsersCommands(), getRefName()));
+    } else if (change != null && autoRebuild) {
+      NoteDbChangeState state = NoteDbChangeState.parse(change);
+      // Only check if this particular user's drafts are up to date, to avoid
+      // reading unnecessary refs.
+      if (!NoteDbChangeState.areDraftsUpToDate(
+          state, new RepoRefCache(repo), getChangeId(), author)) {
+        return rebuildAndOpen(repo);
+      }
+    }
+    return super.openHandle(repo);
+  }
+
+  private static ObjectId findNewId(
+      Iterable<ReceiveCommand> cmds, String refName) {
+    for (ReceiveCommand cmd : cmds) {
+      if (cmd.getRefName().equals(refName)) {
+        return cmd.getNewId();
+      }
+    }
+    return null;
+  }
+
+  private LoadHandle rebuildAndOpen(Repository repo) throws IOException {
+    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
+    try {
+      Change.Id cid = getChangeId();
+      ReviewDb db = args.db.get();
+      ChangeRebuilder rebuilder = args.rebuilder.get();
+      NoteDbUpdateManager.Result r;
+      try (NoteDbUpdateManager manager = rebuilder.stage(db, cid)) {
+        if (manager == null) {
+          return super.openHandle(repo); // May be null in tests.
+        }
+        r = manager.stageAndApplyDelta(change);
+        try {
+          rebuilder.execute(db, cid, manager);
+          repo.scanForRepoChanges();
+        } catch (OrmException | IOException e) {
+          // See ChangeNotes#rebuildAndOpen.
+          log.debug("Rebuilding change {} via drafts failed: {}",
+              getChangeId(), e.getMessage());
+          args.metrics.autoRebuildFailureCount.increment(CHANGES);
+          checkNotNull(r.staged());
+          return LoadHandle.create(
+              ChangeNotesCommit.newStagedRevWalk(
+                  repo, r.staged().allUsersObjects()),
+              draftsId(r));
+        }
+      }
+      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), draftsId(r));
+    } catch (NoSuchChangeException e) {
+      return super.openHandle(repo);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    } finally {
+      log.debug("Rebuilt change {} in {} in {} ms via drafts",
+          getChangeId(),
+          change != null
+              ? "project " + change.getProject()
+              : "unknown project",
+          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
+    }
+  }
+
+  private ObjectId draftsId(NoteDbUpdateManager.Result r) {
+    checkNotNull(r);
+    checkNotNull(r.newState());
+    return r.newState().getDraftIds().get(author);
+  }
+
+  @VisibleForTesting
+  NoteMap getNoteMap() {
+    return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotesParser.java
deleted file mode 100644
index ef8683f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotesParser.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-import java.io.IOException;
-
-class DraftCommentNotesParser implements AutoCloseable {
-  final Multimap<RevId, PatchLineComment> comments;
-  NoteMap noteMap;
-
-  private final Change.Id changeId;
-  private final ObjectId tip;
-  private final RevWalk walk;
-  private final Repository repo;
-  private final Account.Id author;
-
-  DraftCommentNotesParser(Change.Id changeId, RevWalk walk, ObjectId tip,
-      GitRepositoryManager repoManager, AllUsersName draftsProject,
-      Account.Id author) throws RepositoryNotFoundException, IOException {
-    this.changeId = changeId;
-    this.walk = walk;
-    this.tip = tip;
-    this.repo = repoManager.openMetadataRepository(draftsProject);
-    this.author = author;
-
-    comments = ArrayListMultimap.create();
-  }
-
-  @Override
-  public void close() {
-    repo.close();
-  }
-
-  void parseDraftComments() throws IOException, ConfigInvalidException {
-    walk.markStart(walk.parseCommit(tip));
-    noteMap = CommentsInNotesUtil.parseCommentsFromNotes(repo,
-        RefNames.refsDraftComments(author, changeId),
-        walk, changeId, comments, PatchLineComment.Status.DRAFT);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
new file mode 100644
index 0000000..4a7a781
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.common.base.Predicates;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.git.RefCache;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The state of all relevant NoteDb refs across all repos corresponding to a
+ * given Change entity.
+ * <p>
+ * Stored serialized in the {@code Change#noteDbState} field, and used to
+ * determine whether the state in NoteDb is out of date.
+ * <p>
+ * Serialized in the form:
+ * <pre>
+ *   [meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ * </pre>
+ * in numeric account ID order, with hex SHA-1s for human readability.
+ */
+public class NoteDbChangeState {
+  @AutoValue
+  public abstract static class Delta {
+    static Delta create(Change.Id changeId, Optional<ObjectId> newChangeMetaId,
+        Map<Account.Id, ObjectId> newDraftIds) {
+      if (newDraftIds == null) {
+        newDraftIds = ImmutableMap.of();
+      }
+      return new AutoValue_NoteDbChangeState_Delta(
+          changeId,
+          newChangeMetaId,
+          ImmutableMap.copyOf(newDraftIds));
+    }
+
+    abstract Change.Id changeId();
+    abstract Optional<ObjectId> newChangeMetaId();
+    abstract ImmutableMap<Account.Id, ObjectId> newDraftIds();
+  }
+
+  public static NoteDbChangeState parse(Change c) {
+    return parse(c.getId(), c.getNoteDbState());
+  }
+
+  @VisibleForTesting
+  static NoteDbChangeState parse(Change.Id id, String str) {
+    if (str == null) {
+      return null;
+    }
+    List<String> parts = Splitter.on(',').splitToList(str);
+    checkArgument(!parts.isEmpty(),
+        "invalid state string for change %s: %s", id, str);
+    ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
+    Map<Account.Id, ObjectId> draftIds =
+        Maps.newHashMapWithExpectedSize(parts.size() - 1);
+    Splitter s = Splitter.on('=');
+    for (int i = 1; i < parts.size(); i++) {
+      String p = parts.get(i);
+      List<String> draftParts = s.splitToList(p);
+      checkArgument(draftParts.size() == 2,
+          "invalid draft state part for change %s: %s", id, p);
+      draftIds.put(Account.Id.parse(draftParts.get(0)),
+          ObjectId.fromString(draftParts.get(1)));
+    }
+    return new NoteDbChangeState(id, changeMetaId, draftIds);
+  }
+
+  public static NoteDbChangeState applyDelta(Change change, Delta delta) {
+    if (delta == null) {
+      return null;
+    }
+    String oldStr = change.getNoteDbState();
+    if (oldStr == null && !delta.newChangeMetaId().isPresent()) {
+      // Neither an old nor a new meta ID was present, most likely because we
+      // aren't writing a NoteDb graph at all for this change at this point. No
+      // point in proceeding.
+      return null;
+    }
+    NoteDbChangeState oldState = parse(change.getId(), oldStr);
+
+    ObjectId changeMetaId;
+    if (delta.newChangeMetaId().isPresent()) {
+      changeMetaId = delta.newChangeMetaId().get();
+      if (changeMetaId.equals(ObjectId.zeroId())) {
+        change.setNoteDbState(null);
+        return null;
+      }
+    } else {
+      changeMetaId = oldState.changeMetaId;
+    }
+
+    Map<Account.Id, ObjectId> draftIds = new HashMap<>();
+    if (oldState != null) {
+      draftIds.putAll(oldState.draftIds);
+    }
+    for (Map.Entry<Account.Id, ObjectId> e : delta.newDraftIds().entrySet()) {
+      if (e.getValue().equals(ObjectId.zeroId())) {
+        draftIds.remove(e.getKey());
+      } else {
+        draftIds.put(e.getKey(), e.getValue());
+      }
+    }
+
+    NoteDbChangeState state = new NoteDbChangeState(
+        change.getId(), changeMetaId, draftIds);
+    change.setNoteDbState(state.toString());
+    return state;
+  }
+
+  public static boolean isChangeUpToDate(@Nullable NoteDbChangeState state,
+      RefCache changeRepoRefs, Change.Id changeId) throws IOException {
+    if (state == null) {
+      return !changeRepoRefs.get(changeMetaRef(changeId)).isPresent();
+    }
+    return state.isChangeUpToDate(changeRepoRefs);
+  }
+
+  public static boolean areDraftsUpToDate(@Nullable NoteDbChangeState state,
+      RefCache draftsRepoRefs, Change.Id changeId, Account.Id accountId)
+      throws IOException {
+    if (state == null) {
+      return !draftsRepoRefs.get(refsDraftComments(changeId, accountId))
+          .isPresent();
+    }
+    return state.areDraftsUpToDate(draftsRepoRefs, accountId);
+  }
+
+  public static String toString(ObjectId changeMetaId,
+      Map<Account.Id, ObjectId> draftIds) {
+    List<Account.Id> accountIds = Lists.newArrayList(draftIds.keySet());
+    Collections.sort(accountIds, ReviewDbUtil.intKeyOrdering());
+    StringBuilder sb = new StringBuilder(changeMetaId.name());
+    for (Account.Id id : accountIds) {
+      sb.append(',')
+          .append(id.get())
+          .append('=')
+          .append(draftIds.get(id).name());
+    }
+    return sb.toString();
+  }
+
+  private final Change.Id changeId;
+  private final ObjectId changeMetaId;
+  private final ImmutableMap<Account.Id, ObjectId> draftIds;
+
+  public NoteDbChangeState(Change.Id changeId, ObjectId changeMetaId,
+      Map<Account.Id, ObjectId> draftIds) {
+    this.changeId = checkNotNull(changeId);
+    this.changeMetaId = checkNotNull(changeMetaId);
+    this.draftIds = ImmutableMap.copyOf(Maps.filterValues(
+        draftIds, Predicates.not(Predicates.equalTo(ObjectId.zeroId()))));
+  }
+
+  public boolean isChangeUpToDate(RefCache changeRepoRefs) throws IOException {
+    Optional<ObjectId> id = changeRepoRefs.get(changeMetaRef(changeId));
+    if (!id.isPresent()) {
+      return changeMetaId.equals(ObjectId.zeroId());
+    }
+    return id.get().equals(changeMetaId);
+  }
+
+  public boolean areDraftsUpToDate(RefCache draftsRepoRefs, Account.Id accountId)
+      throws IOException {
+    Optional<ObjectId> id =
+        draftsRepoRefs.get(refsDraftComments(changeId, accountId));
+    if (!id.isPresent()) {
+      return !draftIds.containsKey(accountId);
+    }
+    return id.get().equals(draftIds.get(accountId));
+  }
+
+  boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs)
+      throws IOException {
+    if (!isChangeUpToDate(changeRepoRefs)) {
+      return false;
+    }
+    for (Account.Id accountId : draftIds.keySet()) {
+      if (!areDraftsUpToDate(draftsRepoRefs, accountId)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @VisibleForTesting
+  Change.Id getChangeId() {
+    return changeId;
+  }
+
+  @VisibleForTesting
+  public ObjectId getChangeMetaId() {
+    return changeMetaId;
+  }
+
+  @VisibleForTesting
+  ImmutableMap<Account.Id, ObjectId> getDraftIds() {
+    return draftIds;
+  }
+
+  @Override
+  public String toString() {
+    return toString(changeMetaId, draftIds);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
new file mode 100644
index 0000000..24e87de
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+class NoteDbMetrics {
+  /** End-to-end latency for writing a collection of updates. */
+  final Timer1<NoteDbTable> updateLatency;
+
+  /**
+   * The portion of {@link #updateLatency} due to preparing the sequence of
+   * updates.
+   * <p>
+   * May include some I/O (e.g. reading old refs), but excludes writes.
+   */
+  final Timer1<NoteDbTable> stageUpdateLatency;
+
+  /**
+   * End-to-end latency for reading changes from NoteDb, including reading
+   * ref(s) and parsing.
+   */
+  final Timer1<NoteDbTable> readLatency;
+
+  /**
+   * The portion of {@link #readLatency} due to parsing commits, but excluding
+   * I/O (to a best effort).
+   */
+  final Timer1<NoteDbTable> parseLatency;
+
+  /**
+   * Latency due to auto-rebuilding entities when out of date.
+   * <p>
+   * Excludes latency from reading ref to check whether the entity is up to
+   * date.
+   */
+  final Timer1<NoteDbTable> autoRebuildLatency;
+
+  /** Count of auto-rebuild attempts that failed. */
+  final Counter1<NoteDbTable> autoRebuildFailureCount;
+
+  @Inject
+  NoteDbMetrics(MetricMaker metrics) {
+    Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
+
+    updateLatency = metrics.newTimer(
+        "notedb/update_latency",
+        new Description("NoteDb update latency by table")
+            .setCumulative()
+            .setUnit(Units.MILLISECONDS),
+        view);
+
+    stageUpdateLatency = metrics.newTimer(
+        "notedb/stage_update_latency",
+        new Description("Latency for staging updates to NoteDb by table")
+            .setCumulative()
+            .setUnit(Units.MICROSECONDS),
+        view);
+
+    readLatency = metrics.newTimer(
+        "notedb/read_latency",
+        new Description("NoteDb read latency by table")
+            .setCumulative()
+            .setUnit(Units.MILLISECONDS),
+        view);
+
+    parseLatency = metrics.newTimer(
+        "notedb/parse_latency",
+        new Description("NoteDb parse latency by table")
+            .setCumulative()
+            .setUnit(Units.MICROSECONDS),
+        view);
+
+    autoRebuildLatency = metrics.newTimer(
+        "notedb/auto_rebuild_latency",
+        new Description("NoteDb auto-rebuilding latency by table")
+            .setCumulative()
+            .setUnit(Units.MILLISECONDS),
+        view);
+
+    autoRebuildFailureCount = metrics.newCounter(
+        "notedb/auto_rebuild_failure_count",
+        new Description("NoteDb auto-rebuilding attempts that failed by table")
+            .setCumulative(),
+        view);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index 75d926b..ff3b4b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -14,12 +14,88 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Names;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 
 public class NoteDbModule extends FactoryModule {
+  private final Config cfg;
+  private final boolean useTestBindings;
+
+  static NoteDbModule forTest(Config cfg) {
+    return new NoteDbModule(cfg, true);
+  }
+
+  public NoteDbModule(Config cfg) {
+    this(cfg, false);
+  }
+
+  private NoteDbModule(Config cfg, boolean useTestBindings) {
+    this.cfg = cfg;
+    this.useTestBindings = useTestBindings;
+  }
+
   @Override
   public void configure() {
     factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
+    factory(DraftCommentNotes.Factory.class);
+    factory(NoteDbUpdateManager.Factory.class);
+    if (!useTestBindings) {
+      install(ChangeNotesCache.module());
+      if (cfg.getBoolean("noteDb", null, "testRebuilderWrapper", false)) {
+        // Yes, another variety of test bindings with a different way of
+        // configuring it.
+        bind(ChangeRebuilder.class).to(TestChangeRebuilderWrapper.class);
+      } else {
+        bind(ChangeRebuilder.class).to(ChangeRebuilderImpl.class);
+      }
+    } else {
+      bind(ChangeRebuilder.class).toInstance(new ChangeRebuilder(null) {
+        @Override
+        public Result rebuild(ReviewDb db, Change.Id changeId) {
+          return null;
+        }
+
+        @Override
+        public Result rebuild(NoteDbUpdateManager manager,
+            ChangeBundle bundle) {
+          return null;
+        }
+
+        @Override
+        public boolean rebuildProject(ReviewDb db,
+            ImmutableMultimap<NameKey, Id> allChanges, NameKey project,
+            Repository allUsersRepo) {
+          return false;
+        }
+
+        @Override
+        public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
+          return null;
+        }
+
+        @Override
+        public Result execute(ReviewDb db, Change.Id changeId,
+            NoteDbUpdateManager manager) {
+          return null;
+        }
+      });
+      bind(new TypeLiteral<Cache<ChangeNotesCache.Key, ChangeNotesState>>() {})
+          .annotatedWith(Names.named(ChangeNotesCache.CACHE_NAME))
+          .toInstance(CacheBuilder.newBuilder()
+              .<ChangeNotesCache.Key, ChangeNotesState>build());
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
new file mode 100644
index 0000000..255998c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+enum NoteDbTable {
+  ACCOUNTS,
+  CHANGES;
+
+  String key() {
+    return name().toLowerCase();
+  }
+
+  @Override
+  public String toString() {
+    return key();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
new file mode 100644
index 0000000..cad531f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -0,0 +1,575 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.ChainedReceiveCommands;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.InsertedObject;
+import com.google.gwtorm.server.OrmConcurrencyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Object to manage a single sequence of updates to NoteDb.
+ * <p>
+ * Instances are one-time-use. Handles updating both the change repo and the
+ * All-Users repo for any affected changes, with proper ordering.
+ * <p>
+ * To see the state that would be applied prior to executing the full sequence
+ * of updates, use {@link #stage()}.
+ */
+public class NoteDbUpdateManager implements AutoCloseable {
+  public static String CHANGES_READ_ONLY = "NoteDb changes are read-only";
+
+  public interface Factory {
+    NoteDbUpdateManager create(Project.NameKey projectName);
+  }
+
+  @AutoValue
+  public abstract static class StagedResult {
+    private static StagedResult create(Change.Id id, NoteDbChangeState.Delta delta,
+        OpenRepo changeRepo, OpenRepo allUsersRepo) {
+      ImmutableList<ReceiveCommand> changeCommands = ImmutableList.of();
+      ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
+      if (changeRepo != null) {
+        changeCommands = changeRepo.getCommandsSnapshot();
+        changeObjects = changeRepo.tempIns.getInsertedObjects();
+      }
+      ImmutableList<ReceiveCommand> allUsersCommands = ImmutableList.of();
+      ImmutableList<InsertedObject> allUsersObjects = ImmutableList.of();
+      if (allUsersRepo != null) {
+        allUsersCommands = allUsersRepo.getCommandsSnapshot();
+        allUsersObjects = allUsersRepo.tempIns.getInsertedObjects();
+      }
+      return new AutoValue_NoteDbUpdateManager_StagedResult(
+          id, delta,
+          changeCommands, changeObjects,
+          allUsersCommands, allUsersObjects);
+    }
+
+    public abstract Change.Id id();
+    @Nullable public abstract NoteDbChangeState.Delta delta();
+    public abstract ImmutableList<ReceiveCommand> changeCommands();
+    public abstract ImmutableList<InsertedObject> changeObjects();
+
+    public abstract ImmutableList<ReceiveCommand> allUsersCommands();
+    public abstract ImmutableList<InsertedObject> allUsersObjects();
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    static Result create(NoteDbUpdateManager.StagedResult staged,
+        NoteDbChangeState newState) {
+      return new AutoValue_NoteDbUpdateManager_Result(newState, staged);
+    }
+
+    @Nullable public abstract NoteDbChangeState newState();
+
+    @Nullable abstract NoteDbUpdateManager.StagedResult staged();
+  }
+
+  static class OpenRepo implements AutoCloseable {
+    final Repository repo;
+    final RevWalk rw;
+    final ChainedReceiveCommands cmds;
+
+    private final InMemoryInserter tempIns;
+    @Nullable private final ObjectInserter finalIns;
+
+    private final boolean close;
+
+    private OpenRepo(Repository repo, RevWalk rw, @Nullable ObjectInserter ins,
+        ChainedReceiveCommands cmds, boolean close) {
+      ObjectReader reader = rw.getObjectReader();
+      checkArgument(ins == null || reader.getCreatedFromInserter() == ins,
+          "expected reader to be created from %s, but was %s",
+          ins, reader.getCreatedFromInserter());
+      this.repo = checkNotNull(repo);
+      this.tempIns = new InMemoryInserter(rw.getObjectReader());
+      this.rw = new RevWalk(tempIns.newReader());
+      this.finalIns = ins;
+      this.cmds = checkNotNull(cmds);
+      this.close = close;
+    }
+
+    Optional<ObjectId> getObjectId(String refName) throws IOException {
+      return cmds.get(refName);
+    }
+
+    ImmutableList<ReceiveCommand> getCommandsSnapshot() {
+      return ImmutableList.copyOf(cmds.getCommands().values());
+    }
+
+    void flush() throws IOException {
+      checkState(finalIns != null);
+      for (InsertedObject obj : tempIns.getInsertedObjects()) {
+        finalIns.insert(obj.type(), obj.data().toByteArray());
+      }
+      finalIns.flush();
+      tempIns.clear();
+    }
+
+    @Override
+    public void close() {
+      rw.close();
+      if (close) {
+        if (finalIns != null) {
+          finalIns.close();
+        }
+        repo.close();
+      }
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final NotesMigration migration;
+  private final AllUsersName allUsersName;
+  private final NoteDbMetrics metrics;
+  private final Project.NameKey projectName;
+  private final ListMultimap<String, ChangeUpdate> changeUpdates;
+  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final Set<Change.Id> toDelete;
+
+  private OpenRepo changeRepo;
+  private OpenRepo allUsersRepo;
+  private Map<Change.Id, StagedResult> staged;
+  private boolean checkExpectedState = true;
+
+  @AssistedInject
+  NoteDbUpdateManager(GitRepositoryManager repoManager,
+      NotesMigration migration,
+      AllUsersName allUsersName,
+      NoteDbMetrics metrics,
+      @Assisted Project.NameKey projectName) {
+    this.repoManager = repoManager;
+    this.migration = migration;
+    this.allUsersName = allUsersName;
+    this.metrics = metrics;
+    this.projectName = projectName;
+    changeUpdates = ArrayListMultimap.create();
+    draftUpdates = ArrayListMultimap.create();
+    toDelete = new HashSet<>();
+  }
+
+  @Override
+  public void close() {
+    try {
+      if (allUsersRepo != null) {
+        OpenRepo r = allUsersRepo;
+        allUsersRepo = null;
+        r.close();
+      }
+    } finally {
+      if (changeRepo != null) {
+        OpenRepo r = changeRepo;
+        changeRepo = null;
+        r.close();
+      }
+    }
+  }
+
+  public NoteDbUpdateManager setChangeRepo(Repository repo, RevWalk rw,
+      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
+    checkState(changeRepo == null, "change repo already initialized");
+    changeRepo = new OpenRepo(repo, rw, ins, cmds, false);
+    return this;
+  }
+
+  public NoteDbUpdateManager setAllUsersRepo(Repository repo, RevWalk rw,
+      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
+    checkState(allUsersRepo == null, "All-Users repo already initialized");
+    allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false);
+    return this;
+  }
+
+  NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
+    this.checkExpectedState = checkExpectedState;
+    return this;
+  }
+
+  OpenRepo getChangeRepo() throws IOException {
+    initChangeRepo();
+    return changeRepo;
+  }
+
+  OpenRepo getAllUsersRepo() throws IOException {
+    initAllUsersRepo();
+    return allUsersRepo;
+  }
+
+  private void initChangeRepo() throws IOException {
+    if (changeRepo == null) {
+      changeRepo = openRepo(projectName);
+    }
+  }
+
+  private void initAllUsersRepo() throws IOException {
+    if (allUsersRepo == null) {
+      allUsersRepo = openRepo(allUsersName);
+    }
+  }
+
+  private OpenRepo openRepo(Project.NameKey p) throws IOException {
+    Repository repo = repoManager.openRepository(p);
+    ObjectInserter ins = repo.newObjectInserter();
+    return new OpenRepo(repo, new RevWalk(ins.newReader()), ins,
+        new ChainedReceiveCommands(repo), true);
+  }
+
+  private boolean isEmpty() {
+    if (!migration.commitChangeWrites()) {
+      return true;
+    }
+    return changeUpdates.isEmpty()
+        && draftUpdates.isEmpty()
+        && toDelete.isEmpty();
+  }
+
+  /**
+   * Add an update to the list of updates to execute.
+   * <p>
+   * Updates should only be added to the manager after all mutations have been
+   * made, as this method may eagerly access the update.
+   *
+   * @param update the update to add.
+   */
+  public void add(ChangeUpdate update) {
+    checkArgument(update.getProjectName().equals(projectName),
+      "update for project %s cannot be added to manager for project %s",
+      update.getProjectName(), projectName);
+    checkState(staged == null, "cannot add new update after staging");
+    changeUpdates.put(update.getRefName(), update);
+    ChangeDraftUpdate du = update.getDraftUpdate();
+    if (du != null) {
+      draftUpdates.put(du.getRefName(), du);
+    }
+  }
+
+  public void add(ChangeDraftUpdate draftUpdate) {
+    checkState(staged == null, "cannot add new update after staging");
+    draftUpdates.put(draftUpdate.getRefName(), draftUpdate);
+  }
+
+  public void deleteChange(Change.Id id) {
+    checkState(staged == null, "cannot add new change to delete after staging");
+    toDelete.add(id);
+  }
+
+  /**
+   * Stage updates in the manager's internal list of commands.
+   *
+   * @return map of the state that would get written to the applicable repo(s)
+   *     for each affected change.
+   * @throws OrmException if a database layer error occurs.
+   * @throws IOException if a storage layer error occurs.
+   */
+  public Map<Change.Id, StagedResult> stage()
+      throws OrmException, IOException {
+    if (staged != null) {
+      return staged;
+    }
+    try (Timer1.Context timer = metrics.stageUpdateLatency.start(CHANGES)) {
+      staged = new HashMap<>();
+      if (isEmpty()) {
+        return staged;
+      }
+
+      initChangeRepo();
+      if (!draftUpdates.isEmpty() || !toDelete.isEmpty()) {
+        initAllUsersRepo();
+      }
+      checkExpectedState();
+      addCommands();
+
+      Table<Change.Id, Account.Id, ObjectId> allDraftIds = getDraftIds();
+      Set<Change.Id> changeIds = new HashSet<>();
+      for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
+        Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
+        changeIds.add(changeId);
+        Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
+        staged.put(
+            changeId,
+            StagedResult.create(
+                changeId,
+                NoteDbChangeState.Delta.create(
+                    changeId, metaId, allDraftIds.rowMap().remove(changeId)),
+                changeRepo, allUsersRepo));
+      }
+
+      for (Map.Entry<Change.Id, Map<Account.Id, ObjectId>> e
+          : allDraftIds.rowMap().entrySet()) {
+        // If a change remains in the table at this point, it means we are
+        // updating its drafts but not the change itself.
+        StagedResult r = StagedResult.create(
+            e.getKey(),
+            NoteDbChangeState.Delta.create(
+                e.getKey(), Optional.<ObjectId>absent(), e.getValue()),
+            changeRepo, allUsersRepo);
+        checkState(r.changeCommands().isEmpty(),
+            "should not have change commands when updating only drafts: %s", r);
+        staged.put(r.id(), r);
+      }
+
+      return staged;
+    }
+  }
+
+  public Result stageAndApplyDelta(Change change)
+      throws OrmException, IOException {
+    StagedResult sr = stage().get(change.getId());
+    NoteDbChangeState newState =
+        NoteDbChangeState.applyDelta(change, sr != null ? sr.delta() : null);
+    return Result.create(sr, newState);
+  }
+
+  private Table<Change.Id, Account.Id, ObjectId> getDraftIds() {
+    Table<Change.Id, Account.Id, ObjectId> draftIds = HashBasedTable.create();
+    if (allUsersRepo == null) {
+      return draftIds;
+    }
+    for (ReceiveCommand cmd : allUsersRepo.getCommandsSnapshot()) {
+      String r = cmd.getRefName();
+      if (r.startsWith(REFS_DRAFT_COMMENTS)) {
+        Change.Id changeId =
+            Change.Id.fromRefPart(r.substring(REFS_DRAFT_COMMENTS.length()));
+        Account.Id accountId = Account.Id.fromRefSuffix(r);
+        checkDraftRef(accountId != null && changeId != null, r);
+        draftIds.put(changeId, accountId, cmd.getNewId());
+      }
+    }
+    return draftIds;
+  }
+
+  public void flush() throws IOException {
+    if (changeRepo != null) {
+      changeRepo.flush();
+    }
+    if (allUsersRepo != null) {
+      allUsersRepo.flush();
+    }
+  }
+
+  public void execute() throws OrmException, IOException {
+    // Check before even inspecting the list, as this is a programmer error.
+    if (migration.failChangeWrites()) {
+      throw new OrmException(CHANGES_READ_ONLY);
+    }
+    if (isEmpty()) {
+      return;
+    }
+    try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
+      stage();
+      // ChangeUpdates must execute before ChangeDraftUpdates.
+      //
+      // ChangeUpdate will automatically delete draft comments for any published
+      // comments, but the updates to the two repos don't happen atomically.
+      // Thus if the change meta update succeeds and the All-Users update fails,
+      // we may have stale draft comments. Doing it in this order allows stale
+      // comments to be filtered out by ChangeNotes, reflecting the fact that
+      // comments can only go from DRAFT to PUBLISHED, not vice versa.
+      execute(changeRepo);
+      execute(allUsersRepo);
+    } finally {
+      close();
+    }
+  }
+
+  private static void execute(OpenRepo or) throws IOException {
+    if (or == null || or.cmds.isEmpty()) {
+      return;
+    }
+    or.flush();
+    BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
+    or.cmds.addTo(bru);
+    bru.setAllowNonFastForwards(true);
+    bru.execute(or.rw, NullProgressMonitor.INSTANCE);
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Update failed: " + bru);
+      }
+    }
+  }
+
+  private void addCommands() throws OrmException, IOException {
+    if (isEmpty()) {
+      return;
+    }
+    checkState(changeRepo != null, "must set change repo");
+    if (!draftUpdates.isEmpty()) {
+      checkState(allUsersRepo != null, "must set all users repo");
+    }
+    addUpdates(changeUpdates, changeRepo);
+    if (!draftUpdates.isEmpty()) {
+      addUpdates(draftUpdates, allUsersRepo);
+    }
+    for (Change.Id id : toDelete) {
+      doDelete(id);
+    }
+    checkExpectedState();
+  }
+
+  private void doDelete(Change.Id id) throws IOException {
+    String metaRef = RefNames.changeMetaRef(id);
+    Optional<ObjectId> old = changeRepo.cmds.get(metaRef);
+    if (old.isPresent()) {
+      changeRepo.cmds.add(
+          new ReceiveCommand(old.get(), ObjectId.zeroId(), metaRef));
+    }
+
+    // Just scan repo for ref names, but get "old" values from cmds.
+    for (Ref r : allUsersRepo.repo.getRefDatabase().getRefs(
+        RefNames.refsDraftCommentsPrefix(id)).values()) {
+      old = allUsersRepo.cmds.get(r.getName());
+      if (old.isPresent()) {
+        allUsersRepo.cmds.add(
+            new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
+      }
+    }
+  }
+
+  private void checkExpectedState() throws OrmException, IOException {
+    if (!checkExpectedState) {
+      return;
+    }
+
+    // Refuse to apply an update unless the state in NoteDb matches the state
+    // claimed in the ref. This means we may have failed a NoteDb ref update,
+    // and it would be incorrect to claim that the ref is up to date after this
+    // pipeline.
+    //
+    // Generally speaking, this case should be rare; in most cases, we should
+    // have detected and auto-fixed the stale state when creating ChangeNotes
+    // that got passed into the ChangeUpdate.
+    for (Collection<ChangeUpdate> us : changeUpdates.asMap().values()) {
+      ChangeUpdate u = us.iterator().next();
+      NoteDbChangeState expectedState = NoteDbChangeState.parse(u.getChange());
+
+      if (expectedState == null) {
+        // No previous state means we haven't previously written NoteDb graphs
+        // for this change yet. This means either:
+        //  - The change is new, and we'll be creating its ref.
+        //  - We short-circuited before adding any commands that update this
+        //    ref, and we won't stage a delta for this change either.
+        // Either way, it is safe to proceed here rather than throwing
+        // OrmConcurrencyException.
+        continue;
+      }
+
+      if (!expectedState.isChangeUpToDate(changeRepo.cmds.getRepoRefCache())) {
+        throw new OrmConcurrencyException(String.format(
+            "cannot apply NoteDb updates for change %s;"
+            + " change meta ref does not match %s",
+            u.getId(), expectedState.getChangeMetaId().name()));
+      }
+    }
+
+    for (Collection<ChangeDraftUpdate> us : draftUpdates.asMap().values()) {
+      ChangeDraftUpdate u = us.iterator().next();
+      NoteDbChangeState expectedState = NoteDbChangeState.parse(u.getChange());
+
+      if (expectedState == null) {
+        continue; // See above.
+      }
+
+      Account.Id accountId = u.getAccountId();
+      if (!expectedState.areDraftsUpToDate(
+          allUsersRepo.cmds.getRepoRefCache(), accountId)) {
+        throw new OrmConcurrencyException(String.format(
+            "cannot apply NoteDb updates for change %s;"
+            + " draft ref for account %s does not match %s",
+            u.getId(), accountId, expectedState.getChangeMetaId().name()));
+      }
+    }
+  }
+
+  private static <U extends AbstractChangeUpdate> void addUpdates(
+      ListMultimap<String, U> all, OpenRepo or)
+      throws OrmException, IOException {
+    for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
+      String refName = e.getKey();
+      Collection<U> updates = e.getValue();
+      ObjectId old = or.cmds.get(refName).or(ObjectId.zeroId());
+      // Only actually write to the ref if one of the updates explicitly allows
+      // us to do so, i.e. it is known to represent a new change. This avoids
+      // writing partial change meta if the change hasn't been backfilled yet.
+      if (!allowWrite(updates, old)) {
+        continue;
+      }
+
+      ObjectId curr = old;
+      for (U u : updates) {
+        ObjectId next = u.apply(or.rw, or.tempIns, curr);
+        if (next == null) {
+          continue;
+        }
+        curr = next;
+      }
+      if (!old.equals(curr)) {
+        or.cmds.add(new ReceiveCommand(old, curr, refName));
+      }
+    }
+  }
+
+  private static <U extends AbstractChangeUpdate> boolean allowWrite(
+      Collection<U> updates, ObjectId old) {
+    if (!old.equals(ObjectId.zeroId())) {
+      return true;
+    }
+    return updates.iterator().next().allowWriteToNewRef();
+  }
+
+  private static void checkDraftRef(boolean condition, String refName) {
+    checkState(condition, "invalid draft ref: %s", refName);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index d63f972..56b41d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,17 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
-import java.util.HashSet;
-import java.util.Set;
-
 /**
  * Holds the current state of the NoteDb migration.
  * <p>
@@ -44,77 +33,77 @@
  * Changing options quite likely requires re-running {@code RebuildNoteDb}. For
  * these reasons, the options remain undocumented.
  */
-@Singleton
-public class NotesMigration {
-  private static enum Table {
-    CHANGES;
+public abstract class NotesMigration {
+  /**
+   * Read changes from NoteDb.
+   * <p>
+   * Change data is read from NoteDb refs, but ReviewDb is still the source of
+   * truth. If the loader determines NoteDb is out of date, the change data in
+   * NoteDb will be transparently rebuilt. This means that some code paths that
+   * look read-only may in fact attempt to write.
+   * <p>
+   * If true and {@code writeChanges() = false}, changes can still be read from
+   * NoteDb, but any attempts to write will generate an error.
+   */
+  public abstract boolean readChanges();
 
-    private String key() {
-      return name().toLowerCase();
-    }
+  /**
+   * Write changes to NoteDb.
+   * <p>
+   * Updates to change data are written to NoteDb refs, but ReviewDb is still
+   * the source of truth. Change data will not be written unless the NoteDb refs
+   * are already up to date, and the write path will attempt to rebuild the
+   * change if not.
+   * <p>
+   * If false, the behavior when attempting to write depends on
+   * {@code readChanges()}. If {@code readChanges() = false}, writes to NoteDb
+   * are simply ignored; if {@code true}, any attempts to write will generate an
+   * error.
+   */
+  protected abstract boolean writeChanges();
+
+  /**
+   * Read sequential change ID numbers from NoteDb.
+   * <p>
+   * If true, change IDs are read from {@code refs/sequences/changes} in
+   * All-Projects. If false, change IDs are read from ReviewDb's native
+   * sequences.
+   */
+  public abstract boolean readChangeSequence();
+
+  public abstract boolean readAccounts();
+
+  public abstract boolean writeAccounts();
+
+  /**
+   * Whether to fail when reading any data from NoteDb.
+   * <p>
+   * Used in conjunction with {@link #readChanges()} for tests.
+   */
+  public boolean failOnLoad() {
+    return false;
   }
 
-  private static final String NOTEDB = "notedb";
-  private static final String READ = "read";
-  private static final String WRITE = "write";
-
-  private static void checkConfig(Config cfg) {
-    Set<String> keys = new HashSet<>();
-    for (Table t : Table.values()) {
-      keys.add(t.key());
-    }
-    for (String t : cfg.getSubsections(NOTEDB)) {
-      checkArgument(keys.contains(t.toLowerCase()),
-          "invalid notedb table: %s", t);
-      for (String key : cfg.getNames(NOTEDB, t)) {
-        String lk = key.toLowerCase();
-        checkArgument(lk.equals(WRITE) || lk.equals(READ),
-            "invalid notedb key: %s.%s", t, key);
-      }
-      boolean write = cfg.getBoolean(NOTEDB, t, WRITE, false);
-      boolean read = cfg.getBoolean(NOTEDB, t, READ, false);
-      checkArgument(!(read && !write),
-          "must have write enabled when read enabled: %s", t);
-    }
+  public boolean commitChangeWrites() {
+    // It may seem odd that readChanges() without writeChanges() means we should
+    // attempt to commit writes. However, this method is used by callers to know
+    // whether or not they should short-circuit and skip attempting to read or
+    // write NoteDb refs.
+    //
+    // It is possible for commitChangeWrites() to return true and
+    // failChangeWrites() to also return true, causing an error later in the
+    // same codepath. This specific condition is used by the auto-rebuilding
+    // path to rebuild a change and stage the results, but not commit them due
+    // to failChangeWrites().
+    return writeChanges() || readChanges();
   }
 
-  public static NotesMigration allEnabled() {
-    return new NotesMigration(allEnabledConfig());
-  }
-
-  public static Config allEnabledConfig() {
-    Config cfg = new Config();
-    setAllEnabledConfig(cfg);
-    return cfg;
-  }
-
-  public static void setAllEnabledConfig(Config cfg) {
-    for (Table t : Table.values()) {
-      cfg.setBoolean(NOTEDB, t.key(), WRITE, true);
-      cfg.setBoolean(NOTEDB, t.key(), READ, true);
-    }
-  }
-
-  private final boolean writeChanges;
-  private final boolean readChanges;
-
-  @Inject
-  NotesMigration(@GerritServerConfig Config cfg) {
-    checkConfig(cfg);
-    writeChanges = cfg.getBoolean(NOTEDB, Table.CHANGES.key(), WRITE, false);
-    readChanges = cfg.getBoolean(NOTEDB, Table.CHANGES.key(), READ, false);
+  public boolean failChangeWrites() {
+    return !writeChanges() && readChanges();
   }
 
   public boolean enabled() {
-    return writeChanges()
-        || readChanges();
-  }
-
-  public boolean writeChanges() {
-    return writeChanges;
-  }
-
-  public boolean readChanges() {
-    return readChanges;
+    return writeChanges() || readChanges()
+        || writeAccounts() || readAccounts();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
new file mode 100644
index 0000000..39cd6cc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+public enum PatchSetState {
+  /** Published and visible to anyone who can see the change; the default.*/
+  PUBLISHED,
+
+  /** Draft patch set, only visible to certain users. */
+  DRAFT,
+
+  /**
+   * Deleted patch set.
+   * <p>
+   * Used internally as a tombstone; patch sets exposed by public NoteDb
+   * interfaces never have this state.
+   */
+  DELETED;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
new file mode 100644
index 0000000..071e12c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Predicates;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Class for managing an incrementing sequence backed by a git repository.
+ * <p>
+ * The current sequence number is stored as UTF-8 text in a blob pointed to
+ * by a ref in the {@code refs/sequences/*} namespace. Multiple processes can
+ * share the same sequence by incrementing the counter using normal git ref
+ * updates. To amortize the cost of these ref updates, processes can increment
+ * the counter by a larger number and hand out numbers from that range in memory
+ * until they run out. This means concurrent processes will hand out somewhat
+ * non-monotonic numbers.
+ */
+public class RepoSequence {
+  public interface Seed {
+    int get() throws OrmException;
+  }
+
+  @VisibleForTesting
+  static RetryerBuilder<RefUpdate.Result> retryerBuilder() {
+    return RetryerBuilder.<RefUpdate.Result> newBuilder()
+        .retryIfResult(Predicates.equalTo(RefUpdate.Result.LOCK_FAILURE))
+        .withWaitStrategy(
+            WaitStrategies.join(
+              WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
+              WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
+  }
+
+  private static Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
+
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey projectName;
+  private final String refName;
+  private final Seed seed;
+  private final int batchSize;
+  private final Runnable afterReadRef;
+  private final Retryer<RefUpdate.Result> retryer;
+
+  // Protects all non-final fields.
+  private final Lock counterLock;
+
+  private int limit;
+  private int counter;
+
+  @VisibleForTesting
+  int acquireCount;
+
+  public RepoSequence(
+      GitRepositoryManager repoManager,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize) {
+    this(repoManager, projectName, name, seed, batchSize, Runnables.doNothing(),
+        RETRYER);
+  }
+
+  @VisibleForTesting
+  RepoSequence(
+      GitRepositoryManager repoManager,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.projectName = checkNotNull(projectName, "projectName");
+    this.refName = RefNames.REFS_SEQUENCES + checkNotNull(name, "name");
+    this.seed = checkNotNull(seed, "seed");
+
+    checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
+    this.batchSize = batchSize;
+    this.afterReadRef = checkNotNull(afterReadRef, "afterReadRef");
+    this.retryer = checkNotNull(retryer, "retryer");
+
+    counterLock = new ReentrantLock(true);
+  }
+
+  public int next() throws OrmException {
+    counterLock.lock();
+    try {
+      if (counter >= limit) {
+        acquire(batchSize);
+      }
+      return counter++;
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  public ImmutableList<Integer> next(int count) throws OrmException {
+    if (count == 0) {
+      return ImmutableList.of();
+    }
+    checkArgument(count > 0, "count is negative: %s", count);
+    counterLock.lock();
+    try {
+      List<Integer> ids = new ArrayList<>(count);
+      while (counter < limit) {
+        ids.add(counter++);
+        if (ids.size() == count) {
+          return ImmutableList.copyOf(ids);
+        }
+      }
+      acquire(Math.max(count - ids.size(), batchSize));
+      while (ids.size() < count) {
+        ids.add(counter++);
+      }
+      return ImmutableList.copyOf(ids);
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  @VisibleForTesting
+  public void set(int val) throws OrmException {
+    // Don't bother spinning. This is only for tests, and a test that calls set
+    // concurrently with other writes is doing it wrong.
+    counterLock.lock();
+    try {
+      try (Repository repo = repoManager.openRepository(projectName);
+          RevWalk rw = new RevWalk(repo)) {
+        checkResult(store(repo, rw, null, val));
+        counter = limit;
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  private void acquire(int count) throws OrmException {
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      TryAcquire attempt = new TryAcquire(repo, rw, count);
+      checkResult(retryer.call(attempt));
+      counter = attempt.next;
+      limit = counter + count;
+      acquireCount++;
+    } catch (ExecutionException | RetryException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), OrmException.class);
+      throw new OrmException(e);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private void checkResult(RefUpdate.Result result) throws OrmException {
+    if (result != RefUpdate.Result.NEW && result != RefUpdate.Result.FORCED) {
+      throw new OrmException("failed to update " + refName + ": " + result);
+    }
+  }
+
+  private class TryAcquire implements Callable<RefUpdate.Result> {
+    private final Repository repo;
+    private final RevWalk rw;
+    private final int count;
+
+    private int next;
+
+    private TryAcquire(Repository repo, RevWalk rw, int count) {
+      this.repo = repo;
+      this.rw = rw;
+      this.count = count;
+    }
+
+    @Override
+    public RefUpdate.Result call() throws Exception {
+      Ref ref = repo.exactRef(refName);
+      afterReadRef.run();
+      ObjectId oldId;
+      if (ref == null) {
+        oldId = ObjectId.zeroId();
+        next = seed.get();
+      } else {
+        oldId = ref.getObjectId();
+        next = parse(oldId);
+      }
+      return store(repo, rw, oldId, next + count);
+    }
+
+    private int parse(ObjectId id) throws IOException, OrmException {
+      ObjectLoader ol = rw.getObjectReader().open(id, OBJ_BLOB);
+      if (ol.getType() != OBJ_BLOB) {
+        // In theory this should be thrown by open but not all implementations
+        // may do it properly (certainly InMemoryRepository doesn't).
+        throw new IncorrectObjectTypeException(id, OBJ_BLOB);
+      }
+      String str = CharMatcher.whitespace().trimFrom(
+          new String(ol.getCachedBytes(), UTF_8));
+      Integer val = Ints.tryParse(str);
+      if (val == null) {
+        throw new OrmException(
+            "invalid value in " + refName + " blob at " + id.name());
+      }
+      return val;
+    }
+  }
+
+  private RefUpdate.Result store(Repository repo, RevWalk rw,
+      @Nullable ObjectId oldId, int val) throws IOException {
+    ObjectId newId;
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+      ins.flush();
+    }
+    RefUpdate ru = repo.updateRef(refName);
+    if (oldId != null) {
+      ru.setExpectedOldObjectId(oldId);
+    }
+    ru.setNewObjectId(newId);
+    ru.setForceUpdate(true); // Required for non-commitish updates.
+    return ru.update(rw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
deleted file mode 100644
index b829a69..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import org.eclipse.jgit.revwalk.FooterKey;
-
-/** State of a reviewer on a change. */
-public enum ReviewerState {
-  /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER(new FooterKey("Reviewer")),
-
-  /** The reviewer was added to the change, but has not voted. */
-  CC(new FooterKey("CC")),
-
-  /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED(new FooterKey("Removed"));
-
-  private final FooterKey footerKey;
-
-  private ReviewerState(FooterKey footerKey) {
-    this.footerKey = footerKey;
-  }
-
-  FooterKey getFooterKey() {
-    return footerKey;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
new file mode 100644
index 0000000..be7f8d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.extensions.client.ReviewerState;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+import java.util.Arrays;
+
+/** State of a reviewer on a change. */
+public enum ReviewerStateInternal {
+  /** The user has contributed at least one nonzero vote on the change. */
+  REVIEWER(new FooterKey("Reviewer"), ReviewerState.REVIEWER),
+
+  /** The reviewer was added to the change, but has not voted. */
+  CC(new FooterKey("CC"), ReviewerState.CC),
+
+  /** The user was previously a reviewer on the change, but was removed. */
+  REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
+
+  static {
+    boolean ok = true;
+    if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
+      ok = false;
+    }
+    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
+      ok &= s.name().equals(s.state.name());
+    }
+    if (!ok) {
+      throw new IllegalStateException("Mismatched reviewer state mapping: "
+          + Arrays.asList(ReviewerStateInternal.values()) + " != "
+          + Arrays.asList(ReviewerState.values()));
+    }
+  }
+
+  private final FooterKey footerKey;
+  private final ReviewerState state;
+
+  ReviewerStateInternal(FooterKey footerKey, ReviewerState state) {
+    this.footerKey = footerKey;
+    this.state = state;
+  }
+
+  FooterKey getFooterKey() {
+    return footerKey;
+  }
+
+  public ReviewerState asReviewerState() {
+    return state;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
new file mode 100644
index 0000000..73ad68e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.IOException;
+
+class RevisionNote {
+  static final int MAX_NOTE_SZ = 25 << 20;
+
+  private static final byte[] CERT_HEADER =
+      "certificate version ".getBytes(UTF_8);
+  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
+  private static final byte[] END_SIGNATURE =
+      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
+
+  private static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
+    while (p.value < bytes.length && bytes[p.value] == '\n') {
+      p.value++;
+    }
+  }
+
+  private static String parsePushCert(Change.Id changeId, byte[] bytes,
+      MutableInteger p) throws ConfigInvalidException {
+    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
+      return null;
+    }
+    int end = Bytes.indexOf(bytes, END_SIGNATURE);
+    if (end < 0) {
+      throw ChangeNotes.parseException(
+          changeId, "invalid push certificate in note");
+    }
+    int start = p.value;
+    p.value = end + END_SIGNATURE.length;
+    return new String(bytes, start, p.value);
+  }
+
+  final byte[] raw;
+  final ImmutableList<PatchLineComment> comments;
+  final String pushCert;
+
+  RevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
+      ObjectReader reader, ObjectId noteId, boolean draftsOnly)
+      throws ConfigInvalidException, IOException {
+    raw = reader.open(noteId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    MutableInteger p = new MutableInteger();
+    trimLeadingEmptyLines(raw, p);
+    if (!draftsOnly) {
+      pushCert = parsePushCert(changeId, raw, p);
+      trimLeadingEmptyLines(raw, p);
+    } else {
+      pushCert = null;
+    }
+    PatchLineComment.Status status = draftsOnly
+        ? PatchLineComment.Status.DRAFT
+        : PatchLineComment.Status.PUBLISHED;
+    comments = ImmutableList.copyOf(
+        noteUtil.parseNote(raw, p, changeId, status));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
new file mode 100644
index 0000000..c8364d3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RevId;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+class RevisionNoteBuilder {
+  static class Cache {
+    private final RevisionNoteMap revisionNoteMap;
+    private final Map<RevId, RevisionNoteBuilder> builders;
+
+    Cache(RevisionNoteMap revisionNoteMap) {
+      this.revisionNoteMap = revisionNoteMap;
+      this.builders = new HashMap<>();
+    }
+
+    RevisionNoteBuilder get(RevId revId) {
+      RevisionNoteBuilder b = builders.get(revId);
+      if (b == null) {
+        b = new RevisionNoteBuilder(
+            revisionNoteMap.revisionNotes.get(revId));
+        builders.put(revId, b);
+      }
+      return b;
+    }
+
+    Map<RevId, RevisionNoteBuilder> getBuilders() {
+      return Collections.unmodifiableMap(builders);
+    }
+  }
+
+  final byte[] baseRaw;
+  final List<PatchLineComment> baseComments;
+  final Map<PatchLineComment.Key, PatchLineComment> put;
+  final Set<PatchLineComment.Key> delete;
+
+  private String pushCert;
+
+  RevisionNoteBuilder(RevisionNote base) {
+    if (base != null) {
+      baseRaw = base.raw;
+      baseComments = base.comments;
+      put = Maps.newHashMapWithExpectedSize(base.comments.size());
+      pushCert = base.pushCert;
+    } else {
+      baseRaw = new byte[0];
+      baseComments = Collections.emptyList();
+      put = new HashMap<>();
+      pushCert = null;
+    }
+    delete = new HashSet<>();
+  }
+
+  void putComment(PatchLineComment comment) {
+    checkArgument(!delete.contains(comment.getKey()),
+        "cannot both delete and put %s", comment.getKey());
+    put.put(comment.getKey(), comment);
+  }
+
+  void deleteComment(PatchLineComment.Key key) {
+    checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
+    delete.add(key);
+  }
+
+  void setPushCertificate(String pushCert) {
+    this.pushCert = pushCert;
+  }
+
+  byte[] build(ChangeNoteUtil noteUtil) {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    if (pushCert != null) {
+      byte[] certBytes = pushCert.getBytes(UTF_8);
+      out.write(certBytes, 0, trimTrailingNewlines(certBytes));
+      out.write('\n');
+    }
+
+    Multimap<PatchSet.Id, PatchLineComment> all = ArrayListMultimap.create();
+    for (PatchLineComment c : baseComments) {
+      if (!delete.contains(c.getKey()) && !put.containsKey(c.getKey())) {
+        all.put(c.getPatchSetId(), c);
+      }
+    }
+    for (PatchLineComment c : put.values()) {
+      if (!delete.contains(c.getKey())) {
+        all.put(c.getPatchSetId(), c);
+      }
+    }
+    noteUtil.buildNote(all, out);
+    return out.toByteArray();
+  }
+
+  private static int trimTrailingNewlines(byte[] bytes) {
+    int p = bytes.length;
+    while (p > 1 && bytes[p - 1] == '\n') {
+      p--;
+    }
+    return p;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
new file mode 100644
index 0000000..cd70528
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RevId;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+class RevisionNoteMap {
+  final NoteMap noteMap;
+  final ImmutableMap<RevId, RevisionNote> revisionNotes;
+
+  static RevisionNoteMap parse(ChangeNoteUtil noteUtil,
+      Change.Id changeId, ObjectReader reader, NoteMap noteMap,
+      boolean draftsOnly) throws ConfigInvalidException, IOException {
+    Map<RevId, RevisionNote> result = new HashMap<>();
+    for (Note note : noteMap) {
+      RevisionNote rn = new RevisionNote(
+          noteUtil, changeId, reader, note.getData(), draftsOnly);
+      result.put(new RevId(note.name()), rn);
+    }
+    return new RevisionNoteMap(noteMap, ImmutableMap.copyOf(result));
+  }
+
+  static RevisionNoteMap emptyMap() {
+    return new RevisionNoteMap(NoteMap.newEmptyMap(),
+        ImmutableMap.<RevId, RevisionNote> of());
+  }
+
+  private RevisionNoteMap(NoteMap noteMap,
+      ImmutableMap<RevId, RevisionNote> revisionNotes) {
+    this.noteMap = noteMap;
+    this.revisionNotes = revisionNotes;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
new file mode 100644
index 0000000..c0bb8ab
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@VisibleForTesting
+@Singleton
+public class TestChangeRebuilderWrapper extends ChangeRebuilder {
+  private final ChangeRebuilderImpl delegate;
+  private final AtomicBoolean failNextUpdate;
+  private final AtomicBoolean stealNextUpdate;
+
+  @Inject
+  TestChangeRebuilderWrapper(SchemaFactory<ReviewDb> schemaFactory,
+      ChangeRebuilderImpl rebuilder) {
+    super(schemaFactory);
+    this.delegate = rebuilder;
+    this.failNextUpdate = new AtomicBoolean();
+    this.stealNextUpdate = new AtomicBoolean();
+  }
+
+  public void failNextUpdate() {
+    failNextUpdate.set(true);
+  }
+
+  public void stealNextUpdate() {
+    stealNextUpdate.set(true);
+  }
+
+  @Override
+  public Result rebuild(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
+    Result result = delegate.rebuild(db, changeId);
+    if (stealNextUpdate.getAndSet(false)) {
+      throw new IOException("Update stolen");
+    }
+    return result;
+  }
+
+  @Override
+  public Result rebuild(NoteDbUpdateManager manager,
+      ChangeBundle bundle) throws NoSuchChangeException, IOException,
+      OrmException, ConfigInvalidException {
+    // stealNextUpdate doesn't really apply in this case because the IOException
+    // would normally come from the manager.execute() method, which isn't called
+    // here.
+    return delegate.rebuild(manager, bundle);
+  }
+
+  @Override
+  public boolean rebuildProject(ReviewDb db,
+      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project, Repository allUsersRepo)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
+    boolean result =
+        delegate.rebuildProject(db, allChanges, project, allUsersRepo);
+    if (stealNextUpdate.getAndSet(false)) {
+      throw new IOException("Update stolen");
+    }
+    return result;
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException {
+    // Don't inspect stealNextUpdate; that happens in execute() below.
+    return delegate.stage(db, changeId);
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
+    Result result = delegate.execute(db, changeId, manager);
+    if (stealNextUpdate.getAndSet(false)) {
+      throw new IOException("Update stolen");
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
new file mode 100644
index 0000000..c4af9fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeFormatter;
+import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.ResolveMerger;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+public class AutoMerger {
+  private static final Logger log = LoggerFactory.getLogger(AutoMerger.class);
+
+  static boolean cacheAutomerge(Config cfg) {
+    return cfg.getBoolean("change", null, "cacheAutomerge", true);
+  }
+
+  private final PersonIdent gerritIdent;
+  private final boolean save;
+
+  @Inject
+  AutoMerger(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent gerritIdent) {
+    save = cacheAutomerge(cfg);
+    this.gerritIdent = gerritIdent;
+  }
+
+  /**
+   * Perform an auto-merge of the parents of the given merge commit.
+   *
+   * @return auto-merge commit or {@code null} if an auto-merge commit
+   *     couldn't be created. Headers of the returned RevCommit are parsed.
+   */
+  public RevCommit merge(Repository repo, RevWalk rw, final ObjectInserter ins,
+      RevCommit merge, ThreeWayMergeStrategy mergeStrategy)
+      throws IOException {
+    checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    InMemoryInserter tmpIns = null;
+    if (ins instanceof InMemoryInserter) {
+      // Caller gave us an in-memory inserter, so ensure anything we write from
+      // this method is visible to them.
+      tmpIns = (InMemoryInserter) ins;
+    } else if (!save) {
+      // If we don't plan on saving results, use a fully in-memory inserter.
+      // Using just a non-flushing wrapper is not sufficient, since in
+      // particular DfsInserter might try to write to storage after exceeding an
+      // internal buffer size.
+      tmpIns = new InMemoryInserter(rw.getObjectReader());
+    }
+
+    rw.parseHeaders(merge);
+    String hash = merge.name();
+    String refName = RefNames.REFS_CACHE_AUTOMERGE
+        + hash.substring(0, 2)
+        + "/"
+        + hash.substring(2);
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return (RevCommit) obj;
+      }
+      return commit(repo, rw, tmpIns, ins, refName, obj, merge);
+    }
+
+    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(repo, true);
+    DirCache dc = DirCache.newInCore();
+    m.setDirCache(dc);
+    m.setObjectInserter(tmpIns == null ? new NonFlushingWrapper(ins) : tmpIns);
+
+    boolean couldMerge;
+    try {
+      couldMerge = m.merge(merge.getParents());
+    } catch (IOException e) {
+      // It is not safe to continue further down in this method as throwing
+      // an exception most likely means that the merge tree was not created
+      // and m.getMergeResults() is empty. This would mean that all paths are
+      // unmerged and Gerrit UI would show all paths in the patch list.
+      log.warn("Error attempting automerge " + refName, e);
+      return null;
+    }
+
+    ObjectId treeId;
+    if (couldMerge) {
+      treeId = m.getResultTreeId();
+
+    } else {
+      RevCommit ours = merge.getParent(0);
+      RevCommit theirs = merge.getParent(1);
+      rw.parseBody(ours);
+      rw.parseBody(theirs);
+      String oursMsg = ours.getShortMessage();
+      String theirsMsg = theirs.getShortMessage();
+
+      String oursName = String.format("HEAD   (%s %s)",
+          ours.abbreviate(6).name(),
+          oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
+      String theirsName = String.format("BRANCH (%s %s)",
+          theirs.abbreviate(6).name(),
+          theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
+
+      MergeFormatter fmt = new MergeFormatter();
+      Map<String, MergeResult<? extends Sequence>> r = m.getMergeResults();
+      Map<String, ObjectId> resolved = new HashMap<>();
+      for (Map.Entry<String, MergeResult<? extends Sequence>> entry : r.entrySet()) {
+        MergeResult<? extends Sequence> p = entry.getValue();
+        try (TemporaryBuffer buf =
+            new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
+          fmt.formatMerge(buf, p, "BASE", oursName, theirsName, UTF_8.name());
+          buf.close();
+
+          try (InputStream in = buf.openInputStream()) {
+            resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
+          }
+        }
+      }
+
+      DirCacheBuilder builder = dc.builder();
+      int cnt = dc.getEntryCount();
+      for (int i = 0; i < cnt;) {
+        DirCacheEntry entry = dc.getEntry(i);
+        if (entry.getStage() == 0) {
+          builder.add(entry);
+          i++;
+          continue;
+        }
+
+        int next = dc.nextEntry(i);
+        String path = entry.getPathString();
+        DirCacheEntry res = new DirCacheEntry(path);
+        if (resolved.containsKey(path)) {
+          // For a file with content merge conflict that we produced a result
+          // above on, collapse the file down to a single stage 0 with just
+          // the blob content, and a randomly selected mode (the lowest stage,
+          // which should be the merge base, or ours).
+          res.setFileMode(entry.getFileMode());
+          res.setObjectId(resolved.get(path));
+
+        } else if (next == i + 1) {
+          // If there is exactly one stage present, shouldn't be a conflict...
+          res.setFileMode(entry.getFileMode());
+          res.setObjectId(entry.getObjectId());
+
+        } else if (next == i + 2) {
+          // Two stages suggests a delete/modify conflict. Pick the higher
+          // stage as the automatic result.
+          entry = dc.getEntry(i + 1);
+          res.setFileMode(entry.getFileMode());
+          res.setObjectId(entry.getObjectId());
+
+        } else {
+          // 3 stage conflict, no resolve above
+          // Punt on the 3-stage conflict and show the base, for now.
+          res.setFileMode(entry.getFileMode());
+          res.setObjectId(entry.getObjectId());
+        }
+        builder.add(res);
+        i = next;
+      }
+      builder.finish();
+      treeId = dc.writeTree(ins);
+    }
+
+    return commit(repo, rw, tmpIns, ins, refName, treeId, merge);
+  }
+
+  private RevCommit commit(
+      Repository repo,
+      RevWalk rw,
+      @Nullable InMemoryInserter tmpIns,
+      ObjectInserter ins,
+      String refName,
+      ObjectId tree,
+      RevCommit merge) throws IOException {
+    rw.parseHeaders(merge);
+    // For maximum stability, choose a single ident using the committer time of
+    // the input commit, using the server name and timezone.
+    PersonIdent ident = new PersonIdent(
+        gerritIdent,
+        merge.getCommitterIdent().getWhen(),
+        gerritIdent.getTimeZone());
+    CommitBuilder cb = new CommitBuilder();
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    cb.setTreeId(tree);
+    cb.setMessage("Auto-merge of " + merge.name() + '\n');
+    for (RevCommit p : merge.getParents()) {
+      cb.addParentId(p);
+    }
+
+    if (!save) {
+      checkArgument(tmpIns != null);
+      try (ObjectReader tmpReader = tmpIns.newReader();
+          RevWalk tmpRw = new RevWalk(tmpReader)) {
+        return tmpRw.parseCommit(tmpIns.insert(cb));
+      }
+    }
+
+    checkArgument(tmpIns == null);
+    checkArgument(!(ins instanceof InMemoryInserter));
+    ObjectId commitId = ins.insert(cb);
+    ins.flush();
+
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setNewObjectId(commitId);
+    ru.disableRefLog();
+    ru.forceUpdate();
+    return rw.parseCommit(commitId);
+  }
+
+  private static class NonFlushingWrapper extends ObjectInserter.Filter {
+    private final ObjectInserter ins;
+
+    private NonFlushingWrapper(ObjectInserter ins) {
+      this.ins = ins;
+    }
+
+    @Override
+    protected ObjectInserter delegate() {
+      return ins;
+    }
+
+    @Override
+    public void flush() {
+    }
+
+    @Override
+    public void close() {
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
index 46def59..60b97c7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -37,7 +37,7 @@
 public class IntraLineDiff implements Serializable {
   static final long serialVersionUID = IntraLineDiffKey.serialVersionUID;
 
-  public static enum Status implements CodedEnum {
+  public enum Status implements CodedEnum {
     EDIT_LIST('e'), DISABLED('D'), TIMEOUT('T'), ERROR('E');
 
     private final char code;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index 038ad51..cdde12a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -14,84 +14,25 @@
 
 package com.google.gerrit.server.patch;
 
-import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
-import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 
 import org.eclipse.jgit.lib.ObjectId;
 
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
 import java.io.Serializable;
 
-public class IntraLineDiffKey implements Serializable {
-  static final long serialVersionUID = 4L;
+@AutoValue
+public abstract class IntraLineDiffKey implements Serializable {
+  public static final long serialVersionUID = 5L;
 
-  private transient boolean ignoreWhitespace;
-  private transient ObjectId aId;
-  private transient ObjectId bId;
-
-  public IntraLineDiffKey(ObjectId aId, ObjectId bId,
-      boolean ignoreWhitespace) {
-    this.aId = aId;
-    this.bId = bId;
-    this.ignoreWhitespace = ignoreWhitespace;
+  public static IntraLineDiffKey create(ObjectId aId, ObjectId bId,
+      Whitespace whitespace) {
+    return new AutoValue_IntraLineDiffKey(aId, bId, whitespace);
   }
 
-  public ObjectId getBlobA() {
-    return aId;
-  }
+  public abstract ObjectId getBlobA();
 
-  public ObjectId getBlobB() {
-    return bId;
-  }
+  public abstract ObjectId getBlobB();
 
-  public boolean isIgnoreWhitespace() {
-    return ignoreWhitespace;
-  }
-
-  @Override
-  public int hashCode() {
-    int h = 0;
-
-    h = h * 31 + aId.hashCode();
-    h = h * 31 + bId.hashCode();
-    h = h * 31 + (ignoreWhitespace ? 1 : 0);
-
-    return h;
-  }
-
-  @Override
-  public boolean equals(final Object o) {
-    if (o instanceof IntraLineDiffKey) {
-      final IntraLineDiffKey k = (IntraLineDiffKey) o;
-      return aId.equals(k.aId) //
-          && bId.equals(k.bId) //
-          && ignoreWhitespace == k.ignoreWhitespace;
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder n = new StringBuilder();
-    n.append("IntraLineDiffKey[");
-    n.append(aId.name());
-    n.append("..");
-    n.append(bId.name());
-    n.append("]");
-    return n.toString();
-  }
-
-  private void writeObject(final ObjectOutputStream out) throws IOException {
-    writeNotNull(out, aId);
-    writeNotNull(out, bId);
-    out.writeBoolean(ignoreWhitespace);
-  }
-
-  private void readObject(final ObjectInputStream in) throws IOException {
-    aId = readNotNull(in);
-    bId = readNotNull(in);
-    ignoreWhitespace = in.readBoolean();
-  }
+  public abstract Whitespace getWhitespace();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index b6b7fb7..dd15cfc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -40,7 +40,7 @@
 class IntraLineLoader implements Callable<IntraLineDiff> {
   static final Logger log = LoggerFactory.getLogger(IntraLineLoader.class);
 
-  static interface Factory {
+  interface Factory {
     IntraLineLoader create(IntraLineDiffKey key, IntraLineDiffArgs args);
   }
 
@@ -186,80 +186,34 @@
 
           // The leading part of an edit and its trailing part in the same
           // text might be identical. Slide down that edit and use the tail
-          // rather than the leading bit. If however the edit is only on a
-          // whitespace block try to shift it to the left margin, assuming
-          // that it is an indentation change.
+          // rather than the leading bit.
           //
-          boolean aShift = true;
-          if (ab < ae && isOnlyWhitespace(a, ab, ae)) {
-            int lf = findLF(wordEdits, j, a, ab);
-            if (lf < ab && a.charAt(lf) == '\n') {
-              int nb = lf + 1;
-              int p = 0;
-              while (p < ae - ab) {
-                if (cmp.equals(a, ab + p, a, ab + p)) {
-                  p++;
-                } else {
-                  break;
-                }
-              }
-              if (p == ae - ab) {
-                ab = nb;
-                ae = nb + p;
-                aShift = false;
-              }
-            }
+          while (0 < ab && ab < ae && a.charAt(ab - 1) != '\n'
+              && cmp.equals(a, ab - 1, a, ae - 1)) {
+            ab--;
+            ae--;
           }
-          if (aShift) {
-            while (0 < ab && ab < ae && a.charAt(ab - 1) != '\n'
-                && cmp.equals(a, ab - 1, a, ae - 1)) {
-              ab--;
-              ae--;
-            }
-            if (!a.isLineStart(ab) || !a.contains(ab, ae, '\n')) {
-              while (ab < ae && ae < a.size() && cmp.equals(a, ab, a, ae)) {
-                ab++;
-                ae++;
-                if (a.charAt(ae - 1) == '\n') {
-                  break;
-                }
+          if (!a.isLineStart(ab) || !a.contains(ab, ae, '\n')) {
+            while (ab < ae && ae < a.size() && cmp.equals(a, ab, a, ae)) {
+              ab++;
+              ae++;
+              if (a.charAt(ae - 1) == '\n') {
+                break;
               }
             }
           }
 
-          boolean bShift = true;
-          if (bb < be && isOnlyWhitespace(b, bb, be)) {
-            int lf = findLF(wordEdits, j, b, bb);
-            if (lf < bb && b.charAt(lf) == '\n') {
-              int nb = lf + 1;
-              int p = 0;
-              while (p < be - bb) {
-                if (cmp.equals(b, bb + p, b, bb + p)) {
-                  p++;
-                } else {
-                  break;
-                }
-              }
-              if (p == be - bb) {
-                bb = nb;
-                be = nb + p;
-                bShift = false;
-              }
-            }
+          while (0 < bb && bb < be && b.charAt(bb - 1) != '\n'
+              && cmp.equals(b, bb - 1, b, be - 1)) {
+            bb--;
+            be--;
           }
-          if (bShift) {
-            while (0 < bb && bb < be && b.charAt(bb - 1) != '\n'
-                && cmp.equals(b, bb - 1, b, be - 1)) {
-              bb--;
-              be--;
-            }
-            if (!b.isLineStart(bb) || !b.contains(bb, be, '\n')) {
-              while (bb < be && be < b.size() && cmp.equals(b, bb, b, be)) {
-                bb++;
-                be++;
-                if (b.charAt(be - 1) == '\n') {
-                  break;
-                }
+          if (!b.isLineStart(bb) || !b.contains(bb, be, '\n')) {
+            while (bb < be && be < b.size() && cmp.equals(b, bb, b, be)) {
+              bb++;
+              be++;
+              if (b.charAt(be - 1) == '\n') {
+                break;
               }
             }
           }
@@ -342,22 +296,4 @@
     }
     return true;
   }
-
-  private static int findLF(List<Edit> edits, int j, CharText t, int b) {
-    int lf = b;
-    int limit = 0 < j ? edits.get(j - 1).getEndB() : 0;
-    while (limit < lf && t.charAt(lf) != '\n') {
-      lf--;
-    }
-    return lf;
-  }
-
-  private static boolean isOnlyWhitespace(CharText t, final int b, final int e) {
-    for (int c = b; c < e; c++) {
-      if (!Character.isWhitespace(t.charAt(c))) {
-        return false;
-      }
-    }
-    return b < e;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
index f6cff15..7088fe8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
@@ -21,8 +21,8 @@
     Weigher<IntraLineDiffKey, IntraLineDiff> {
   @Override
   public int weigh(IntraLineDiffKey key, IntraLineDiff value) {
-    return 16 + 8*8 + 2*36     // Size of IntraLineDiffKey, 64 bit JVM
-        + 16 + 2*8 + 16+8+4+20 // Size of IntraLineDiff, 64 bit JVM
-        + (8 + 16 + 4*4) * value.getEdits().size();
+    return 16 + 8 * 8 + 2 * 36     // Size of IntraLineDiffKey, 64 bit JVM
+        + 16 + 2 * 8 + 16 + 8 + 4 + 20 // Size of IntraLineDiff, 64 bit JVM
+        + (8 + 16 + 4 * 4) * value.getEdits().size();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
index 2704be8..e570b3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.gerrit.common.errors.CorruptEntityException;
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.reviewdb.client.Patch;
 
@@ -87,12 +86,11 @@
    * @param file the file index to extract.
    * @param line the line number to extract (1 based; 1 is the first line).
    * @return the string version of the file line.
-   * @throws CorruptEntityException the patch cannot be read.
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException
    */
   public String getLine(final int file, final int line)
-      throws CorruptEntityException, IOException, NoSuchEntityException {
+      throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
@@ -116,12 +114,11 @@
    *
    * @param file the file index to extract.
    * @return number of lines in file.
-   * @throws CorruptEntityException the patch cannot be read.
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException the file is not exist.
    */
   public int getLineCount(final int file)
-      throws CorruptEntityException, IOException, NoSuchEntityException {
+      throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
index 5f6113f..2a4afb3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
@@ -63,7 +63,7 @@
   private transient int deletions;
   private transient PatchListEntry[] patches;
 
-  PatchList(@Nullable final AnyObjectId oldId, final AnyObjectId newId,
+  public PatchList(@Nullable final AnyObjectId oldId, final AnyObjectId newId,
       final boolean againstParent, final PatchListEntry[] patches) {
     this.oldId = oldId != null ? oldId.copy() : null;
     this.newId = newId.copy();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
index a6332f0..8a2403f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -18,14 +18,19 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 
+import org.eclipse.jgit.lib.ObjectId;
+
 /** Provides a cached list of {@link PatchListEntry}. */
 public interface PatchListCache {
-  public PatchList get(PatchListKey key, Project.NameKey project)
+  PatchList get(PatchListKey key, Project.NameKey project)
       throws PatchListNotAvailableException;
 
-  public PatchList get(Change change, PatchSet patchSet)
+  PatchList get(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException;
 
-  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
+  ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException;
+
+  IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
       IntraLineDiffArgs args);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index da1e7b5..abafad7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -16,6 +16,7 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.cache.Cache;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -87,24 +88,43 @@
       throws PatchListNotAvailableException {
     try {
       return fileCache.get(key, fileLoaderFactory.create(key, project));
-    } catch (ExecutionException | LargeObjectException e) {
+    } catch (ExecutionException e) {
       PatchListLoader.log.warn("Error computing " + key, e);
-      throw new PatchListNotAvailableException(e.getCause());
+      throw new PatchListNotAvailableException(e);
+    } catch (UncheckedExecutionException e) {
+      if (e.getCause() instanceof LargeObjectException) {
+        PatchListLoader.log.warn("Error computing " + key, e);
+        throw new PatchListNotAvailableException(e);
+      }
+      throw e;
     }
   }
 
   @Override
   public PatchList get(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException {
+    return get(change, patchSet, null);
+  }
+
+  @Override
+  public ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
+    return get(change, patchSet, parentNum).getOldId();
+  }
+
+  private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
-    ObjectId a = null;
     if (patchSet.getRevision() == null) {
       throw new PatchListNotAvailableException(
           "revision is null for " + patchSet.getId());
     }
     ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
     Whitespace ws = Whitespace.IGNORE_NONE;
-    return get(new PatchListKey(a, b, ws), project);
+    if (parentNum != null) {
+      return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
+    }
+    return get(PatchListKey.againstDefaultBase(b, ws), project);
   }
 
   @Override
@@ -117,8 +137,7 @@
         IntraLineLoader.log.warn("Error computing " + key, e);
         return new IntraLineDiff(IntraLineDiff.Status.ERROR);
       }
-    } else {
-      return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
     }
+    return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
index 319483a..3266f01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -50,7 +50,7 @@
 
   static PatchListEntry empty(final String fileName) {
     return new PatchListEntry(ChangeType.MODIFIED, PatchType.UNIFIED, null,
-        fileName, EMPTY_HEADER, Collections.<Edit> emptyList(), 0, 0, 0);
+        fileName, EMPTY_HEADER, Collections.<Edit> emptyList(), 0, 0, 0, 0);
   }
 
   private final ChangeType changeType;
@@ -61,11 +61,13 @@
   private final List<Edit> edits;
   private final int insertions;
   private final int deletions;
+  private final long size;
   private final long sizeDelta;
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(FileHeader hdr, List<Edit> editList, long sizeDelta) {
+  PatchListEntry(FileHeader hdr, List<Edit> editList, long size,
+      long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
 
@@ -77,6 +79,7 @@
 
       case ADDED:
       case MODIFIED:
+      case REWRITE:
         oldName = null;
         newName = hdr.getNewPath();
         break;
@@ -107,12 +110,13 @@
     }
     insertions = ins;
     deletions = del;
+    this.size = size;
     this.sizeDelta = sizeDelta;
   }
 
   private PatchListEntry(ChangeType changeType, PatchType patchType,
       String oldName, String newName, byte[] header, List<Edit> edits,
-      int insertions, int deletions, long sizeDelta) {
+      int insertions, int deletions, long size, long sizeDelta) {
     this.changeType = changeType;
     this.patchType = patchType;
     this.oldName = oldName;
@@ -121,21 +125,22 @@
     this.edits = edits;
     this.insertions = insertions;
     this.deletions = deletions;
+    this.size = size;
     this.sizeDelta = sizeDelta;
   }
 
   int weigh() {
-    int size = 16 + 6*8 + 2*4 + 20 + 16+8+4+20;
+    int size = 16 + 6 * 8 + 2 * 4 + 20 + 16 + 8 + 4 + 20;
     size += stringSize(oldName);
     size += stringSize(newName);
     size += header.length;
-    size += (8 + 16 + 4*4) * edits.size();
+    size += (8 + 16 + 4 * 4) * edits.size();
     return size;
   }
 
   private static int stringSize(String str) {
     if (str != null) {
-      return 16 + 3*4 + 16 + str.length() * 2;
+      return 16 + 3 * 4 + 16 + str.length() * 2;
     }
     return 0;
   }
@@ -168,6 +173,10 @@
     return deletions;
   }
 
+  public long getSize() {
+    return size;
+  }
+
   public long getSizeDelta() {
     return sizeDelta;
   }
@@ -204,6 +213,7 @@
     writeBytes(out, header);
     writeVarInt32(out, insertions);
     writeVarInt32(out, deletions);
+    writeFixInt64(out, size);
     writeFixInt64(out, sizeDelta);
 
     writeVarInt32(out, edits.size());
@@ -223,6 +233,7 @@
     byte[] hdr = readBytes(in);
     int ins = readVarInt32(in);
     int del = readVarInt32(in);
+    long size = readFixInt64(in);
     long sizeDelta = readFixInt64(in);
 
     int editCount = readVarInt32(in);
@@ -236,7 +247,7 @@
     }
 
     return new PatchListEntry(changeType, patchType, oldName, newName, hdr,
-        toList(editArray), ins, del, sizeDelta);
+        toList(editArray), ins, del, size, sizeDelta);
   }
 
   private static List<Edit> toList(Edit[] l) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
index 15277b2..43e3dce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -32,9 +32,10 @@
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.util.Objects;
 
 public class PatchListKey implements Serializable {
-  static final long serialVersionUID = 18L;
+  public static final long serialVersionUID = 22L;
 
   public static final BiMap<Whitespace, Character> WHITESPACE_TYPES = ImmutableBiMap.of(
       Whitespace.IGNORE_NONE, 'N',
@@ -46,7 +47,36 @@
     checkState(WHITESPACE_TYPES.size() == Whitespace.values().length);
   }
 
+  public static PatchListKey againstDefaultBase(AnyObjectId newId,
+      Whitespace ws) {
+    return new PatchListKey(null, newId, ws);
+  }
+
+  public static PatchListKey againstParentNum(int parentNum, AnyObjectId newId,
+      Whitespace ws) {
+    return new PatchListKey(parentNum, newId, ws);
+  }
+
+  /**
+   * Old patch-set ID
+   * <p>
+   * When null, it represents the Base of the newId for a non-merge commit.
+   * <p>
+   * When newId is a merge commit, null value of the oldId represents either
+   * the auto-merge commit of the newId or a parent commit of the newId.
+   * These two cases are distinguished by the parentNum.
+   */
   private transient ObjectId oldId;
+
+  /**
+   * 1-based parent number when newId is a merge commit
+   * <p>
+   * For the auto-merge case this field is null.
+   * <p>
+   * Used only when oldId is null and newId is a merge commit
+   */
+  private transient Integer parentNum;
+
   private transient ObjectId newId;
   private transient Whitespace whitespace;
 
@@ -56,12 +86,24 @@
     whitespace = ws;
   }
 
+  private PatchListKey(int parentNum, AnyObjectId b, Whitespace ws) {
+    this.parentNum = Integer.valueOf(parentNum);
+    newId = b.copy();
+    whitespace = ws;
+  }
+
   /** Old side commit, or null to assume ancestor or combined merge. */
   @Nullable
   public ObjectId getOldId() {
     return oldId;
   }
 
+  /** Parent number (old side) of the new side (merge) commit */
+  @Nullable
+  public Integer getParentNum() {
+    return parentNum;
+  }
+
   /** New side commit name. */
   public ObjectId getNewId() {
     return newId;
@@ -73,24 +115,16 @@
 
   @Override
   public int hashCode() {
-    int h = 0;
-
-    if (oldId != null) {
-      h = h * 31 + oldId.hashCode();
-    }
-
-    h = h * 31 + newId.hashCode();
-    h = h * 31 + whitespace.name().hashCode();
-
-    return h;
+    return Objects.hash(oldId, parentNum, newId, whitespace);
   }
 
   @Override
   public boolean equals(final Object o) {
     if (o instanceof PatchListKey) {
-      final PatchListKey k = (PatchListKey) o;
-      return eq(oldId, k.oldId) //
-          && eq(newId, k.newId) //
+      PatchListKey k = (PatchListKey) o;
+      return Objects.equals(oldId, k.oldId)
+          && Objects.equals(parentNum, k.parentNum)
+          && Objects.equals(newId, k.newId)
           && whitespace == k.whitespace;
     }
     return false;
@@ -109,15 +143,9 @@
     return n.toString();
   }
 
-  private static boolean eq(final ObjectId a, final ObjectId b) {
-    if (a == null && b == null) {
-      return true;
-    }
-    return a != null && b != null && AnyObjectId.equals(a, b);
-  }
-
   private void writeObject(final ObjectOutputStream out) throws IOException {
     writeCanBeNull(out, oldId);
+    out.writeInt(parentNum == null ? 0 : parentNum);
     writeNotNull(out, newId);
     Character c = WHITESPACE_TYPES.get(whitespace);
     if (c == null) {
@@ -128,6 +156,8 @@
 
   private void readObject(final ObjectInputStream in) throws IOException {
     oldId = readCanBeNull(in);
+    int n = in.readInt();
+    parentNum = n == 0 ? null : Integer.valueOf(n);
     newId = readNotNull(in);
     char t = in.readChar();
     whitespace = WHITESPACE_TYPES.inverse().get(t);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index c10241b..2fa43bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -15,6 +15,7 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -24,10 +25,10 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -39,22 +40,13 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.diff.RawTextComparator;
-import org.eclipse.jgit.diff.Sequence;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeFormatter;
-import org.eclipse.jgit.merge.MergeResult;
-import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.patch.FileHeader.PatchType;
@@ -63,18 +55,14 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.TemporaryBuffer;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -94,34 +82,42 @@
   private final PatchListCache patchListCache;
   private final ThreeWayMergeStrategy mergeStrategy;
   private final ExecutorService diffExecutor;
+  private final AutoMerger autoMerger;
   private final PatchListKey key;
   private final Project.NameKey project;
   private final long timeoutMillis;
+  private final boolean save;
 
   @AssistedInject
   PatchListLoader(GitRepositoryManager mgr,
       PatchListCache plc,
       @GerritServerConfig Config cfg,
       @DiffExecutor ExecutorService de,
+      AutoMerger am,
       @Assisted PatchListKey k,
       @Assisted Project.NameKey p) {
     repoManager = mgr;
     patchListCache = plc;
     mergeStrategy = MergeUtil.getMergeStrategy(cfg);
     diffExecutor = de;
+    autoMerger = am;
     key = k;
     project = p;
     timeoutMillis =
         ConfigUtil.getTimeUnit(cfg, "cache", PatchListCacheImpl.FILE_NAME,
             "timeout", TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
+    save = AutoMerger.cacheAutomerge(cfg);
   }
 
   @Override
   public PatchList call() throws IOException,
       PatchListNotAvailableException {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return readPatchList(key, repo);
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = newInserter(repo);
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      return readPatchList(repo, rw, ins);
     }
   }
 
@@ -142,42 +138,47 @@
     }
   }
 
-  private PatchList readPatchList(final PatchListKey key, final Repository repo)
-      throws IOException, PatchListNotAvailableException {
-    final RawTextComparator cmp = comparatorFor(key.getWhitespace());
-    try (ObjectReader reader = repo.newObjectReader();
-        RevWalk rw = new RevWalk(reader);
-        DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-      final RevCommit b = rw.parseCommit(key.getNewId());
-      final RevObject a = aFor(key, repo, rw, b);
+  private ObjectInserter newInserter(Repository repo) {
+    return save
+        ? repo.newObjectInserter()
+        : new InMemoryInserter(repo);
+  }
+
+  public PatchList readPatchList(Repository repo, RevWalk rw,
+      ObjectInserter ins) throws IOException, PatchListNotAvailableException {
+    ObjectReader reader = rw.getObjectReader();
+    checkArgument(reader.getCreatedFromInserter() == ins);
+    RawTextComparator cmp = comparatorFor(key.getWhitespace());
+    try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      RevCommit b = rw.parseCommit(key.getNewId());
+      RevObject a = aFor(key, repo, rw, ins, b);
 
       if (a == null) {
         // TODO(sop) Remove this case.
         // This is a merge commit, compared to its ancestor.
         //
-        final PatchListEntry[] entries = new PatchListEntry[1];
+        PatchListEntry[] entries = new PatchListEntry[1];
         entries[0] = newCommitMessage(cmp, reader, null, b);
         return new PatchList(a, b, true, entries);
       }
 
-      final boolean againstParent =
-          b.getParentCount() > 0 && b.getParent(0) == a;
+      boolean againstParent = isAgainstParent(a, b);
 
       RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
       RevTree aTree = rw.parseTree(a);
       RevTree bTree = b.getTree();
 
-      df.setRepository(repo);
+      df.setReader(reader, repo.getConfig());
       df.setDiffComparator(cmp);
       df.setDetectRenames(true);
       List<DiffEntry> diffEntries = df.scan(aTree, bTree);
 
       Set<String> paths = null;
-      if (key.getOldId() != null) {
-        PatchListKey newKey =
-            new PatchListKey(null, key.getNewId(), key.getWhitespace());
-        PatchListKey oldKey =
-            new PatchListKey(null, key.getOldId(), key.getWhitespace());
+      if (key.getOldId() != null && b.getParentCount() == 1) {
+        PatchListKey newKey = PatchListKey.againstDefaultBase(
+            key.getNewId(), key.getWhitespace());
+        PatchListKey oldKey = PatchListKey.againstDefaultBase(
+            key.getOldId(), key.getWhitespace());
         paths = FluentIterable
             .from(patchListCache.get(newKey, project).getPatches())
             .append(patchListCache.get(oldKey, project).getPatches())
@@ -201,10 +202,10 @@
 
           FileHeader fh = toFileHeader(key, df, e);
           long oldSize =
-              getFileSize(repo, reader, e.getOldMode(), e.getOldPath(), aTree);
+              getFileSize(reader, e.getOldMode(), e.getOldPath(), aTree);
           long newSize =
-              getFileSize(repo, reader, e.getNewMode(), e.getNewPath(), bTree);
-          entries.add(newEntry(aTree, fh, newSize - oldSize));
+              getFileSize(reader, e.getNewMode(), e.getNewPath(), bTree);
+          entries.add(newEntry(aTree, fh, newSize, newSize - oldSize));
         }
       }
       return new PatchList(a, b, againstParent,
@@ -212,14 +213,24 @@
     }
   }
 
-  private static long getFileSize(Repository repo, ObjectReader reader,
+  private boolean isAgainstParent(RevObject a, RevCommit b) {
+    for (int i = 0; i < b.getParentCount(); i++) {
+      if (b.getParent(i).equals(a)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private static long getFileSize(ObjectReader reader,
       FileMode mode, String path, RevTree t) throws IOException {
     if (!isBlob(mode)) {
       return 0;
     }
     try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
       return tw != null
-          ? repo.open(tw.getObjectId(0), OBJ_BLOB).getSize()
+          ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize()
           : 0;
     }
   }
@@ -299,34 +310,34 @@
     byte[] rawHdr = hdr.toString().getBytes(UTF_8);
     byte[] aContent = aText.getContent();
     byte[] bContent = bText.getContent();
+    long size = bContent.length;
     long sizeDelta = bContent.length - aContent.length;
     RawText aRawText = new RawText(aContent);
     RawText bRawText = new RawText(bContent);
     EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
     FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
-    return new PatchListEntry(fh, edits, sizeDelta);
+    return new PatchListEntry(fh, edits, size, sizeDelta);
   }
 
   private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader,
-      long sizeDelta) {
+      long size, long sizeDelta) {
     if (aTree == null // want combined diff
         || fileHeader.getPatchType() != PatchType.UNIFIED
         || fileHeader.getHunks().isEmpty()) {
       return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          sizeDelta);
+          size, sizeDelta);
     }
 
     List<Edit> edits = fileHeader.toEditList();
     if (edits.isEmpty()) {
       return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          sizeDelta);
-    } else {
-      return new PatchListEntry(fileHeader, edits, sizeDelta);
+          size, sizeDelta);
     }
+    return new PatchListEntry(fileHeader, edits, size, sizeDelta);
   }
 
-  private RevObject aFor(final PatchListKey key,
-      final Repository repo, final RevWalk rw, final RevCommit b)
+  private RevObject aFor(PatchListKey key,
+      Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
       throws IOException {
     if (key.getOldId() != null) {
       return rw.parseAny(key.getOldId());
@@ -334,165 +345,28 @@
 
     switch (b.getParentCount()) {
       case 0:
-        return rw.parseAny(emptyTree(repo));
+        return rw.parseAny(emptyTree(ins));
       case 1: {
         RevCommit r = b.getParent(0);
         rw.parseBody(r);
         return r;
       }
       case 2:
-        return automerge(repo, rw, b, mergeStrategy);
+        if (key.getParentNum() != null) {
+          RevCommit r = b.getParent(key.getParentNum() - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
       default:
         // TODO(sop) handle an octopus merge.
         return null;
     }
   }
 
-  public static RevTree automerge(Repository repo, RevWalk rw, RevCommit b,
-      ThreeWayMergeStrategy mergeStrategy) throws IOException {
-    return automerge(repo, rw, b, mergeStrategy, true);
-  }
-
-  public static RevTree automerge(Repository repo, RevWalk rw, RevCommit b,
-      ThreeWayMergeStrategy mergeStrategy, boolean save) throws IOException {
-    String hash = b.name();
-    String refName = RefNames.REFS_CACHE_AUTOMERGE
-        + hash.substring(0, 2)
-        + "/"
-        + hash.substring(2);
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      return rw.parseTree(ref.getObjectId());
-    }
-
-    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(repo, true);
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      DirCache dc = DirCache.newInCore();
-      m.setDirCache(dc);
-      m.setObjectInserter(new ObjectInserter.Filter() {
-        @Override
-        protected ObjectInserter delegate() {
-          return ins;
-        }
-
-        @Override
-        public void flush() {
-        }
-
-        @Override
-        public void close() {
-        }
-      });
-
-      boolean couldMerge;
-      try {
-        couldMerge = m.merge(b.getParents());
-      } catch (IOException e) {
-        // It is not safe to continue further down in this method as throwing
-        // an exception most likely means that the merge tree was not created
-        // and m.getMergeResults() is empty. This would mean that all paths are
-        // unmerged and Gerrit UI would show all paths in the patch list.
-        log.warn("Error attempting automerge " + refName, e);
-        return null;
-      }
-
-      ObjectId treeId;
-      if (couldMerge) {
-        treeId = m.getResultTreeId();
-
-      } else {
-        RevCommit ours = b.getParent(0);
-        RevCommit theirs = b.getParent(1);
-        rw.parseBody(ours);
-        rw.parseBody(theirs);
-        String oursMsg = ours.getShortMessage();
-        String theirsMsg = theirs.getShortMessage();
-
-        String oursName = String.format("HEAD   (%s %s)",
-            ours.abbreviate(6).name(),
-            oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
-        String theirsName = String.format("BRANCH (%s %s)",
-            theirs.abbreviate(6).name(),
-            theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
-
-        MergeFormatter fmt = new MergeFormatter();
-        Map<String, MergeResult<? extends Sequence>> r = m.getMergeResults();
-        Map<String, ObjectId> resolved = new HashMap<>();
-        for (Map.Entry<String, MergeResult<? extends Sequence>> entry : r.entrySet()) {
-          MergeResult<? extends Sequence> p = entry.getValue();
-          try (TemporaryBuffer buf =
-              new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
-            fmt.formatMerge(buf, p, "BASE", oursName, theirsName, UTF_8.name());
-            buf.close();
-
-            try (InputStream in = buf.openInputStream()) {
-              resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
-            }
-          }
-        }
-
-        DirCacheBuilder builder = dc.builder();
-        int cnt = dc.getEntryCount();
-        for (int i = 0; i < cnt;) {
-          DirCacheEntry entry = dc.getEntry(i);
-          if (entry.getStage() == 0) {
-            builder.add(entry);
-            i++;
-            continue;
-          }
-
-          int next = dc.nextEntry(i);
-          String path = entry.getPathString();
-          DirCacheEntry res = new DirCacheEntry(path);
-          if (resolved.containsKey(path)) {
-            // For a file with content merge conflict that we produced a result
-            // above on, collapse the file down to a single stage 0 with just
-            // the blob content, and a randomly selected mode (the lowest stage,
-            // which should be the merge base, or ours).
-            res.setFileMode(entry.getFileMode());
-            res.setObjectId(resolved.get(path));
-
-          } else if (next == i + 1) {
-            // If there is exactly one stage present, shouldn't be a conflict...
-            res.setFileMode(entry.getFileMode());
-            res.setObjectId(entry.getObjectId());
-
-          } else if (next == i + 2) {
-            // Two stages suggests a delete/modify conflict. Pick the higher
-            // stage as the automatic result.
-            entry = dc.getEntry(i + 1);
-            res.setFileMode(entry.getFileMode());
-            res.setObjectId(entry.getObjectId());
-
-          } else { // 3 stage conflict, no resolve above
-            // Punt on the 3-stage conflict and show the base, for now.
-            res.setFileMode(entry.getFileMode());
-            res.setObjectId(entry.getObjectId());
-          }
-          builder.add(res);
-          i = next;
-        }
-        builder.finish();
-        treeId = dc.writeTree(ins);
-      }
-      ins.flush();
-
-      if (save) {
-        RefUpdate update = repo.updateRef(refName);
-        update.setNewObjectId(treeId);
-        update.disableRefLog();
-        update.forceUpdate();
-      }
-
-      return rw.lookupTree(treeId);
-    }
-  }
-
-  private static ObjectId emptyTree(final Repository repo) throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
-      oi.flush();
-      return id;
-    }
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
index d715246..2362986 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
@@ -20,8 +20,8 @@
 public class PatchListWeigher implements Weigher<PatchListKey, PatchList> {
   @Override
   public int weigh(PatchListKey key, PatchList value) {
-    int size = 16 + 4*8 + 2*36 // Size of PatchListKey, 64 bit JVM
-        + 16 + 3*8 + 3*4 + 20; // Size of PatchList, 64 bit JVM
+    int size = 16 + 4 * 8 + 2 * 36 // Size of PatchListKey, 64 bit JVM
+        + 16 + 3 * 8 + 3 * 4 + 20; // Size of PatchList, 64 bit JVM
     for (PatchListEntry e : value.getPatches()) {
       size += e.weigh();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index c26aea8..e09d26f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -143,9 +143,9 @@
     } else if (diffPrefs.intralineDifference) {
       IntraLineDiff d =
           patchListCache.getIntraLineDiff(
-              new IntraLineDiffKey(
+              IntraLineDiffKey.create(
                 a.id, b.id,
-                diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE),
+                diffPrefs.ignoreWhitespace),
               IntraLineDiffArgs.create(
                 a.src, b.src, edits, projectKey, bId, b.path));
       if (d != null) {
@@ -225,6 +225,7 @@
       case MODIFIED:
       case COPIED:
       case RENAMED:
+      case REWRITE:
         return true;
 
       case ADDED:
@@ -240,6 +241,7 @@
         return null;
       case DELETED:
       case MODIFIED:
+      case REWRITE:
         return entry.getNewName();
       case COPIED:
       case RENAMED:
@@ -256,6 +258,7 @@
       case MODIFIED:
       case COPIED:
       case RENAMED:
+      case REWRITE:
       default:
         return entry.getNewName();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 5836df5..a7d2523 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.util.GitUtil.getParent;
+
 import com.google.common.base.Optional;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.CommentDetail;
@@ -31,6 +34,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -41,9 +45,9 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -67,12 +71,20 @@
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
         DiffPreferencesInfo diffPrefs);
+
+    PatchScriptFactory create(
+        ChangeControl control,
+        String fileName,
+        int parentNum,
+        PatchSet.Id patchSetB,
+        DiffPreferencesInfo diffPrefs);
   }
 
   private static final Logger log =
       LoggerFactory.getLogger(PatchScriptFactory.class);
 
   private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
   private final ReviewDb db;
@@ -82,6 +94,7 @@
   private final String fileName;
   @Nullable
   private final PatchSet.Id psa;
+  private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
   private final ChangeEditUtil editReader;
@@ -99,11 +112,13 @@
   private List<Patch> history;
   private CommentDetail comments;
 
-  @Inject
-  PatchScriptFactory(final GitRepositoryManager grm,
+  @AssistedInject
+  PatchScriptFactory(GitRepositoryManager grm,
+      PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
-      final PatchListCache patchListCache, final ReviewDb db,
-      final AccountInfoCacheFactory.Factory aicFactory,
+      PatchListCache patchListCache,
+      ReviewDb db,
+      AccountInfoCacheFactory.Factory aicFactory,
       PatchLineCommentsUtil plcUtil,
       ChangeEditUtil editReader,
       @Assisted ChangeControl control,
@@ -112,6 +127,7 @@
       @Assisted("patchSetB") final PatchSet.Id patchSetB,
       @Assisted DiffPreferencesInfo diffPrefs) {
     this.repoManager = grm;
+    this.psUtil = psUtil;
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
     this.db = db;
@@ -122,12 +138,47 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
+    this.parentNum = -1;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
     changeId = patchSetB.getParentKey();
   }
 
+  @AssistedInject
+  PatchScriptFactory(GitRepositoryManager grm,
+      PatchSetUtil psUtil,
+      Provider<PatchScriptBuilder> builderFactory,
+      PatchListCache patchListCache,
+      ReviewDb db,
+      AccountInfoCacheFactory.Factory aicFactory,
+      PatchLineCommentsUtil plcUtil,
+      ChangeEditUtil editReader,
+      @Assisted ChangeControl control,
+      @Assisted String fileName,
+      @Assisted int parentNum,
+      @Assisted PatchSet.Id patchSetB,
+      @Assisted DiffPreferencesInfo diffPrefs) {
+    this.repoManager = grm;
+    this.psUtil = psUtil;
+    this.builderFactory = builderFactory;
+    this.patchListCache = patchListCache;
+    this.db = db;
+    this.control = control;
+    this.aicFactory = aicFactory;
+    this.plcUtil = plcUtil;
+    this.editReader = editReader;
+
+    this.fileName = fileName;
+    this.psa = null;
+    this.parentNum = parentNum;
+    this.psb = patchSetB;
+    this.diffPrefs = diffPrefs;
+
+    changeId = patchSetB.getParentKey();
+    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+  }
+
   public void setLoadHistory(boolean load) {
     loadHistory = load;
   }
@@ -140,28 +191,41 @@
   public PatchScript call() throws OrmException, NoSuchChangeException,
       LargeObjectException, AuthException,
       InvalidChangeOperationException, IOException {
-    validatePatchSetId(psa);
+    if (parentNum < 0) {
+      validatePatchSetId(psa);
+    }
     validatePatchSetId(psb);
 
     change = control.getChange();
     project = change.getProject();
 
-    aId = psa != null ? toObjectId(db, psa) : null;
-    bId = toObjectId(db, psb);
+    PatchSet psEntityA = psa != null
+        ? psUtil.get(db, control.getNotes(), psa) : null;
+    PatchSet psEntityB = psb.get() == 0
+        ? new PatchSet(psb)
+        : psUtil.get(db, control.getNotes(), psb);
 
-    if ((psa != null && !control.isPatchVisible(db.patchSets().get(psa), db)) ||
-        (psb != null && !control.isPatchVisible(db.patchSets().get(psb), db))) {
+    if ((psEntityA != null && !control.isPatchVisible(psEntityA, db)) ||
+        (psEntityB != null && !control.isPatchVisible(psEntityB, db))) {
       throw new NoSuchChangeException(changeId);
     }
 
     try (Repository git = repoManager.openRepository(project)) {
+      bId = toObjectId(psEntityB);
+      if (parentNum < 0) {
+        aId = psEntityA != null ? toObjectId(psEntityA) : null;
+      } else {
+        aId = getParent(git, bId, parentNum);
+      }
+
       try {
         final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
         final PatchScriptBuilder b = newBuilder(list, git);
         final PatchListEntry content = list.get(fileName);
 
-        loadCommentsAndHistory(content.getChangeType(), //
-            content.getOldName(), //
+        loadCommentsAndHistory(control.getNotes(),
+            content.getChangeType(),
+            content.getOldName(),
             content.getNewName());
 
         return b.toPatchScript(content, comments, history);
@@ -200,32 +264,25 @@
     return b;
   }
 
-  private ObjectId toObjectId(final ReviewDb db, final PatchSet.Id psId)
-      throws OrmException, NoSuchChangeException, AuthException,
-      NoSuchChangeException, IOException {
-    if (!changeId.equals(psId.getParentKey())) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    if (psId.get() == 0) {
+  private ObjectId toObjectId(PatchSet ps) throws NoSuchChangeException,
+      AuthException, NoSuchChangeException, IOException, OrmException {
+    if (ps.getId().get() == 0) {
       return getEditRev();
     }
-    PatchSet ps = db.patchSets().get(psId);
-    if (ps == null || ps.getRevision() == null
-        || ps.getRevision().get() == null) {
+    if (ps.getRevision() == null || ps.getRevision().get() == null) {
       throw new NoSuchChangeException(changeId);
     }
 
     try {
       return ObjectId.fromString(ps.getRevision().get());
     } catch (IllegalArgumentException e) {
-      log.error("Patch set " + psId + " has invalid revision");
+      log.error("Patch set " + ps.getId() + " has invalid revision");
       throw new NoSuchChangeException(changeId, e);
     }
   }
 
   private ObjectId getEditRev() throws AuthException,
-      NoSuchChangeException, IOException {
+      NoSuchChangeException, IOException, OrmException {
     edit = editReader.byChange(change);
     if (edit.isPresent()) {
       return edit.get().getRef().getObjectId();
@@ -242,9 +299,9 @@
     }
   }
 
-  private void loadCommentsAndHistory(final ChangeType changeType,
-      final String oldName, final String newName) throws OrmException {
-    final Map<Patch.Key, Patch> byKey = new HashMap<>();
+  private void loadCommentsAndHistory(ChangeNotes notes, ChangeType changeType,
+      String oldName, String newName) throws OrmException {
+    Map<Patch.Key, Patch> byKey = new HashMap<>();
 
     if (loadHistory) {
       // This seems like a cheap trick. It doesn't properly account for a
@@ -253,7 +310,7 @@
       // proper rename detection between the patch sets.
       //
       history = new ArrayList<>();
-      for (final PatchSet ps : db.patchSets().byChange(changeId)) {
+      for (PatchSet ps : psUtil.byChange(db, notes)) {
         if (!control.isPatchVisible(ps, db)) {
           continue;
         }
@@ -275,12 +332,12 @@
           }
         }
 
-        final Patch p = new Patch(new Patch.Key(ps.getId(), name));
+        Patch p = new Patch(new Patch.Key(ps.getId(), name));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
       if (edit != null && edit.isPresent()) {
-        final Patch p = new Patch(new Patch.Key(
+        Patch p = new Patch(new Patch.Key(
             new PatchSet.Id(psb.getParentKey(), 0), fileName));
         history.add(p);
         byKey.put(p.getKey(), p);
@@ -288,7 +345,7 @@
     }
 
     if (loadComments && edit == null) {
-      final AccountInfoCacheFactory aic = aicFactory.create();
+      AccountInfoCacheFactory aic = aicFactory.create();
       comments = new CommentDetail(psa, psb);
       switch (changeType) {
         case ADDED:
@@ -312,9 +369,9 @@
           break;
       }
 
-      final CurrentUser user = control.getUser();
+      CurrentUser user = control.getUser();
       if (user.isIdentifiedUser()) {
-        final Account.Id me = user.getAccountId();
+        Account.Id me = user.getAccountId();
         switch (changeType) {
           case ADDED:
           case MODIFIED:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 16598b7..85afafc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -15,14 +15,16 @@
 package com.google.gerrit.server.patch;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -47,12 +49,16 @@
 @Singleton
 public class PatchSetInfoFactory {
   private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
   private final AccountByEmailCache byEmailCache;
 
   @Inject
-  public PatchSetInfoFactory(final GitRepositoryManager grm,
-      final AccountByEmailCache byEmailCache) {
-    this.repoManager = grm;
+  public PatchSetInfoFactory(
+      GitRepositoryManager repoManager,
+      PatchSetUtil psUtil,
+      AccountByEmailCache byEmailCache) {
+    this.repoManager = repoManager;
+    this.psUtil = psUtil;
     this.byEmailCache = byEmailCache;
   }
 
@@ -68,20 +74,19 @@
     return info;
   }
 
-  public PatchSetInfo get(ReviewDb db, PatchSet.Id patchSetId)
+  public PatchSetInfo get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
       throws PatchSetInfoNotAvailableException {
     try {
-      final PatchSet patchSet = db.patchSets().get(patchSetId);
-      final Change change = db.changes().get(patchSet.getId().getParentKey());
-      return get(change, patchSet);
+      PatchSet patchSet = psUtil.get(db, notes, psId);
+      return get(notes.getProjectName(), patchSet);
     } catch (OrmException e) {
       throw new PatchSetInfoNotAvailableException(e);
     }
   }
 
-  public PatchSetInfo get(Change change, PatchSet patchSet)
+  public PatchSetInfo get(Project.NameKey project, PatchSet patchSet)
       throws PatchSetInfoNotAvailableException {
-    try (Repository repo = repoManager.openRepository(change.getProject());
+    try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       final RevCommit src =
           rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
@@ -93,6 +98,7 @@
     }
   }
 
+  // TODO: The same method exists in EventFactory, find a common place for it
   private UserIdentity toUserIdentity(final PersonIdent who) {
     final UserIdentity u = new UserIdentity();
     u.setName(who.getName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
index e239654..ee8f963 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.Plugin.ApiType;
 import com.google.inject.Module;
@@ -26,6 +25,7 @@
 import java.io.IOException;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Modifier;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.jar.Manifest;
@@ -85,7 +85,7 @@
         ImmutableMap.builder();
 
     for (Class<? extends Annotation> annotation : annotations) {
-      Set<ExtensionMetaData> classMetaDataSet = Sets.newHashSet();
+      Set<ExtensionMetaData> classMetaDataSet = new HashSet<>();
       result.put(annotation, classMetaDataSet);
 
       for (Class<?> clazz : preloadedClasses) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
index 0eaddb3..438add6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.server.plugins;
 
+import static com.google.gerrit.extensions.webui.JavaScriptPlugin.STATIC_INIT_JS;
 import static com.google.gerrit.server.plugins.AutoRegisterUtil.calculateBindAnnotation;
 import static com.google.gerrit.server.plugins.PluginGuiceEnvironment.is;
 
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.annotations.Listen;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.plugins.PluginContentScanner.ExtensionMetaData;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
@@ -37,6 +39,7 @@
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -48,10 +51,11 @@
   private final PluginContentScanner scanner;
   private final ClassLoader classLoader;
   private final ModuleGenerator sshGen;
-  private final HttpModuleGenerator httpGen;
+  private final ModuleGenerator httpGen;
 
   private Set<Class<?>> sysSingletons;
   private Multimap<TypeLiteral<?>, Class<?>> sysListen;
+  private String initJs;
 
   Module sysModule;
   Module sshModule;
@@ -70,19 +74,20 @@
         : new ModuleGenerator.NOP();
     this.httpGen = env.hasHttpModule()
         ? env.newHttpModuleGenerator()
-        : new HttpModuleGenerator.NOP();
+        : new ModuleGenerator.NOP();
   }
 
   AutoRegisterModules discover() throws InvalidPluginException {
-    sysSingletons = Sets.newHashSet();
+    sysSingletons = new HashSet<>();
     sysListen = LinkedListMultimap.create();
+    initJs = null;
 
     sshGen.setPluginName(pluginName);
     httpGen.setPluginName(pluginName);
 
     scan();
 
-    if (!sysSingletons.isEmpty() || !sysListen.isEmpty()) {
+    if (!sysSingletons.isEmpty() || !sysListen.isEmpty() || initJs != null) {
       sysModule = makeSystemModule();
     }
     sshModule = sshGen.create();
@@ -107,6 +112,10 @@
           Annotation n = calculateBindAnnotation(impl);
           bind(type).annotatedWith(n).to(impl);
         }
+        if (initJs != null) {
+          DynamicSet.bind(binder(), WebUiPlugin.class)
+              .toInstance(new JavaScriptPlugin(initJs));
+        }
       }
     };
   }
@@ -120,18 +129,20 @@
     for (ExtensionMetaData listener : extensions.get(Listen.class)) {
       listen(listener);
     }
-    exportInitJs();
+    if (env.hasHttpModule()) {
+      exportInitJs();
+    }
   }
 
   private void exportInitJs() {
     try {
-      if (scanner.getEntry(JavaScriptPlugin.STATIC_INIT_JS).isPresent()) {
-        httpGen.export(JavaScriptPlugin.INIT_JS);
+      if (scanner.getEntry(STATIC_INIT_JS).isPresent()) {
+        initJs = STATIC_INIT_JS;
       }
     } catch (IOException e) {
       log.warn(String.format("Cannot access %s from plugin %s: "
           + "JavaScript auto-discovered plugin will not be registered",
-          JavaScriptPlugin.STATIC_INIT_JS, pluginName), e);
+          STATIC_INIT_JS, pluginName), e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/HttpModuleGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/HttpModuleGenerator.java
deleted file mode 100644
index dd0ce67..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/HttpModuleGenerator.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.plugins;
-
-
-public interface HttpModuleGenerator extends ModuleGenerator {
-  void export(String javascript);
-
-  static class NOP extends ModuleGenerator.NOP
-      implements HttpModuleGenerator {
-    @Override
-    public void export(String javascript) {
-      // do nothing
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 926ef44..fa913b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.plugins.PluginLoader.asTemp;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 
@@ -45,10 +47,13 @@
   static final Logger log = LoggerFactory.getLogger(JarPluginProvider.class);
 
   private final Path tmpDir;
+  private final PluginConfigFactory configFactory;
 
   @Inject
-  JarPluginProvider(SitePaths sitePaths) {
-    tmpDir = sitePaths.tmp_dir;
+  JarPluginProvider(SitePaths sitePaths,
+      PluginConfigFactory configFactory) {
+    this.tmpDir = sitePaths.tmp_dir;
+    this.configFactory = configFactory;
   }
 
   @Override
@@ -143,9 +148,12 @@
               PluginLoader.parentFor(type));
 
       JarScanner jarScanner = createJarScanner(tmp);
+      PluginConfig pluginConfig = configFactory.getFromGerritConfig(name);
+
       ServerPlugin plugin = new ServerPlugin(name, description.canonicalUrl,
           description.user, srcJar, snapshot, jarScanner,
-          description.dataDir, pluginLoader);
+          description.dataDir, pluginLoader,
+          pluginConfig.getString("metricsPrefix", null));
       plugin.setCleanupHandle(new CleanupHandle(tmp, jarFile));
       keep = true;
       return plugin;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index d94df9c..1f612a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -27,7 +27,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 
 import org.eclipse.jgit.util.IO;
 import org.objectweb.asm.AnnotationVisitor;
@@ -47,6 +46,8 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -77,10 +78,10 @@
   public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
       String pluginName, Iterable<Class<? extends Annotation>> annotations)
       throws InvalidPluginException {
-    Set<String> descriptors = Sets.newHashSet();
+    Set<String> descriptors = new HashSet<>();
     Multimap<String, JarScanner.ClassData> rawMap = ArrayListMultimap.create();
     Map<Class<? extends Annotation>, String> classObjToClassDescr =
-        Maps.newHashMap();
+        new HashMap<>();
 
     for (Class<? extends Annotation> annotation : annotations) {
       String descriptor = Type.getType(annotation).getDescriptor();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
index ea81f17..544cc5b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -30,7 +30,7 @@
 import java.nio.file.Path;
 
 class JsPlugin extends Plugin {
-  private Injector httpInjector;
+  private Injector sysInjector;
 
   JsPlugin(String name, Path srcFile, PluginUser pluginUser,
       FileSnapshot snapshot) {
@@ -52,7 +52,7 @@
   public void start(PluginGuiceEnvironment env) throws Exception {
     manager = new LifecycleManager();
     String fileName = getSrcFile().getFileName().toString();
-    httpInjector =
+    sysInjector =
         Guice.createInjector(new StandaloneJsPluginModule(getName(), fileName));
     manager.start();
   }
@@ -61,13 +61,13 @@
   protected void stop(PluginGuiceEnvironment env) {
     if (manager != null) {
       manager.stop();
-      httpInjector = null;
+      sysInjector = null;
     }
   }
 
   @Override
   public Injector getSysInjector() {
-    return null;
+    return sysInjector;
   }
 
   @Override
@@ -79,7 +79,7 @@
   @Override
   @Nullable
   public Injector getHttpInjector() {
-    return httpInjector;
+    return null;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index 1d717ef..b9871ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -34,6 +33,7 @@
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 
 /** List the installed plugins. */
 @RequiresCapability(GlobalCapability.VIEW_PLUGINS)
@@ -68,7 +68,7 @@
   }
 
   public JsonElement display(PrintWriter stdout) {
-    Map<String, PluginInfo> output = Maps.newTreeMap();
+    Map<String, PluginInfo> output = new TreeMap<>();
     List<Plugin> plugins = Lists.newArrayList(pluginLoader.getPlugins(all));
     Collections.sort(plugins, new Comparator<Plugin>() {
       @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
index ae8bb0c..7818591 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
@@ -27,7 +27,7 @@
 
   Module create() throws InvalidPluginException;
 
-  static class NOP implements ModuleGenerator {
+  class NOP implements ModuleGenerator {
 
     @Override
     public void setPluginName(String name) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
index 588bc6d..4fe0c2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
@@ -26,13 +25,14 @@
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
 
 public abstract class Plugin {
-  public static enum ApiType {
+  public enum ApiType {
     EXTENSION, PLUGIN, JS
   }
 
@@ -146,7 +146,7 @@
     if (manager != null) {
       if (handle instanceof ReloadableRegistrationHandle) {
         if (reloadableHandles == null) {
-          reloadableHandles = Lists.newArrayList();
+          reloadableHandles = new ArrayList<>();
         }
         reloadableHandles.add((ReloadableRegistrationHandle<?>) handle);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index 1d9cd0e..15bb92f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -74,7 +74,7 @@
    * provided by a plugin to extend an existing
    * extension point in Gerrit.
    */
-  public static class ExtensionMetaData {
+  class ExtensionMetaData {
     public final String className;
     public final String annotationValue;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 04865a2..2c5354e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.plugins;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicItemsOf;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicMapsOf;
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicSetsOf;
@@ -21,8 +22,6 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.RootRelative;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -34,6 +33,8 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.util.PluginRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -51,7 +52,11 @@
 
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -77,13 +82,14 @@
   private final List<StartPluginListener> onStart;
   private final List<StopPluginListener> onStop;
   private final List<ReloadPluginListener> onReload;
+  private final MetricMaker serverMetrics;
 
   private Module sysModule;
   private Module sshModule;
   private Module httpModule;
 
   private Provider<ModuleGenerator> sshGen;
-  private Provider<HttpModuleGenerator> httpGen;
+  private Provider<ModuleGenerator> httpGen;
 
   private Map<TypeLiteral<?>, DynamicItem<?>> sysItems;
   private Map<TypeLiteral<?>, DynamicItem<?>> sshItems;
@@ -102,12 +108,14 @@
       Injector sysInjector,
       ThreadLocalRequestContext local,
       ServerInformation srvInfo,
-      CopyConfigModule ccm) {
+      CopyConfigModule ccm,
+      MetricMaker serverMetrics) {
     this.sysInjector = sysInjector;
     this.srvInfo = srvInfo;
     this.local = local;
     this.copyConfigModule = ccm;
     this.copyConfigKeys = Guice.createInjector(ccm).getAllBindings().keySet();
+    this.serverMetrics = serverMetrics;
 
     onStart = new CopyOnWriteArrayList<>();
     onStart.addAll(listeners(sysInjector, StartPluginListener.class));
@@ -127,6 +135,10 @@
     return srvInfo;
   }
 
+  MetricMaker getServerMetrics() {
+    return serverMetrics;
+  }
+
   boolean hasDynamicItem(TypeLiteral<?> type) {
     return sysItems.containsKey(type)
         || (sshItems != null && sshItems.containsKey(type))
@@ -189,15 +201,27 @@
 
   public void setHttpInjector(Injector injector) {
     httpModule = copy(injector);
-    httpGen = injector.getProvider(HttpModuleGenerator.class);
+    httpGen = injector.getProvider(ModuleGenerator.class);
     httpItems = dynamicItemsOf(injector);
-    httpSets = dynamicSetsOf(injector);
+    httpSets = httpDynamicSetsOf(injector);
     httpMaps = dynamicMapsOf(injector);
     onStart.addAll(listeners(injector, StartPluginListener.class));
     onStop.addAll(listeners(injector, StopPluginListener.class));
     onReload.addAll(listeners(injector, ReloadPluginListener.class));
   }
 
+  private Map<TypeLiteral<?>, DynamicSet<?>> httpDynamicSetsOf(Injector i) {
+    // Copy binding of DynamicSet<WebUiPlugin> from sysInjector to HTTP.
+    // This supports older plugins that bound a plugin in the HttpModule.
+    TypeLiteral<WebUiPlugin> key = TypeLiteral.get(WebUiPlugin.class);
+    DynamicSet<?> web = sysSets.get(key);
+    checkNotNull(web, "DynamicSet<WebUiPlugin> exists in sysInjector");
+
+    Map<TypeLiteral<?>, DynamicSet<?>> m = new HashMap<>(dynamicSetsOf(i));
+    m.put(key, web);
+    return Collections.unmodifiableMap(m);
+  }
+
   boolean hasHttpModule() {
     return httpModule != null;
   }
@@ -206,7 +230,7 @@
     return httpModule;
   }
 
-  HttpModuleGenerator newHttpModuleGenerator() {
+  ModuleGenerator newHttpModuleGenerator() {
     return httpGen.get();
   }
 
@@ -323,7 +347,7 @@
       PrivateInternals_DynamicMapImpl<Object> map =
           (PrivateInternals_DynamicMapImpl<Object>) e.getValue();
 
-      Map<Annotation, ReloadableRegistrationHandle<?>> am = Maps.newHashMap();
+      Map<Annotation, ReloadableRegistrationHandle<?>> am = new HashMap<>();
       for (ReloadableRegistrationHandle<?> h : oldHandles.get(type)) {
         Annotation a = h.getKey().getAnnotation();
         if (a != null && !UNIQUE_ANNOTATION.isInstance(a)) {
@@ -357,7 +381,7 @@
 
   /** Type used to declare unique annotations. Guice hides this, so extract it. */
   private static final Class<?> UNIQUE_ANNOTATION =
-      UniqueAnnotations.create().getClass();
+      UniqueAnnotations.create().annotationType();
 
   private void reattachSet(
       ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> oldHandles,
@@ -378,7 +402,7 @@
       // Index all old handles that match this DynamicSet<T> keyed by
       // annotations. Ignore the unique annotations, thereby favoring
       // the @Named annotations or some other non-unique naming.
-      Map<Annotation, ReloadableRegistrationHandle<?>> am = Maps.newHashMap();
+      Map<Annotation, ReloadableRegistrationHandle<?>> am = new HashMap<>();
       List<ReloadableRegistrationHandle<?>> old = oldHandles.get(type);
       Iterator<ReloadableRegistrationHandle<?>> oi = old.iterator();
       while (oi.hasNext()) {
@@ -424,6 +448,7 @@
       }
     }
   }
+
   private void reattachItem(
       ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> oldHandles,
       Map<TypeLiteral<?>, DynamicItem<?>> items,
@@ -463,7 +488,7 @@
   private static <T> void replace(Plugin newPlugin,
       ReloadableRegistrationHandle<T> h, Binding<T> b) {
     RegistrationHandle n = h.replace(b.getKey(), b.getProvider());
-    if (n != null){
+    if (n != null) {
       newPlugin.add(n);
     }
   }
@@ -485,8 +510,8 @@
   }
 
   private Module copy(Injector src) {
-    Set<TypeLiteral<?>> dynamicTypes = Sets.newHashSet();
-    Set<TypeLiteral<?>> dynamicItemTypes = Sets.newHashSet();
+    Set<TypeLiteral<?>> dynamicTypes = new HashSet<>();
+    Set<TypeLiteral<?>> dynamicItemTypes = new HashSet<>();
     for (Map.Entry<Key<?>, Binding<?>> e : src.getBindings().entrySet()) {
       TypeLiteral<?> type = e.getKey().getTypeLiteral();
       if (type.getRawType() == DynamicItem.class) {
@@ -499,7 +524,7 @@
       }
     }
 
-    final Map<Key<?>, Binding<?>> bindings = Maps.newLinkedHashMap();
+    final Map<Key<?>, Binding<?>> bindings = new LinkedHashMap<>();
     for (Map.Entry<Key<?>, Binding<?>> e : src.getBindings().entrySet()) {
       if (dynamicTypes.contains(e.getKey().getTypeLiteral())
           && e.getKey().getAnnotation() != null) {
@@ -566,6 +591,9 @@
     if (StopPluginListener.class.isAssignableFrom(type)) {
       return false;
     }
+    if (MetricMaker.class.isAssignableFrom(type)) {
+      return false;
+    }
 
     if (type.getName().startsWith("com.google.inject.")) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index 5006401..e170510 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Predicate;
+import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -25,7 +26,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Queues;
+import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.annotations.PluginName;
@@ -56,10 +57,11 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.AbstractMap;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -72,7 +74,6 @@
 
 @Singleton
 public class PluginLoader implements LifecycleListener {
-  static final String PLUGIN_TMP_PREFIX = "plugin_";
   static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
 
   public String getPluginName(Path srcPath) {
@@ -114,8 +115,8 @@
     pluginUserFactory = puf;
     running = Maps.newConcurrentMap();
     disabled = Maps.newConcurrentMap();
-    broken = Maps.newHashMap();
-    toCleanup = Queues.newArrayDeque();
+    broken = new HashMap<>();
+    toCleanup = new ArrayDeque<>();
     cleanupHandles = Maps.newConcurrentMap();
     cleaner = pct;
     urlProvider = provider;
@@ -135,6 +136,34 @@
     }
   }
 
+  public static List<Path> listPlugins(Path pluginsDir, final String suffix)
+      throws IOException {
+    if (pluginsDir == null || !Files.exists(pluginsDir)) {
+      return ImmutableList.of();
+    }
+    DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
+      @Override
+      public boolean accept(Path entry) throws IOException {
+        String n = entry.getFileName().toString();
+        boolean accept = !n.startsWith(".last_")
+            && !n.startsWith(".next_")
+            && Files.isRegularFile(entry);
+        if (!Strings.isNullOrEmpty(suffix)) {
+          accept &= n.endsWith(suffix);
+        }
+        return accept;
+      }
+    };
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(
+        pluginsDir, filter)) {
+      return Ordering.natural().sortedCopy(files);
+    }
+  }
+
+  public static List<Path> listPlugins(Path pluginsDir) throws IOException {
+    return listPlugins(pluginsDir, null);
+  }
+
   public boolean isRemoteAdminEnabled() {
     return remoteAdmin;
   }
@@ -150,11 +179,10 @@
   public Iterable<Plugin> getPlugins(boolean all) {
     if (!all) {
       return running.values();
-    } else {
-      List<Plugin> plugins = new ArrayList<>(running.values());
-      plugins.addAll(disabled.values());
-      return plugins;
     }
+    List<Plugin> plugins = new ArrayList<>(running.values());
+    plugins.addAll(disabled.values());
+    return plugins;
   }
 
   public String installPluginFromStream(String originalName, InputStream in)
@@ -623,7 +651,7 @@
     for (String name : pluginPaths.keys()) {
       for (Path pluginPath : pluginPaths.asMap().get(name)) {
         if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
-          assert(!activePlugins.containsKey(name));
+          assert !activePlugins.containsKey(name);
           activePlugins.put(name, pluginPath);
         }
       }
@@ -656,11 +684,11 @@
         continue;
       }
       Path winner = Iterables.getFirst(enabled, null);
-      assert(winner != null);
+      assert winner != null;
       // Disable all loser plugins by renaming their file names to
       // "file.disabled" and replace the disabled files in the multimap.
-      Collection<Path> elementsToRemove = Lists.newArrayList();
-      Collection<Path> elementsToAdd = Lists.newArrayList();
+      Collection<Path> elementsToRemove = new ArrayList<>();
+      Collection<Path> elementsToAdd = new ArrayList<>();
       for (Path loser : Iterables.skip(enabled, 1)) {
         log.warn(String.format("Plugin <%s> was disabled, because"
              + " another plugin <%s>"
@@ -682,20 +710,8 @@
   }
 
   private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
-    if (pluginsDir == null || !Files.exists(pluginsDir)) {
-      return Collections.emptyList();
-    }
-    DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
-      @Override
-      public boolean accept(Path entry) throws IOException {
-        String n = entry.getFileName().toString();
-        return !n.startsWith(".last_")
-            && !n.startsWith(".next_");
-      }
-    };
-    try (DirectoryStream<Path> files
-        = Files.newDirectoryStream(pluginsDir, filter)) {
-      return ImmutableList.copyOf(files);
+    try {
+      return listPlugins(pluginsDir);
     } catch (IOException e) {
       log.error("Cannot list " + pluginsDir.toAbsolutePath(), e);
       return ImmutableList.of();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
new file mode 100644
index 0000000..23b1eee
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
@@ -0,0 +1,204 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+public class PluginMetricMaker extends MetricMaker implements LifecycleListener {
+  private final MetricMaker root;
+  private final String prefix;
+  private final Set<RegistrationHandle> cleanup;
+
+  public PluginMetricMaker(MetricMaker root, String prefix) {
+    this.root = root;
+    this.prefix = prefix.endsWith("/") ? prefix : prefix + "/";
+    cleanup = Collections.synchronizedSet(new HashSet<RegistrationHandle>());
+  }
+
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    Counter0 m = root.newCounter(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1) {
+    Counter1<F1> m = root.newCounter(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Counter2<F1, F2> m = root.newCounter(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Counter3<F1, F2, F3> m =
+        root.newCounter(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public Timer0 newTimer(String name, Description desc) {
+    Timer0 m = root.newTimer(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Timer1<F1> newTimer(
+      String name, Description desc,
+      Field<F1> field1) {
+    Timer1<F1> m = root.newTimer(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Timer2<F1, F2> m = root.newTimer(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Timer3<F1, F2, F3> m =
+        root.newTimer(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public Histogram0 newHistogram(String name, Description desc) {
+    Histogram0 m = root.newHistogram(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Histogram1<F1> newHistogram(
+      String name, Description desc,
+      Field<F1> field1) {
+    Histogram1<F1> m = root.newHistogram(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Histogram2<F1, F2> m = root.newHistogram(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Histogram3<F1, F2, F3> m =
+        root.newHistogram(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc) {
+    CallbackMetric0<V> m = root.newCallbackMetric(prefix + name, valueClass, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, Field<F1> field1) {
+    CallbackMetric1<F1, V> m =
+        root.newCallbackMetric(prefix + name, valueClass, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger) {
+    final RegistrationHandle handle = root.newTrigger(metrics, trigger);
+    cleanup.add(handle);
+    return new RegistrationHandle() {
+      @Override
+      public void remove() {
+        handle.remove();
+        cleanup.remove(handle);
+      }
+    };
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public void stop() {
+    synchronized (cleanup) {
+      Iterator<RegistrationHandle> itr = cleanup.iterator();
+      while (itr.hasNext()) {
+        itr.next().remove();
+        itr.remove();
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
index 72a499e..73fb9c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was reloaded. */
 public interface ReloadPluginListener {
-  public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin);
+  void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 14c1185..40f1fea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -30,32 +30,19 @@
 
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
 
 public class ServerPlugin extends Plugin {
-
-  /** Unique key that changes whenever a plugin reloads. */
-  public static final class CacheKey {
-    private final String name;
-
-    CacheKey(String name) {
-      this.name = name;
-    }
-
-    @Override
-    public String toString() {
-      int id = System.identityHashCode(this);
-      return String.format("Plugin[%s@%x]", name, id);
-    }
-  }
-
   private final Manifest manifest;
   private final PluginContentScanner scanner;
   private final Path dataDir;
   private final String pluginCanonicalWebUrl;
   private final ClassLoader classLoader;
+  private final String metricsPrefix;
   private Class<? extends Module> sysModule;
   private Class<? extends Module> sshModule;
   private Class<? extends Module> httpModule;
@@ -73,7 +60,8 @@
       FileSnapshot snapshot,
       PluginContentScanner scanner,
       Path dataDir,
-      ClassLoader classLoader) throws InvalidPluginException {
+      ClassLoader classLoader,
+      String metricsPrefix) throws InvalidPluginException {
     super(name, srcJar, pluginUser, snapshot,
         Plugin.getApiType(getPluginManifest(scanner)));
     this.pluginCanonicalWebUrl = pluginCanonicalWebUrl;
@@ -81,9 +69,22 @@
     this.dataDir = dataDir;
     this.classLoader = classLoader;
     this.manifest = getPluginManifest(scanner);
+    this.metricsPrefix = metricsPrefix;
     loadGuiceModules(manifest, classLoader);
   }
 
+  public ServerPlugin(String name,
+      String pluginCanonicalWebUrl,
+      PluginUser pluginUser,
+      Path srcJar,
+      FileSnapshot snapshot,
+      PluginContentScanner scanner,
+      Path dataDir,
+      ClassLoader classLoader) throws InvalidPluginException {
+    this(name, pluginCanonicalWebUrl, pluginUser, srcJar, snapshot, scanner,
+        dataDir, classLoader, null);
+  }
+
   private void loadGuiceModules(Manifest manifest, ClassLoader classLoader) throws InvalidPluginException {
     Attributes main = manifest.getMainAttributes();
     String sysName = main.getValue("Gerrit-Module");
@@ -100,7 +101,7 @@
       this.sysModule = load(sysName, classLoader);
       this.sshModule = load(sshName, classLoader);
       this.httpModule = load(httpName, classLoader);
-    } catch(ClassNotFoundException e) {
+    } catch (ClassNotFoundException e) {
       throw new InvalidPluginException("Unable to load plugin Guice Modules", e);
     }
   }
@@ -122,10 +123,6 @@
     return (Class<? extends Module>) clazz;
   }
 
-  Path getSrcJar() {
-    return getSrcFile();
-  }
-
   Path getDataDir() {
     return dataDir;
   }
@@ -134,6 +131,10 @@
     return pluginCanonicalWebUrl;
   }
 
+  String getMetricsPrefix() {
+    return metricsPrefix;
+  }
+
   private static Manifest getPluginManifest(PluginContentScanner scanner)
       throws InvalidPluginException {
     try {
@@ -179,6 +180,7 @@
   private void startPlugin(PluginGuiceEnvironment env) throws Exception {
     Injector root = newRootInjector(env);
     serverManager = new LifecycleManager();
+    serverManager.add(root);
 
     AutoRegisterModules auto = null;
     if (sysModule == null && sshModule == null && httpModule == null) {
@@ -197,7 +199,7 @@
     }
 
     if (env.hasSshModule()) {
-      List<Module> modules = Lists.newLinkedList();
+      List<Module> modules = new LinkedList<>();
       if (getApiType() == ApiType.PLUGIN) {
         modules.add(env.getSshModule());
       }
@@ -213,7 +215,7 @@
     }
 
     if (env.hasHttpModule()) {
-      List<Module> modules = Lists.newLinkedList();
+      List<Module> modules = new LinkedList<>();
       if (getApiType() == ApiType.PLUGIN) {
         modules.add(env.getHttpModule());
       }
@@ -236,7 +238,7 @@
     if (getApiType() == ApiType.PLUGIN) {
       modules.add(env.getSysModule());
     }
-    modules.add(new ServerPluginInfoModule(this));
+    modules.add(new ServerPluginInfoModule(this, env.getServerMetrics()));
     return Guice.createInjector(modules);
   }
 
@@ -278,7 +280,7 @@
     if (serverManager != null) {
       if (handle instanceof ReloadableRegistrationHandle) {
         if (reloadableHandles == null) {
-          reloadableHandles = Lists.newArrayList();
+          reloadableHandles = new ArrayList<>();
         }
         reloadableHandles.add((ReloadableRegistrationHandle<?>) handle);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index b0e9453..ff89cef4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.plugins;
 
+import com.google.common.base.MoreObjects;
 import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
 import com.google.gerrit.extensions.annotations.PluginData;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.PluginUser;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
@@ -32,10 +35,12 @@
   private final Path dataDir;
 
   private volatile boolean ready;
+  private final MetricMaker serverMetrics;
 
-  ServerPluginInfoModule(ServerPlugin plugin) {
+  ServerPluginInfoModule(ServerPlugin plugin, MetricMaker serverMetrics) {
     this.plugin = plugin;
     this.dataDir = plugin.getDataDir();
+    this.serverMetrics = serverMetrics;
   }
 
   @Override
@@ -47,6 +52,18 @@
     bind(String.class)
       .annotatedWith(PluginCanonicalWebUrl.class)
       .toInstance(plugin.getPluginCanonicalWebUrl());
+
+    install(new LifecycleModule() {
+      @Override
+      public void configure() {
+        PluginMetricMaker metrics = new PluginMetricMaker(
+            serverMetrics,
+            MoreObjects.firstNonNull(plugin.getMetricsPrefix(),
+                String.format("plugins/%s/", plugin.getName())));
+        bind(MetricMaker.class).toInstance(metrics);
+        listener().toInstance(metrics);
+      }
+    });
   }
 
   @Provides
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
index bc2432b..068d73c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
@@ -37,7 +37,7 @@
   /**
    * Descriptor of the Plugin that ServerPluginProvider has to load.
    */
-  public class PluginDescription {
+  class PluginDescription {
     public final PluginUser user;
     public final String canonicalUrl;
     public final Path dataDir;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
index aaad370..0d27c87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was loaded. */
 public interface StartPluginListener {
-  public void onStartPlugin(Plugin plugin);
+  void onStartPlugin(Plugin plugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
index 24bd655..7ce53a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was unloaded. */
 public interface StopPluginListener {
-  public void onStopPlugin(Plugin plugin);
+  void onStopPlugin(Plugin plugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
index 29ab220..8e85fd0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -16,8 +16,12 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.config.AdministrateServerGroups;
+import com.google.gerrit.server.config.AdministrateServerGroupsProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitReceivePackGroupsProvider;
 import com.google.gerrit.server.config.GitUploadPackGroups;
@@ -29,15 +33,22 @@
 public class AccessControlModule extends FactoryModule {
   @Override
   protected void configure() {
-    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {}) //
-        .annotatedWith(GitUploadPackGroups.class) //
-        .toProvider(GitUploadPackGroupsProvider.class).in(SINGLETON);
+    bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
+      .annotatedWith(AdministrateServerGroups.class)
+      .toProvider(AdministrateServerGroupsProvider.class)
+      .in(SINGLETON);
 
-    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {}) //
-        .annotatedWith(GitReceivePackGroups.class) //
-        .toProvider(GitReceivePackGroupsProvider.class).in(SINGLETON);
+    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
+        .annotatedWith(GitUploadPackGroups.class)
+        .toProvider(GitUploadPackGroupsProvider.class)
+        .in(SINGLETON);
 
-    factory(ChangeControl.AssistedFactory.class);
+    bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
+        .annotatedWith(GitReceivePackGroups.class)
+        .toProvider(GitReceivePackGroupsProvider.class)
+        .in(SINGLETON);
+
+    bind(ChangeControl.Factory.class);
     factory(ProjectControl.AssistedFactory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index 3ab6ff5..9086b6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -28,14 +28,14 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.Singleton;
 
 import java.io.IOException;
 import java.util.Collection;
@@ -44,30 +44,31 @@
 
 /** Access control management for a user accessing a single change. */
 public class ChangeControl {
+  @Singleton
   public static class GenericFactory {
     private final ProjectControl.GenericFactory projectControl;
-    private final Provider<ReviewDb> db;
+    private final ChangeNotes.Factory notesFactory;
 
     @Inject
-    GenericFactory(ProjectControl.GenericFactory p, Provider<ReviewDb> d) {
+    GenericFactory(
+        ProjectControl.GenericFactory p,
+        ChangeNotes.Factory n) {
       projectControl = p;
-      db = d;
+      notesFactory = n;
     }
 
-    public ChangeControl controlFor(Change.Id changeId, CurrentUser user)
+    public ChangeControl controlFor(ReviewDb db, Project.NameKey project,
+        Change.Id changeId, CurrentUser user)
         throws NoSuchChangeException, OrmException {
-      Change change = db.get().changes().get(changeId);
-      if (change == null) {
-        throw new NoSuchChangeException(changeId);
-      }
-      return controlFor(change, user);
+      return controlFor(notesFactory.create(db, project, changeId), user);
     }
 
-    public ChangeControl controlFor(Change change, CurrentUser user)
-        throws NoSuchChangeException {
+    public ChangeControl controlFor(ReviewDb db, Change change,
+        CurrentUser user) throws NoSuchChangeException, OrmException {
       final Project.NameKey projectKey = change.getProject();
       try {
-        return projectControl.controlFor(projectKey, user).controlFor(change);
+        return projectControl.controlFor(projectKey, user)
+            .controlFor(db, change);
       } catch (NoSuchProjectException e) {
         throw new NoSuchChangeException(change.getId(), e);
       } catch (IOException e) {
@@ -76,60 +77,99 @@
       }
     }
 
-    public ChangeControl validateFor(Change.Id changeId, CurrentUser user)
-        throws NoSuchChangeException, OrmException {
-      Change change = db.get().changes().get(changeId);
-      if (change == null) {
-        throw new NoSuchChangeException(changeId);
+    public ChangeControl controlFor(ChangeNotes notes, CurrentUser user)
+        throws NoSuchChangeException {
+      try {
+        return projectControl.controlFor(notes.getProjectName(), user)
+            .controlFor(notes);
+      } catch (NoSuchProjectException | IOException e) {
+        throw new NoSuchChangeException(notes.getChangeId(), e);
       }
-      return validateFor(change, user);
     }
 
-    public ChangeControl validateFor(Change change, CurrentUser user)
-        throws NoSuchChangeException, OrmException {
-      ChangeControl c = controlFor(change, user);
-      if (!c.isVisible(db.get())) {
-        throw new NoSuchChangeException(c.getChange().getId());
+    public ChangeControl validateFor(ReviewDb db, Change.Id changeId,
+        CurrentUser user) throws NoSuchChangeException, OrmException {
+      return validateFor(db, notesFactory.createChecked(changeId), user);
+    }
+
+    public ChangeControl validateFor(ReviewDb db, ChangeNotes notes,
+        CurrentUser user) throws NoSuchChangeException, OrmException {
+      ChangeControl c = controlFor(notes, user);
+      if (!c.isVisible(db)) {
+        throw new NoSuchChangeException(c.getId());
       }
       return c;
     }
   }
 
-  public interface AssistedFactory {
-    ChangeControl create(RefControl refControl, Change change);
-    ChangeControl create(RefControl refControl, ChangeNotes notes);
+  @Singleton
+  public static class Factory {
+    private final ChangeData.Factory changeDataFactory;
+    private final ChangeNotes.Factory notesFactory;
+    private final ApprovalsUtil approvalsUtil;
+    private final PatchSetUtil patchSetUtil;
+
+    @Inject
+    Factory(ChangeData.Factory changeDataFactory,
+        ChangeNotes.Factory notesFactory,
+        ApprovalsUtil approvalsUtil,
+        PatchSetUtil patchSetUtil) {
+      this.changeDataFactory = changeDataFactory;
+      this.notesFactory = notesFactory;
+      this.approvalsUtil = approvalsUtil;
+      this.patchSetUtil = patchSetUtil;
+    }
+
+    ChangeControl create(RefControl refControl, ReviewDb db, Project.NameKey
+        project, Change.Id changeId) throws OrmException {
+      return create(refControl,
+          notesFactory.create(db, project, changeId));
+    }
+
+    /**
+     * Create a change control for a change that was loaded from index. This
+     * method should only be used when database access is harmful and potentially
+     * stale data from the index is acceptable.
+     *
+     * @param refControl ref control
+     * @param change change loaded from secondary index
+     * @return change control
+     */
+    ChangeControl createForIndexedChange(RefControl refControl, Change change) {
+      return create(refControl, notesFactory.createFromIndexedChange(change));
+    }
+
+    ChangeControl create(RefControl refControl, ChangeNotes notes) {
+      return new ChangeControl(changeDataFactory, approvalsUtil, refControl,
+          notes, patchSetUtil);
+    }
   }
 
   private final ChangeData.Factory changeDataFactory;
+  private final ApprovalsUtil approvalsUtil;
   private final RefControl refControl;
   private final ChangeNotes notes;
+  private final PatchSetUtil patchSetUtil;
 
-  @AssistedInject
   ChangeControl(
       ChangeData.Factory changeDataFactory,
-      ChangeNotes.Factory notesFactory,
-      @Assisted RefControl refControl,
-      @Assisted Change change) {
-    this(changeDataFactory, refControl,
-        notesFactory.create(change));
-  }
-
-  @AssistedInject
-  ChangeControl(
-      ChangeData.Factory changeDataFactory,
-      @Assisted RefControl refControl,
-      @Assisted ChangeNotes notes) {
+      ApprovalsUtil approvalsUtil,
+      RefControl refControl,
+      ChangeNotes notes,
+      PatchSetUtil patchSetUtil) {
     this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
     this.refControl = refControl;
     this.notes = notes;
+    this.patchSetUtil = patchSetUtil;
   }
 
   public ChangeControl forUser(final CurrentUser who) {
     if (getUser().equals(who)) {
       return this;
     }
-    return new ChangeControl(changeDataFactory,
-        getRefControl().forUser(who), notes);
+    return new ChangeControl(changeDataFactory, approvalsUtil,
+        getRefControl().forUser(who), notes, patchSetUtil);
   }
 
   public RefControl getRefControl() {
@@ -148,6 +188,10 @@
     return getProjectControl().getProject();
   }
 
+  public Change.Id getId() {
+    return notes.getChangeId();
+  }
+
   public Change getChange() {
     return notes.getChange();
   }
@@ -158,8 +202,14 @@
 
   /** Can this user see this change? */
   public boolean isVisible(ReviewDb db) throws OrmException {
+    return isVisible(db, null);
+  }
+
+  /** Can this user see this change? */
+  public boolean isVisible(ReviewDb db, @Nullable ChangeData cd)
+      throws OrmException {
     if (getChange().getStatus() == Change.Status.DRAFT
-        && !isDraftVisible(db, null)) {
+        && !isDraftVisible(db, cd)) {
       return false;
     }
     return isRefVisible();
@@ -190,13 +240,19 @@
   }
 
   /** Can this user abandon this change? */
-  public boolean canAbandon() {
-    return isOwner() // owner (aka creator) of the change can abandon
+  public boolean canAbandon(ReviewDb db) throws OrmException {
+    return (isOwner() // owner (aka creator) of the change can abandon
         || getRefControl().isOwner() // branch owner can abandon
         || getProjectControl().isOwner() // project owner can abandon
         || getUser().getCapabilities().canAdministrateServer() // site administers are god
         || getRefControl().canAbandon() // user can abandon a specific ref
-    ;
+        ) && !isPatchSetLocked(db);
+  }
+
+  /** Can this user change the destination branch of this change
+      to the new ref? */
+  public boolean canMoveTo(String ref, ReviewDb db) throws OrmException {
+    return getProjectControl().controlForRef(ref).canUpload() && canAbandon(db);
   }
 
   /** Can this user publish this draft change or any draft patch set of this change? */
@@ -212,14 +268,14 @@
   }
 
   /** Can this user rebase this change? */
-  public boolean canRebase() {
-    return isOwner() || getRefControl().canSubmit()
-        || getRefControl().canRebase();
+  public boolean canRebase(ReviewDb db) throws OrmException {
+    return (isOwner() || getRefControl().canSubmit()
+        || getRefControl().canRebase()) && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
-  public boolean canRestore() {
-    return canAbandon() // Anyone who can abandon the change can restore it back
+  public boolean canRestore(ReviewDb db) throws OrmException {
+    return canAbandon(db) // Anyone who can abandon the change can restore it back
         && getRefControl().canUpload(); // as long as you can upload too
   }
 
@@ -258,8 +314,33 @@
   }
 
   /** Can this user add a patch set to this change? */
-  public boolean canAddPatchSet() {
-    return getRefControl().canUpload();
+  public boolean canAddPatchSet(ReviewDb db) throws OrmException {
+    if (!getRefControl().canUpload()
+        || isPatchSetLocked(db)
+        || !isPatchVisible(patchSetUtil.current(db, notes), db)) {
+      return false;
+    }
+    if (isOwner()) {
+      return true;
+    }
+    return getRefControl().canAddPatchSet();
+  }
+
+  /** Is the current patch set locked against state changes? */
+  public boolean isPatchSetLocked(ReviewDb db) throws OrmException {
+    if (getChange().getStatus() == Change.Status.MERGED) {
+      return false;
+    }
+
+    for (PatchSetApproval ap : approvalsUtil.byPatchSet(db, this,
+        getChange().currentPatchSetId())) {
+      LabelType type = getLabelTypes().byLabel(ap.getLabel());
+      if (type != null && ap.getValue() == 1
+          && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
+        return true;
+      }
+    }
+    return false;
   }
 
   /** Is this user the owner of the change? */
@@ -280,7 +361,7 @@
   public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd)
       throws OrmException {
     if (getUser().isIdentifiedUser()) {
-      Collection<Account.Id> results = changeData(db, cd).reviewers().values();
+      Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
     }
     return false;
@@ -329,9 +410,8 @@
           || getUser().getCapabilities().canAdministrateServer() // site administers are god
           || getRefControl().canEditTopicName() // user can edit topic on a specific ref
       ;
-    } else {
-      return getRefControl().canForceEditTopicName();
     }
+    return getRefControl().canForceEditTopicName();
   }
 
   /** Can this user edit the hashtag name? */
@@ -353,7 +433,7 @@
 
   private boolean match(String destBranch, String refPattern) {
     return RefPatternMatcher.getMatcher(refPattern).match(destBranch,
-        getUser().getUserName());
+        getUser());
   }
 
   private ChangeData changeData(ReviewDb db, @Nullable ChangeData cd) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeModifiedException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeModifiedException.java
deleted file mode 100644
index f3ca8b6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeModifiedException.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-public class ChangeModifiedException extends InvalidChangeOperationException {
-  private static final long serialVersionUID = 1L;
-
-  public ChangeModifiedException(String msg) {
-    super(msg);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
new file mode 100644
index 0000000..37d5295
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.merge.ResolveMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+import java.io.IOException;
+
+/**
+ * Check the mergeability at current branch for a git object references expression.
+ */
+public class CheckMergeability implements RestReadView<BranchResource> {
+
+  private String source;
+  private String strategy;
+  private SubmitType submitType;
+  private final Provider<ReviewDb> db;
+
+  @Option(name = "--source", metaVar = "COMMIT",
+      usage = "the source reference to merge, which could be any git object "
+          + "references expression, refer to "
+          + "org.eclipse.jgit.lib.Repository#resolve(String)",
+      required = true)
+  public void setSource(String source) {
+    this.source = source;
+  }
+
+  @Option(name = "--strategy", metaVar = "STRATEGY",
+      usage = "name of the merge strategy, refer to "
+          + "org.eclipse.jgit.merge.MergeStrategy")
+  public void setStrategy(String strategy) {
+    this.strategy = strategy;
+  }
+
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  CheckMergeability(GitRepositoryManager gitManager,
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db) {
+    this.gitManager = gitManager;
+    this.strategy = MergeUtil.getMergeStrategy(cfg).getName();
+    this.submitType = cfg.getEnum("project", null, "submitType",
+        SubmitType.MERGE_IF_NECESSARY);
+    this.db = db;
+  }
+
+  @Override
+  public MergeableInfo apply(BranchResource resource)
+      throws IOException, BadRequestException, ResourceNotFoundException {
+    if (!(submitType.equals(SubmitType.MERGE_ALWAYS) ||
+          submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+      throw new BadRequestException(
+          "Submit type: " + submitType + " is not supported");
+    }
+
+    MergeableInfo result = new MergeableInfo();
+    result.submitType = submitType;
+    result.strategy = strategy;
+    try (Repository git = gitManager.openRepository(resource.getNameKey());
+         RevWalk rw = new RevWalk(git);
+         ObjectInserter inserter = new InMemoryInserter(git)) {
+      Merger m = MergeUtil.newMerger(git, inserter, strategy);
+
+      Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
+      if (destRef == null) {
+        throw new ResourceNotFoundException(resource.getRef());
+      }
+
+      RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
+      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
+
+      if (!resource.getControl().canReadCommit(db.get(), git, sourceCommit)) {
+        throw new BadRequestException(
+            "do not have read permission for: " + source);
+      }
+
+      if (rw.isMergedInto(sourceCommit, targetCommit)) {
+        result.mergeable = true;
+        result.commitMerged = true;
+        result.contentMerged = true;
+        return result;
+      }
+
+      if (m.merge(false, targetCommit, sourceCommit)) {
+        result.mergeable = true;
+        result.commitMerged = false;
+        result.contentMerged = m.getResultTreeId().equals(targetCommit.getTree());
+      } else {
+        result.mergeable = false;
+        if (m instanceof ResolveMerger) {
+          result.conflicts = ((ResolveMerger) m).getUnmergedPaths();
+        }
+      }
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java
deleted file mode 100644
index a91e745..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Strings;
-
-/** Info about a single commentlink section in a config. */
-public class CommentLinkInfo {
-  public static class Enabled extends CommentLinkInfo {
-    public Enabled(String name) {
-      super(name, true);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public static class Disabled extends CommentLinkInfo {
-    public Disabled(String name) {
-      super(name, false);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public final String match;
-  public final String link;
-  public final String html;
-  public final Boolean enabled; // null means true
-
-  public final transient String name;
-
-  public CommentLinkInfo(String name, String match, String link, String html,
-      Boolean enabled) {
-    checkArgument(name != null, "invalid commentlink.name");
-    checkArgument(!Strings.isNullOrEmpty(match),
-        "invalid commentlink.%s.match", name);
-    link = Strings.emptyToNull(link);
-    html = Strings.emptyToNull(html);
-    checkArgument(
-        (link != null && html == null) || (link == null && html != null),
-        "commentlink.%s must have either link or html", name);
-    this.name = name;
-    this.match = match;
-    this.link = link;
-    this.html = html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfo(CommentLinkInfo src, boolean enabled) {
-    this.name = src.name;
-    this.match = src.match;
-    this.link = src.link;
-    this.html = src.html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfo(String name, boolean enabled) {
-    this.name = name;
-    this.match = null;
-    this.link = null;
-    this.html = null;
-    this.enabled = enabled;
-  }
-
-  boolean isOverrideOnly() {
-    return false;
-  }
-
-  CommentLinkInfo inherit(CommentLinkInfo src) {
-    return new CommentLinkInfo(src, enabled);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
new file mode 100644
index 0000000..ef5af20
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+
+/** Info about a single commentlink section in a config. */
+public class CommentLinkInfoImpl extends CommentLinkInfo {
+  public static class Enabled extends CommentLinkInfoImpl {
+    public Enabled(String name) {
+      super(name, true);
+    }
+
+    @Override
+    boolean isOverrideOnly() {
+      return true;
+    }
+  }
+
+  public static class Disabled extends CommentLinkInfoImpl {
+    public Disabled(String name) {
+      super(name, false);
+    }
+
+    @Override
+    boolean isOverrideOnly() {
+      return true;
+    }
+  }
+
+  public CommentLinkInfoImpl(String name, String match, String link, String html,
+      Boolean enabled) {
+    checkArgument(name != null, "invalid commentlink.name");
+    checkArgument(!Strings.isNullOrEmpty(match),
+        "invalid commentlink.%s.match", name);
+    link = Strings.emptyToNull(link);
+    html = Strings.emptyToNull(html);
+    checkArgument(
+        (link != null && html == null) || (link == null && html != null),
+        "commentlink.%s must have either link or html", name);
+    this.name = name;
+    this.match = match;
+    this.link = link;
+    this.html = html;
+    this.enabled = enabled;
+  }
+
+  private CommentLinkInfoImpl(CommentLinkInfo src, boolean enabled) {
+    this.name = src.name;
+    this.match = src.match;
+    this.link = src.link;
+    this.html = src.html;
+    this.enabled = enabled;
+  }
+
+  private CommentLinkInfoImpl(String name, boolean enabled) {
+    this.name = name;
+    this.match = null;
+    this.link = null;
+    this.html = null;
+    this.enabled = enabled;
+  }
+
+  boolean isOverrideOnly() {
+    return false;
+  }
+
+  CommentLinkInfo inherit(CommentLinkInfo src) {
+    return new CommentLinkInfoImpl(src, enabled);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
index b20c461..f151b59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
@@ -46,7 +47,7 @@
         Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
       try {
-        CommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+        CommentLinkInfoImpl cl = ProjectConfig.buildCommentLink(cfg, name, true);
         if (cl.isOverrideOnly()) {
           log.warn("commentlink " + name + " empty except for \"enabled\"");
           continue;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
index 4879bb7..3deb7d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
@@ -69,7 +69,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(objectId);
       rw.parseBody(commit);
-      if (!parent.getControl().canReadCommit(db.get(), rw, commit)) {
+      if (!parent.getControl().canReadCommit(db.get(), repo, commit)) {
         throw new ResourceNotFoundException(id);
       }
       for (int i = 0; i < commit.getParentCount(); i++) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java
deleted file mode 100644
index 6cbf7c6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java
+++ /dev/null
@@ -1,240 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
-import com.google.inject.util.Providers;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-public class ConfigInfo {
-  public String description;
-  public InheritedBooleanInfo useContributorAgreements;
-  public InheritedBooleanInfo useContentMerge;
-  public InheritedBooleanInfo useSignedOffBy;
-  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
-  public InheritedBooleanInfo requireChangeId;
-  public InheritedBooleanInfo enableSignedPush;
-  public InheritedBooleanInfo requireSignedPush;
-  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
-  public SubmitType submitType;
-  public com.google.gerrit.extensions.client.ProjectState state;
-  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
-  public Map<String, ActionInfo> actions;
-
-  public Map<String, CommentLinkInfo> commentlinks;
-  public ThemeInfo theme;
-
-  public ConfigInfo(boolean serverEnableSignedPush,
-      ProjectControl control,
-      TransferConfig config,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory,
-      AllProjectsNameProvider allProjects,
-      DynamicMap<RestView<ProjectResource>> views) {
-    ProjectState projectState = control.getProjectState();
-    Project p = control.getProject();
-    this.description = Strings.emptyToNull(p.getDescription());
-
-    InheritedBooleanInfo useContributorAgreements =
-        new InheritedBooleanInfo();
-    InheritedBooleanInfo useSignedOffBy = new InheritedBooleanInfo();
-    InheritedBooleanInfo useContentMerge = new InheritedBooleanInfo();
-    InheritedBooleanInfo requireChangeId = new InheritedBooleanInfo();
-    InheritedBooleanInfo createNewChangeForAllNotInTarget =
-        new InheritedBooleanInfo();
-    InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
-    InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
-
-    useContributorAgreements.value = projectState.isUseContributorAgreements();
-    useSignedOffBy.value = projectState.isUseSignedOffBy();
-    useContentMerge.value = projectState.isUseContentMerge();
-    requireChangeId.value = projectState.isRequireChangeID();
-    createNewChangeForAllNotInTarget.value =
-        projectState.isCreateNewChangeForAllNotInTarget();
-
-    useContributorAgreements.configuredValue =
-        p.getUseContributorAgreements();
-    useSignedOffBy.configuredValue = p.getUseSignedOffBy();
-    useContentMerge.configuredValue = p.getUseContentMerge();
-    requireChangeId.configuredValue = p.getRequireChangeID();
-    createNewChangeForAllNotInTarget.configuredValue =
-        p.getCreateNewChangeForAllNotInTarget();
-    enableSignedPush.configuredValue = p.getEnableSignedPush();
-    requireSignedPush.configuredValue = p.getRequireSignedPush();
-
-    ProjectState parentState = Iterables.getFirst(projectState
-        .parents(), null);
-    if (parentState != null) {
-      useContributorAgreements.inheritedValue =
-          parentState.isUseContributorAgreements();
-      useSignedOffBy.inheritedValue = parentState.isUseSignedOffBy();
-      useContentMerge.inheritedValue = parentState.isUseContentMerge();
-      requireChangeId.inheritedValue = parentState.isRequireChangeID();
-      createNewChangeForAllNotInTarget.inheritedValue =
-          parentState.isCreateNewChangeForAllNotInTarget();
-      enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
-      requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
-    }
-
-    this.useContributorAgreements = useContributorAgreements;
-    this.useSignedOffBy = useSignedOffBy;
-    this.useContentMerge = useContentMerge;
-    this.requireChangeId = requireChangeId;
-    this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
-    if (serverEnableSignedPush) {
-      this.enableSignedPush = enableSignedPush;
-      this.requireSignedPush = requireSignedPush;
-    }
-
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config
-            .getMaxObjectSizeLimit() ? config
-            .getFormattedMaxObjectSizeLimit() : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue =
-        config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
-
-    this.submitType = p.getSubmitType();
-    this.state = p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE ? p.getState() : null;
-
-    this.commentlinks = Maps.newLinkedHashMap();
-    for (CommentLinkInfo cl : projectState.getCommentLinks()) {
-      this.commentlinks.put(cl.name, cl);
-    }
-
-    pluginConfig =
-        getPluginConfig(control.getProjectState(), pluginConfigEntries,
-            cfgFactory, allProjects);
-
-    actions = Maps.newTreeMap();
-    for (UiAction.Description d : UiActions.from(
-        views, new ProjectResource(control),
-        Providers.of(control.getUser()))) {
-      actions.put(d.getId(), new ActionInfo(d));
-    }
-    this.theme = projectState.getTheme();
-  }
-
-  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
-      ProjectState project, DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory, AllProjectsNameProvider allProjects) {
-    TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
-    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-      ProjectConfigEntry configEntry = e.getProvider().get();
-      PluginConfig cfg =
-          cfgFactory.getFromProjectConfig(project, e.getPluginName());
-      String configuredValue = cfg.getString(e.getExportName());
-      ConfigParameterInfo p = new ConfigParameterInfo();
-      p.displayName = configEntry.getDisplayName();
-      p.description = configEntry.getDescription();
-      p.warning = configEntry.getWarning(project);
-      p.type = configEntry.getType();
-      p.permittedValues = configEntry.getPermittedValues();
-      p.editable = configEntry.isEditable(project) ? true : null;
-      if (configEntry.isInheritable()
-          && !allProjects.get().equals(project.getProject().getNameKey())) {
-        PluginConfig cfgWithInheritance =
-            cfgFactory.getFromProjectConfigWithInheritance(project,
-                e.getPluginName());
-        p.inheritable = true;
-        p.value = configEntry.onRead(project,
-            cfgWithInheritance.getString(e.getExportName(),
-                configEntry.getDefaultValue()));
-        p.configuredValue = configuredValue;
-        p.inheritedValue = getInheritedValue(project, cfgFactory, e);
-      } else {
-        if (configEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
-          p.values = configEntry.onRead(project,
-              Arrays.asList(cfg.getStringList(e.getExportName())));
-        } else {
-          p.value = configEntry.onRead(project, configuredValue != null
-              ? configuredValue
-              : configEntry.getDefaultValue());
-        }
-      }
-      Map<String, ConfigParameterInfo> pc = pluginConfig.get(e.getPluginName());
-      if (pc == null) {
-        pc = new TreeMap<>();
-        pluginConfig.put(e.getPluginName(), pc);
-      }
-      pc.put(e.getExportName(), p);
-    }
-    return !pluginConfig.isEmpty() ? pluginConfig : null;
-  }
-
-  private String getInheritedValue(ProjectState project,
-      PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
-    ProjectConfigEntry configEntry = e.getProvider().get();
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    String inheritedValue = configEntry.getDefaultValue();
-    if (parent != null) {
-      PluginConfig parentCfgWithInheritance =
-          cfgFactory.getFromProjectConfigWithInheritance(parent,
-              e.getPluginName());
-      inheritedValue =
-          parentCfgWithInheritance.getString(e.getExportName(),
-              configEntry.getDefaultValue());
-    }
-    return inheritedValue;
-  }
-
-  public static class InheritedBooleanInfo {
-    public Boolean value;
-    public InheritableBoolean configuredValue;
-    public Boolean inheritedValue;
-  }
-
-  public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
-  }
-
-  public static class ConfigParameterInfo {
-    public String displayName;
-    public String description;
-    public String warning;
-    public ProjectConfigEntry.Type type;
-    public String value;
-    public Boolean editable;
-    public Boolean inheritable;
-    public String configuredValue;
-    public String inheritedValue;
-    public List<String> permittedValues;
-    public List<String> values;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
new file mode 100644
index 0000000..a7ba217
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.inject.util.Providers;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class ConfigInfoImpl extends ConfigInfo {
+  public ConfigInfoImpl(boolean serverEnableSignedPush,
+      ProjectControl control,
+      TransferConfig config,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects,
+      DynamicMap<RestView<ProjectResource>> views) {
+    ProjectState projectState = control.getProjectState();
+    Project p = control.getProject();
+    this.description = Strings.emptyToNull(p.getDescription());
+
+    InheritedBooleanInfo useContributorAgreements =
+        new InheritedBooleanInfo();
+    InheritedBooleanInfo useSignedOffBy = new InheritedBooleanInfo();
+    InheritedBooleanInfo useContentMerge = new InheritedBooleanInfo();
+    InheritedBooleanInfo requireChangeId = new InheritedBooleanInfo();
+    InheritedBooleanInfo createNewChangeForAllNotInTarget =
+        new InheritedBooleanInfo();
+    InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
+    InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
+    InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
+
+    useContributorAgreements.value = projectState.isUseContributorAgreements();
+    useSignedOffBy.value = projectState.isUseSignedOffBy();
+    useContentMerge.value = projectState.isUseContentMerge();
+    requireChangeId.value = projectState.isRequireChangeID();
+    createNewChangeForAllNotInTarget.value =
+        projectState.isCreateNewChangeForAllNotInTarget();
+
+    useContributorAgreements.configuredValue =
+        p.getUseContributorAgreements();
+    useSignedOffBy.configuredValue = p.getUseSignedOffBy();
+    useContentMerge.configuredValue = p.getUseContentMerge();
+    requireChangeId.configuredValue = p.getRequireChangeID();
+    createNewChangeForAllNotInTarget.configuredValue =
+        p.getCreateNewChangeForAllNotInTarget();
+    enableSignedPush.configuredValue = p.getEnableSignedPush();
+    requireSignedPush.configuredValue = p.getRequireSignedPush();
+    rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
+
+    ProjectState parentState = Iterables.getFirst(projectState
+        .parents(), null);
+    if (parentState != null) {
+      useContributorAgreements.inheritedValue =
+          parentState.isUseContributorAgreements();
+      useSignedOffBy.inheritedValue = parentState.isUseSignedOffBy();
+      useContentMerge.inheritedValue = parentState.isUseContentMerge();
+      requireChangeId.inheritedValue = parentState.isRequireChangeID();
+      createNewChangeForAllNotInTarget.inheritedValue =
+          parentState.isCreateNewChangeForAllNotInTarget();
+      enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
+      requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
+      rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
+    }
+
+    this.useContributorAgreements = useContributorAgreements;
+    this.useSignedOffBy = useSignedOffBy;
+    this.useContentMerge = useContentMerge;
+    this.requireChangeId = requireChangeId;
+    this.rejectImplicitMerges = rejectImplicitMerges;
+    this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+    if (serverEnableSignedPush) {
+      this.enableSignedPush = enableSignedPush;
+      this.requireSignedPush = requireSignedPush;
+    }
+
+    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
+    maxObjectSizeLimit.value =
+        config.getEffectiveMaxObjectSizeLimit(projectState) == config
+            .getMaxObjectSizeLimit() ? config
+            .getFormattedMaxObjectSizeLimit() : p.getMaxObjectSizeLimit();
+    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
+    maxObjectSizeLimit.inheritedValue =
+        config.getFormattedMaxObjectSizeLimit();
+    this.maxObjectSizeLimit = maxObjectSizeLimit;
+
+    this.submitType = p.getSubmitType();
+    this.state = p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE ? p.getState() : null;
+
+    this.commentlinks = new LinkedHashMap<>();
+    for (CommentLinkInfo cl : projectState.getCommentLinks()) {
+      this.commentlinks.put(cl.name, cl);
+    }
+
+    pluginConfig =
+        getPluginConfig(control.getProjectState(), pluginConfigEntries,
+            cfgFactory, allProjects);
+
+    actions = new TreeMap<>();
+    for (UiAction.Description d : UiActions.from(
+        views, new ProjectResource(control),
+        Providers.of(control.getUser()))) {
+      actions.put(d.getId(), new ActionInfo(d));
+    }
+    this.theme = projectState.getTheme();
+  }
+
+  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
+      ProjectState project, DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory, AllProjectsName allProjects) {
+    TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
+    for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+      ProjectConfigEntry configEntry = e.getProvider().get();
+      PluginConfig cfg =
+          cfgFactory.getFromProjectConfig(project, e.getPluginName());
+      String configuredValue = cfg.getString(e.getExportName());
+      ConfigParameterInfo p = new ConfigParameterInfo();
+      p.displayName = configEntry.getDisplayName();
+      p.description = configEntry.getDescription();
+      p.warning = configEntry.getWarning(project);
+      p.type = configEntry.getType();
+      p.permittedValues = configEntry.getPermittedValues();
+      p.editable = configEntry.isEditable(project) ? true : null;
+      if (configEntry.isInheritable()
+          && !allProjects.equals(project.getProject().getNameKey())) {
+        PluginConfig cfgWithInheritance =
+            cfgFactory.getFromProjectConfigWithInheritance(project,
+                e.getPluginName());
+        p.inheritable = true;
+        p.value = configEntry.onRead(project,
+            cfgWithInheritance.getString(e.getExportName(),
+                configEntry.getDefaultValue()));
+        p.configuredValue = configuredValue;
+        p.inheritedValue = getInheritedValue(project, cfgFactory, e);
+      } else {
+        if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+          p.values = configEntry.onRead(project,
+              Arrays.asList(cfg.getStringList(e.getExportName())));
+        } else {
+          p.value = configEntry.onRead(project, configuredValue != null
+              ? configuredValue
+              : configEntry.getDefaultValue());
+        }
+      }
+      Map<String, ConfigParameterInfo> pc = pluginConfig.get(e.getPluginName());
+      if (pc == null) {
+        pc = new TreeMap<>();
+        pluginConfig.put(e.getPluginName(), pc);
+      }
+      pc.put(e.getExportName(), p);
+    }
+    return !pluginConfig.isEmpty() ? pluginConfig : null;
+  }
+
+  private String getInheritedValue(ProjectState project,
+      PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
+    ProjectConfigEntry configEntry = e.getProvider().get();
+    ProjectState parent = Iterables.getFirst(project.parents(), null);
+    String inheritedValue = configEntry.getDefaultValue();
+    if (parent != null) {
+      PluginConfig parentCfgWithInheritance =
+          cfgFactory.getFromProjectConfigWithInheritance(parent,
+              e.getPluginName());
+      inheritedValue =
+          parentCfgWithInheritance.getString(e.getExportName(),
+              configEntry.getDefaultValue());
+    }
+    return inheritedValue;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
index 252b44a..c7b2922 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
@@ -14,38 +14,28 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.common.errors.InvalidRevisionException;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.CreateBranch.Input;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RevisionSyntaxException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.ObjectWalk;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -53,49 +43,43 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.util.Collections;
 
-public class CreateBranch implements RestModifyView<ProjectResource, Input> {
+public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateBranch.class);
 
-  public static class Input {
-    public String ref;
-
-    @DefaultInput
-    public String revision;
-  }
-
-  public static interface Factory {
+  public interface Factory {
     CreateBranch create(String ref);
   }
 
-  private final Provider<IdentifiedUser>  identifiedUser;
+  private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> db;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
+  private final RefValidationHelper refCreationValidator;
   private String ref;
 
   @Inject
   CreateBranch(Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       Provider<ReviewDb> db,
-      GitReferenceUpdated referenceUpdated, ChangeHooks hooks,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refHelperFactory,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.db = db;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
+    this.refCreationValidator =
+        refHelperFactory.create(ReceiveCommand.Type.CREATE);
     this.ref = ref;
   }
 
   @Override
-  public BranchInfo apply(ProjectResource rsrc, Input input)
+  public BranchInfo apply(ProjectResource rsrc, BranchInput input)
       throws BadRequestException, AuthException, ResourceConflictException,
       IOException {
     if (input == null) {
-      input = new Input();
+      input = new BranchInput();
     }
     if (input.ref != null && !ref.equals(input.ref)) {
       throw new BadRequestException("ref must match URL");
@@ -118,8 +102,8 @@
     final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
     final RefControl refControl = rsrc.getControl().controlForRef(name);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      final ObjectId revid = parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
-      final RevWalk rw = verifyConnected(repo, revid);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
+      RevWalk rw = RefUtil.verifyConnected(repo, revid);
       RevObject object = rw.parseAny(revid);
 
       if (ref.startsWith(Constants.R_HEADS)) {
@@ -133,8 +117,7 @@
         }
       }
 
-      rw.reset();
-      if (!refControl.canCreate(db.get(), rw, object)) {
+      if (!refControl.canCreate(db.get(), repo, object)) {
         throw new AuthException("Cannot create \"" + ref + "\"");
       }
 
@@ -144,29 +127,38 @@
         u.setNewObjectId(object.copy());
         u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
         u.setRefLogMessage("created via REST from " + input.revision, false);
+        refCreationValidator.validateRefOperation(
+            rsrc.getName(), identifiedUser.get(), u);
         final RefUpdate.Result result = u.update(rw);
         switch (result) {
           case FAST_FORWARD:
           case NEW:
           case NO_CHANGE:
-            referenceUpdated.fire(name.getParentKey(), u, ReceiveCommand.Type.CREATE);
-            hooks.doRefUpdatedHook(name, u, identifiedUser.get().getAccount());
+            referenceUpdated.fire(
+                name.getParentKey(), u, ReceiveCommand.Type.CREATE,
+                identifiedUser.get().getAccount());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
               throw new ResourceConflictException("branch \"" + ref
                   + "\" already exists");
             }
-            String refPrefix = getRefPrefix(ref);
+            String refPrefix = RefUtil.getRefPrefix(ref);
             while (!Constants.R_HEADS.equals(refPrefix)) {
               if (repo.getRefDatabase().exactRef(refPrefix) != null) {
                 throw new ResourceConflictException("Cannot create branch \""
                     + ref + "\" since it conflicts with branch \"" + refPrefix
                     + "\".");
               }
-              refPrefix = getRefPrefix(refPrefix);
+              refPrefix = RefUtil.getRefPrefix(refPrefix);
             }
             //$FALL-THROUGH$
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
           default: {
             throw new IOException(result.name());
           }
@@ -181,70 +173,8 @@
         log.error("Cannot create branch \"" + name + "\"", err);
         throw err;
       }
-    } catch (InvalidRevisionException e) {
+    } catch (RefUtil.InvalidRevisionException e) {
       throw new BadRequestException("invalid revision \"" + input.revision + "\"");
     }
   }
-
-  private static String getRefPrefix(final String refName) {
-    final int i = refName.lastIndexOf('/');
-    if (i > Constants.R_HEADS.length() - 1) {
-      return refName.substring(0, i);
-    }
-    return Constants.R_HEADS;
-  }
-
-  private ObjectId parseBaseRevision(Repository repo,
-      Project.NameKey projectName, String baseRevision)
-      throws InvalidRevisionException {
-    try {
-      final ObjectId revid = repo.resolve(baseRevision);
-      if (revid == null) {
-        throw new InvalidRevisionException();
-      }
-      return revid;
-    } catch (IOException err) {
-      log.error("Cannot resolve \"" + baseRevision + "\" in project \""
-          + projectName.get() + "\"", err);
-      throw new InvalidRevisionException();
-    } catch (RevisionSyntaxException err) {
-      log.error("Invalid revision syntax \"" + baseRevision + "\"", err);
-      throw new InvalidRevisionException();
-    }
-  }
-
-  private RevWalk verifyConnected(final Repository repo, final ObjectId revid)
-      throws InvalidRevisionException {
-    try {
-      final ObjectWalk rw = new ObjectWalk(repo);
-      try {
-        rw.markStart(rw.parseCommit(revid));
-      } catch (IncorrectObjectTypeException err) {
-        throw new InvalidRevisionException();
-      }
-      RefDatabase refDb = repo.getRefDatabase();
-      Iterable<Ref> refs = Iterables.concat(
-          refDb.getRefs(Constants.R_HEADS).values(),
-          refDb.getRefs(Constants.R_TAGS).values());
-      Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
-      if (rc != null) {
-        refs = Iterables.concat(refs, Collections.singleton(rc));
-      }
-      for (Ref r : refs) {
-        try {
-          rw.markUninteresting(rw.parseAny(r.getObjectId()));
-        } catch (MissingObjectException err) {
-          continue;
-        }
-      }
-      rw.checkConnectivity();
-      return rw;
-    } catch (IncorrectObjectTypeException | MissingObjectException err) {
-      throw new InvalidRevisionException();
-    } catch (IOException err) {
-      log.error("Repository \"" + repo.getDirectory()
-          + "\" may be corrupt; suggest running git fsck", err);
-      throw new InvalidRevisionException();
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index c04878f..fd6e225 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -41,12 +42,13 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
 import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
@@ -80,7 +82,7 @@
 
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
 public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
-  public static interface Factory {
+  public interface Factory {
     CreateProject create(String name);
   }
 
@@ -93,7 +95,7 @@
   private final ProjectJson json;
   private final ProjectControl.GenericFactory projectControlFactory;
   private final GitRepositoryManager repoManager;
-  private final DynamicSet<NewProjectCreatedListener> createdListener;
+  private final DynamicSet<NewProjectCreatedListener> createdListeners;
   private final ProjectCache projectCache;
   private final GroupBackend groupBackend;
   private final ProjectOwnerGroupsProvider.Factory projectOwnerGroups;
@@ -101,7 +103,7 @@
   private final GitReferenceUpdated referenceUpdated;
   private final RepositoryConfig repositoryCfg;
   private final PersonIdent serverIdent;
-  private final Provider<CurrentUser> currentUser;
+  private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final String name;
@@ -112,7 +114,7 @@
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       ProjectControl.GenericFactory projectControlFactory,
       GitRepositoryManager repoManager,
-      DynamicSet<NewProjectCreatedListener> createdListener,
+      DynamicSet<NewProjectCreatedListener> createdListeners,
       ProjectCache projectCache,
       GroupBackend groupBackend,
       ProjectOwnerGroupsProvider.Factory projectOwnerGroups,
@@ -120,7 +122,7 @@
       GitReferenceUpdated referenceUpdated,
       RepositoryConfig repositoryCfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      Provider<CurrentUser> currentUser,
+      Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       @Assisted String name) {
@@ -130,7 +132,7 @@
     this.json = json;
     this.projectControlFactory = projectControlFactory;
     this.repoManager = repoManager;
-    this.createdListener = createdListener;
+    this.createdListeners = createdListeners;
     this.projectCache = projectCache;
     this.groupBackend = groupBackend;
     this.projectOwnerGroups = projectOwnerGroups;
@@ -138,7 +140,7 @@
     this.referenceUpdated = referenceUpdated;
     this.repositoryCfg = repositoryCfg;
     this.serverIdent = serverIdent;
-    this.currentUser = currentUser;
+    this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
     this.name = name;
@@ -213,9 +215,9 @@
 
     if (input.pluginConfigValues != null) {
       try {
-        ProjectControl projectControl =
-            projectControlFactory.controlFor(p.getNameKey(), currentUser.get());
-        PutConfig.Input in = new PutConfig.Input();
+        ProjectControl projectControl = projectControlFactory.controlFor(
+            p.getNameKey(), identifiedUser.get());
+        ConfigInput in = new ConfigInput();
         in.pluginConfigValues = input.pluginConfigValues;
         putConfig.get().apply(projectControl, in);
       } catch (NoSuchProjectException e) {
@@ -226,7 +228,7 @@
     return Response.created(json.format(p));
   }
 
-  public Project createProject(CreateProjectArgs args)
+  private Project createProject(CreateProjectArgs args)
       throws BadRequestException, ResourceConflictException, IOException,
       ConfigInvalidException {
     final Project.NameKey nameKey = args.getProject();
@@ -253,24 +255,7 @@
           createEmptyCommits(repo, nameKey, args.branch);
         }
 
-        NewProjectCreatedListener.Event event = new NewProjectCreatedListener.Event() {
-          @Override
-          public String getProjectName() {
-            return nameKey.get();
-          }
-
-          @Override
-          public String getHeadName() {
-            return head;
-          }
-        };
-        for (NewProjectCreatedListener l : createdListener) {
-          try {
-            l.onNewProjectCreated(event);
-          } catch (RuntimeException e) {
-            log.warn("Failure in NewProjectCreatedListener", e);
-          }
-        }
+        fire(nameKey, head);
 
         return projectCache.get(nameKey).getProject();
       }
@@ -289,11 +274,8 @@
   }
 
   private void createProjectConfig(CreateProjectArgs args) throws IOException, ConfigInvalidException {
-    MetaDataUpdate md =
-        metaDataUpdateFactory.create(args.getProject());
-    try {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
       ProjectConfig config = ProjectConfig.read(md);
-      config.load(md);
 
       Project newProject = config.getProject();
       newProject.setDescription(args.projectDescription);
@@ -326,8 +308,6 @@
 
       md.setMessage("Created project\n");
       config.commit(md);
-    } finally {
-      md.close();
     }
     projectCache.onCreateProject(args.getProject());
     repoManager.setProjectDescription(args.getProject(),
@@ -375,8 +355,18 @@
         Result result = ru.update();
         switch (result) {
           case NEW:
-            referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE);
+            referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE,
+                identifiedUser.get().getAccount());
             break;
+          case FAST_FORWARD:
+          case FORCED:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
           default: {
             throw new IOException(String.format(
               "Failed to create ref \"%s\": %s", ref, result.name()));
@@ -390,4 +380,40 @@
       throw e;
     }
   }
+
+  private void fire(Project.NameKey name, String head) {
+    if (!createdListeners.iterator().hasNext()) {
+      return;
+    }
+
+    Event event = new Event(name, head);
+    for (NewProjectCreatedListener l : createdListeners) {
+      try {
+        l.onNewProjectCreated(event);
+      } catch (RuntimeException e) {
+        log.warn("Failure in NewProjectCreatedListener", e);
+      }
+    }
+  }
+
+  static class Event extends AbstractNoNotifyEvent
+      implements NewProjectCreatedListener.Event {
+    private final Project.NameKey name;
+    private final String head;
+
+    Event(Project.NameKey name, String head) {
+      this.name = name;
+      this.head = head;
+    }
+
+    @Override
+    public String getProjectName() {
+      return name.get();
+    }
+
+    @Override
+    public String getHeadName() {
+      return head;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
new file mode 100644
index 0000000..446fa72
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.TimeZone;
+
+public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
+  private static final Logger log = LoggerFactory.getLogger(CreateTag.class);
+
+  public interface Factory {
+    CreateTag create(String ref);
+  }
+
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final GitRepositoryManager repoManager;
+  private final TagCache tagCache;
+  private final GitReferenceUpdated referenceUpdated;
+  private String ref;
+
+  @Inject
+  CreateTag(Provider<IdentifiedUser> identifiedUser,
+      GitRepositoryManager repoManager,
+      TagCache tagCache,
+      GitReferenceUpdated referenceUpdated,
+      @Assisted String ref) {
+    this.identifiedUser = identifiedUser;
+    this.repoManager = repoManager;
+    this.tagCache = tagCache;
+    this.referenceUpdated = referenceUpdated;
+    this.ref = ref;
+  }
+
+  @Override
+  public TagInfo apply(ProjectResource resource, TagInput input)
+      throws RestApiException, IOException {
+    if (input == null) {
+      input = new TagInput();
+    }
+    if (input.ref != null && !ref.equals(input.ref)) {
+      throw new BadRequestException("ref must match URL");
+    }
+    if (input.revision == null) {
+      input.revision = Constants.HEAD;
+    }
+    while (ref.startsWith("/")) {
+      ref = ref.substring(1);
+    }
+    if (ref.startsWith(R_REFS) && !ref.startsWith(R_TAGS)) {
+      throw new BadRequestException("invalid tag name \"" + ref + "\"");
+    }
+    if (!ref.startsWith(R_TAGS)) {
+      ref = R_TAGS + ref;
+    }
+    if (!Repository.isValidRefName(ref)) {
+      throw new BadRequestException("invalid tag name \"" + ref + "\"");
+    }
+
+    RefControl refControl = resource.getControl().controlForRef(ref);
+    try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
+      ObjectId revid = RefUtil.parseBaseRevision(
+          repo, resource.getNameKey(), input.revision);
+      RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      RevObject object = rw.parseAny(revid);
+      rw.reset();
+      boolean isAnnotated = Strings.emptyToNull(input.message) != null;
+      boolean isSigned = isAnnotated
+          && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+      if (isSigned) {
+        throw new MethodNotAllowedException(
+            "Cannot create signed tag \"" + ref + "\"");
+      } else if (isAnnotated && !refControl.canPerform(Permission.PUSH_TAG)) {
+        throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+      } else if (!refControl.canPerform(Permission.CREATE)) {
+        throw new AuthException("Cannot create tag \"" + ref + "\"");
+      }
+      if (repo.getRefDatabase().exactRef(ref) != null) {
+        throw new ResourceConflictException(
+            "tag \"" + ref + "\" already exists");
+      }
+
+      try (Git git = new Git(repo)) {
+        TagCommand tag = git.tag()
+            .setObjectId(object)
+            .setName(ref.substring(R_TAGS.length()))
+            .setAnnotated(isAnnotated)
+            .setSigned(isSigned);
+
+        if (isAnnotated) {
+          tag.setMessage(input.message)
+             .setTagger(identifiedUser.get()
+                 .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+        }
+
+        Ref result = tag.call();
+        tagCache.updateFastForward(resource.getNameKey(), ref,
+            ObjectId.zeroId(), result.getObjectId());
+        referenceUpdated.fire(resource.getNameKey(), ref,
+            ObjectId.zeroId(), result.getObjectId(),
+            identifiedUser.get().getAccount());
+        try (RevWalk w = new RevWalk(repo)) {
+          return ListTags.createTagInfo(result, w);
+        }
+      }
+    } catch (InvalidRevisionException e) {
+      throw new BadRequestException("Invalid base revision");
+    } catch (GitAPIException e) {
+      log.error("Cannot create tag \"" + ref + "\"", e);
+      throw new IOException(e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index 4646e3b..a20c51e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -49,6 +49,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 
 @Singleton
@@ -80,7 +81,7 @@
   @Override
   public RestModifyView<ProjectResource, ?> create(ProjectResource parent,
       IdString id) throws RestApiException {
-    if (id.equals("default")) {
+    if (id.toString().equals("default")) {
       return createDefault.get();
     }
     throw new ResourceNotFoundException(id);
@@ -90,7 +91,7 @@
   public DashboardResource parse(ProjectResource parent, IdString id)
       throws ResourceNotFoundException, IOException, ConfigInvalidException {
     ProjectControl myCtl = parent.getControl();
-    if (id.equals("default")) {
+    if (id.toString().equals("default")) {
       return DashboardResource.projectDefault(myCtl);
     }
 
@@ -188,9 +189,8 @@
         Strings.nullToEmpty(proj.getDefaultDashboard()));
     if (defaultId.startsWith(REFS_DASHBOARDS)) {
       return defaultId.substring(REFS_DASHBOARDS.length());
-    } else {
-      return defaultId;
     }
+    return defaultId;
   }
 
   static class DashboardInfo {
@@ -207,7 +207,7 @@
     Boolean isDefault;
 
     String title;
-    List<Section> sections = Lists.newArrayList();
+    List<Section> sections = new ArrayList<>();
 
     DashboardInfo(String ref, String name) {
       this.ref = ref;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
index 2aa6b0b..732d47b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -14,25 +14,22 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.DeleteBranch.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.LockFailedException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -42,7 +39,7 @@
 import java.io.IOException;
 
 @Singleton
-public class DeleteBranch implements RestModifyView<BranchResource, Input>{
+public class DeleteBranch implements RestModifyView<BranchResource, Input> {
   private static final Logger log = LoggerFactory.getLogger(DeleteBranch.class);
   private static final int MAX_LOCK_FAILURE_CALLS = 10;
   private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
@@ -52,22 +49,22 @@
 
   private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
+  private final RefValidationHelper refDeletionValidator;
 
   @Inject
   DeleteBranch(Provider<IdentifiedUser> identifiedUser,
-      GitRepositoryManager repoManager, Provider<ReviewDb> dbProvider,
+      GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
-      GitReferenceUpdated referenceUpdated, ChangeHooks hooks) {
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refHelperFactory) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
-    this.dbProvider = dbProvider;
     this.queryProvider = queryProvider;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
+    this.refDeletionValidator =
+        refHelperFactory.create(ReceiveCommand.Type.DELETE);
   }
 
   @Override
@@ -84,8 +81,13 @@
 
     try (Repository r = repoManager.openRepository(rsrc.getNameKey())) {
       RefUpdate.Result result;
-      RefUpdate u = r.updateRef(rsrc.getRef());
+      String ref = rsrc.getRef();
+      RefUpdate u = r.updateRef(ref);
+      u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
+      u.setNewObjectId(ObjectId.zeroId());
       u.setForceUpdate(true);
+      refDeletionValidator.validateRefOperation(
+          rsrc.getName(), identifiedUser.get(), u);
       int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
       for (;;) {
         try {
@@ -113,17 +115,19 @@
         case NO_CHANGE:
         case FAST_FORWARD:
         case FORCED:
-          referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE);
-          hooks.doRefUpdatedHook(rsrc.getBranchKey(), u, identifiedUser.get().getAccount());
-          ResultSet<SubmoduleSubscription> submoduleSubscriptions =
-            dbProvider.get().submoduleSubscriptions().bySuperProject(rsrc.getBranchKey());
-          dbProvider.get().submoduleSubscriptions().delete(submoduleSubscriptions);
+          referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE,
+              identifiedUser.get().getAccount());
           break;
 
         case REJECTED_CURRENT_BRANCH:
           log.error("Cannot delete " + rsrc.getBranchKey() + ": " + result.name());
           throw new ResourceConflictException("cannot delete current branch");
 
+        case IO_FAILURE:
+        case LOCK_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case RENAMED:
         default:
           log.error("Cannot delete " + rsrc.getBranchKey() + ": " + result.name());
           throw new ResourceConflictException("cannot delete branch: " + result.name());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index e420771..7d53fec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -17,20 +17,16 @@
 import static java.lang.String.format;
 
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.DeleteBranches.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -39,6 +35,7 @@
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -47,52 +44,43 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.util.List;
 
 @Singleton
-class DeleteBranches implements RestModifyView<ProjectResource, Input> {
+public class DeleteBranches
+    implements RestModifyView<ProjectResource, DeleteBranchesInput> {
   private static final Logger log = LoggerFactory.getLogger(DeleteBranches.class);
 
-  static class Input {
-    List<String> branches;
-
-    static Input init(Input in) {
-      if (in == null) {
-        in = new Input();
-      }
-      if (in.branches == null) {
-        in.branches = Lists.newArrayListWithCapacity(1);
-      }
-      return in;
-    }
-  }
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
+  private final RefValidationHelper refDeletionValidator;
 
   @Inject
   DeleteBranches(Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
-      Provider<ReviewDb> dbProvider,
       Provider<InternalChangeQuery> queryProvider,
       GitReferenceUpdated referenceUpdated,
-      ChangeHooks hooks) {
+      RefValidationHelper.Factory refHelperFactory) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
-    this.dbProvider = dbProvider;
     this.queryProvider = queryProvider;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
+    this.refDeletionValidator =
+        refHelperFactory.create(ReceiveCommand.Type.DELETE);
   }
 
   @Override
-  public Response<?> apply(ProjectResource project, Input input)
+  public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
       throws OrmException, IOException, ResourceConflictException {
-    input = Input.init(input);
+
+    if (input == null) {
+      input = new DeleteBranchesInput();
+    }
+    if (input.branches == null) {
+      input.branches = Lists.newArrayListWithCapacity(1);
+    }
+
     try (Repository r = repoManager.openRepository(project.getNameKey())) {
       BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
       for (String branch : input.branches) {
@@ -117,7 +105,8 @@
   }
 
   private ReceiveCommand createDeleteCommand(ProjectResource project,
-      Repository r, String branch) throws OrmException, IOException {
+      Repository r, String branch)
+          throws OrmException, IOException, ResourceConflictException {
     Ref ref = r.getRefDatabase().getRef(branch);
     ReceiveCommand command;
     if (ref == null) {
@@ -137,6 +126,12 @@
     if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
       command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
     }
+    RefUpdate u = r.updateRef(branch);
+    u.setExpectedOldObjectId(r.exactRef(branch).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
+    u.setForceUpdate(true);
+    refDeletionValidator.validateRefOperation(
+        project.getName(), identifiedUser.get(), u);
     return command;
   }
 
@@ -151,6 +146,13 @@
       case REJECTED_OTHER_REASON:
         msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getMessage());
         break;
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case OK:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_NOCREATE:
+      case REJECTED_NODELETE:
+      case REJECTED_NONFASTFORWARD:
       default:
         msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
         break;
@@ -160,15 +162,8 @@
     errorMessages.append("\n");
   }
 
-  private void postDeletion(ProjectResource project, ReceiveCommand cmd)
-      throws OrmException {
-    referenceUpdated.fire(project.getNameKey(), cmd);
-    Branch.NameKey branchKey =
-        new Branch.NameKey(project.getNameKey(), cmd.getRefName());
-    hooks.doRefUpdatedHook(branchKey, cmd.getOldId(), cmd.getNewId(),
+  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
+    referenceUpdated.fire(project.getNameKey(), cmd,
         identifiedUser.get().getAccount());
-    ResultSet<SubmoduleSubscription> submoduleSubscriptions =
-        dbProvider.get().submoduleSubscriptions().bySuperProject(branchKey);
-    dbProvider.get().submoduleSubscriptions().delete(submoduleSubscriptions);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
index 03dc97c..3b50129 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
@@ -20,13 +20,19 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.project.GarbageCollect.Input;
+import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.io.IOException;
@@ -42,22 +48,60 @@
   public static class Input {
     public boolean showProgress;
     public boolean aggressive;
+    public boolean async;
   }
 
   private final boolean canGC;
-  private GarbageCollection.Factory garbageCollectionFactory;
+  private final GarbageCollection.Factory garbageCollectionFactory;
+  private final WorkQueue workQueue;
+  private final Provider<String> canonicalUrl;
 
   @Inject
-  GarbageCollect(
-      GitRepositoryManager repoManager,
-      GarbageCollection.Factory garbageCollectionFactory) {
+  GarbageCollect(GitRepositoryManager repoManager,
+      GarbageCollection.Factory garbageCollectionFactory, WorkQueue workQueue,
+      @CanonicalWebUrl Provider<String> canonicalUrl) {
+    this.workQueue = workQueue;
+    this.canonicalUrl = canonicalUrl;
     this.canGC = repoManager instanceof LocalDiskRepositoryManager;
     this.garbageCollectionFactory = garbageCollectionFactory;
   }
 
-  @SuppressWarnings("resource")
   @Override
-  public BinaryResult apply(final ProjectResource rsrc, final Input input) {
+  public Object apply(ProjectResource rsrc, Input input) {
+    Project.NameKey project = rsrc.getNameKey();
+    if (input.async) {
+      return applyAsync(project, input);
+    }
+    return applySync(project, input);
+  }
+
+  private Response.Accepted applyAsync(final Project.NameKey project, final Input input) {
+    Runnable job = new Runnable() {
+      @Override
+      public void run() {
+       runGC(project, input, null);
+      }
+
+      @Override
+      public String toString() {
+        return "Run " + (input.aggressive ? "aggressive " : "")
+            + "garbage collection on project " + project.get();
+      }
+    };
+
+    @SuppressWarnings("unchecked")
+    WorkQueue.Task<Void> task =
+        (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
+
+    String location = canonicalUrl.get() + "a/config/server/tasks/"
+            + IdGenerator.format(task.getTaskId());
+
+    return Response.accepted(location);
+  }
+
+  @SuppressWarnings("resource")
+  private BinaryResult applySync(final Project.NameKey project,
+      final Input input) {
     return new BinaryResult() {
       @Override
       public void writeTo(OutputStream out) throws IOException {
@@ -69,10 +113,8 @@
           }
         };
         try {
-          GarbageCollectionResult result =
-              garbageCollectionFactory.create().run(
-                  Collections.singletonList(rsrc.getNameKey()), input.aggressive,
-                  input.showProgress ? writer : null);
+          PrintWriter progressWriter = input.showProgress ? writer : null;
+          GarbageCollectionResult result = runGC(project, input, progressWriter);
           String msg = "Garbage collection completed successfully.";
           if (result.hasErrors()) {
             for (GarbageCollectionResult.Error e : result.getErrors()) {
@@ -104,6 +146,13 @@
      .disableGzip();
   }
 
+  GarbageCollectionResult runGC(Project.NameKey project,
+      Input input, PrintWriter progressWriter) {
+    return garbageCollectionFactory.create().run(
+        Collections.singletonList(project), input.aggressive,
+        progressWriter);
+  }
+
   @Override
   public UiAction.Description getDescription(ProjectResource rsrc) {
     return new UiAction.Description()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
new file mode 100644
index 0000000..8effe44
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
@@ -0,0 +1,271 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+@Singleton
+public class GetAccess implements RestReadView<ProjectResource> {
+
+  public static final BiMap<PermissionRule.Action,
+      PermissionRuleInfo.Action> ACTION_TYPE = ImmutableBiMap.of(
+          PermissionRule.Action.ALLOW, PermissionRuleInfo.Action.ALLOW,
+          PermissionRule.Action.BATCH, PermissionRuleInfo.Action.BATCH,
+          PermissionRule.Action.BLOCK, PermissionRuleInfo.Action.BLOCK,
+          PermissionRule.Action.DENY, PermissionRuleInfo.Action.DENY,
+          PermissionRule.Action.INTERACTIVE,
+          PermissionRuleInfo.Action.INTERACTIVE);
+
+  private final Provider<CurrentUser> self;
+  private final GroupControl.Factory groupControlFactory;
+  private final AllProjectsName allProjectsName;
+  private final ProjectJson projectJson;
+  private final ProjectCache projectCache;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectControl.GenericFactory projectControlFactory;
+  private final GroupBackend groupBackend;
+
+  @Inject
+  public GetAccess(Provider<CurrentUser> self,
+      GroupControl.Factory groupControlFactory,
+      AllProjectsName allProjectsName,
+      ProjectCache projectCache,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectJson projectJson,
+      ProjectControl.GenericFactory projectControlFactory,
+      GroupBackend groupBackend) {
+    this.self = self;
+    this.groupControlFactory = groupControlFactory;
+    this.allProjectsName = allProjectsName;
+    this.projectJson = projectJson;
+    this.projectCache = projectCache;
+    this.projectControlFactory = projectControlFactory;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.groupBackend = groupBackend;
+  }
+
+  public ProjectAccessInfo apply(Project.NameKey nameKey)
+      throws ResourceNotFoundException, ResourceConflictException, IOException {
+    try {
+      return this.apply(new ProjectResource(
+          projectControlFactory.controlFor(nameKey, self.get())));
+    } catch (NoSuchProjectException e) {
+      throw new ResourceNotFoundException(nameKey.get());
+    }
+  }
+
+  @Override
+  public ProjectAccessInfo apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, ResourceConflictException, IOException {
+    // Load the current configuration from the repository, ensuring it's the most
+    // recent version available. If it differs from what was in the project
+    // state, force a cache flush now.
+    //
+    Project.NameKey projectName = rsrc.getNameKey();
+    ProjectAccessInfo info = new ProjectAccessInfo();
+    ProjectConfig config;
+    ProjectControl pc = open(projectName);
+    RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
+      config = ProjectConfig.read(md);
+
+      if (config.updateGroupNames(groupBackend)) {
+        md.setMessage("Update group names\n");
+        config.commit(md);
+        projectCache.evict(config.getProject());
+        pc = open(projectName);
+      } else if (config.getRevision() != null
+          && !config.getRevision().equals(
+          pc.getProjectState().getConfig().getRevision())) {
+        projectCache.evict(config.getProject());
+        pc = open(projectName);
+      }
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(e.getMessage());
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(rsrc.getName());
+    }
+
+    info.local = new HashMap<>();
+    info.ownerOf = new HashSet<>();
+    Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
+
+    for (AccessSection section : config.getAccessSections()) {
+      String name = section.getName();
+      if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
+        if (pc.isOwner()) {
+          info.local.put(name, createAccessSection(section));
+          info.ownerOf.add(name);
+
+        } else if (metaConfigControl.isVisible()) {
+          info.local.put(section.getName(), createAccessSection(section));
+        }
+
+      } else if (RefConfigSection.isValid(name)) {
+        RefControl rc = pc.controlForRef(name);
+        if (rc.isOwner()) {
+          info.local.put(name, createAccessSection(section));
+          info.ownerOf.add(name);
+
+        } else if (metaConfigControl.isVisible()) {
+          info.local.put(name, createAccessSection(section));
+
+        } else if (rc.isVisible()) {
+          // Filter the section to only add rules describing groups that
+          // are visible to the current-user. This includes any group the
+          // user is a member of, as well as groups they own or that
+          // are visible to all users.
+
+          AccessSection dst = null;
+          for (Permission srcPerm : section.getPermissions()) {
+            Permission dstPerm = null;
+
+            for (PermissionRule srcRule : srcPerm.getRules()) {
+              AccountGroup.UUID group = srcRule.getGroup().getUUID();
+              if (group == null) {
+                continue;
+              }
+
+              Boolean canSeeGroup = visibleGroups.get(group);
+              if (canSeeGroup == null) {
+                try {
+                  canSeeGroup = groupControlFactory.controlFor(group)
+                      .isVisible();
+                } catch (NoSuchGroupException e) {
+                  canSeeGroup = Boolean.FALSE;
+                }
+                visibleGroups.put(group, canSeeGroup);
+              }
+
+              if (canSeeGroup) {
+                if (dstPerm == null) {
+                  if (dst == null) {
+                    dst = new AccessSection(name);
+                    info.local.put(name, createAccessSection(dst));
+                  }
+                  dstPerm = dst.getPermission(srcPerm.getName(), true);
+                }
+                dstPerm.add(srcRule);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    if (info.ownerOf.isEmpty() && pc.isOwnerAnyRef()) {
+      // Special case: If the section list is empty, this project has no current
+      // access control information. Rely on what ProjectControl determines
+      // is ownership, which probably means falling back to site administrators.
+      info.ownerOf.add(AccessSection.ALL);
+    }
+
+    if (config.getRevision() != null) {
+      info.revision = config.getRevision().name();
+    }
+
+    ProjectState parent =
+        Iterables.getFirst(pc.getProjectState().parents(), null);
+    if (parent != null) {
+      info.inheritsFrom = projectJson.format(parent.getProject());
+    }
+
+    if (pc.getProject().getNameKey().equals(allProjectsName)) {
+      if (pc.isOwner()) {
+        info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
+      }
+    }
+
+    info.isOwner = toBoolean(pc.isOwner());
+    info.canUpload = toBoolean(pc.isOwner()
+        || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
+    info.canAdd = toBoolean(pc.canAddRefs());
+    info.configVisible = pc.isOwner() || metaConfigControl.isVisible();
+
+    return info;
+  }
+
+  private AccessSectionInfo createAccessSection(AccessSection section) {
+    AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
+    accessSectionInfo.permissions = new HashMap<>();
+    for (Permission p : section.getPermissions()) {
+      PermissionInfo pInfo = new PermissionInfo(p.getLabel(),
+          p.getExclusiveGroup() ? true : null);
+      pInfo.rules = new HashMap<>();
+      for (PermissionRule r : p.getRules()) {
+        PermissionRuleInfo info = new PermissionRuleInfo(
+            ACTION_TYPE.get(r.getAction()), r.getForce());
+        if (r.hasRange()) {
+          info.max = r.getMax();
+          info.min = r.getMin();
+        }
+        AccountGroup.UUID group = r.getGroup().getUUID();
+        if (group != null) {
+          pInfo.rules.put(group.get(), info);
+        }
+      }
+      accessSectionInfo.permissions.put(p.getName(), pInfo);
+    }
+    return accessSectionInfo;
+  }
+
+  private ProjectControl open(Project.NameKey projectName)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return projectControlFactory.validateFor(projectName,
+          ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
+    } catch (NoSuchProjectException e) {
+      throw new ResourceNotFoundException(projectName.get());
+    }
+  }
+
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index 469da93..c999119 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.TransferConfig;
@@ -31,7 +32,7 @@
   private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
-  private final AllProjectsNameProvider allProjects;
+  private final AllProjectsName allProjects;
   private final DynamicMap<RestView<ProjectResource>> views;
 
   @Inject
@@ -39,7 +40,7 @@
       TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
-      AllProjectsNameProvider allProjects,
+      AllProjectsName allProjects,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.config = config;
@@ -51,7 +52,7 @@
 
   @Override
   public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfo(serverEnableSignedPush, resource.getControl(), config,
-        pluginConfigEntries, cfgFactory, allProjects, views);
+    return new ConfigInfoImpl(serverEnableSignedPush, resource.getControl(),
+        config, pluginConfigEntries, cfgFactory, allProjects, views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
index f2b5fb8..12ca2eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
@@ -62,7 +62,7 @@
       } else if (head.getObjectId() != null) {
         try (RevWalk rw = new RevWalk(repo)) {
           RevCommit commit = rw.parseCommit(head.getObjectId());
-          if (rsrc.getControl().canReadCommit(db.get(), rw, commit)) {
+          if (rsrc.getControl().canReadCommit(db.get(), repo, commit)) {
             return head.getObjectId().name();
           }
           throw new AuthException("not allowed to see HEAD");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index b2596be..0c2f9fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.inject.Inject;
 
 import org.kohsuke.args4j.Option;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -42,10 +41,10 @@
 
   @Inject
   ListChildProjects(ProjectCache projectCache,
-      AllProjectsNameProvider allProjectsNameProvider,
+      AllProjectsName allProjectsName,
       ProjectJson json, ProjectNode.Factory projectNodeFactory) {
     this.projectCache = projectCache;
-    this.allProjects = allProjectsNameProvider.get();
+    this.allProjects = allProjectsName;
     this.json = json;
     this.projectNodeFactory = projectNodeFactory;
   }
@@ -59,13 +58,12 @@
     if (recursive) {
       return getChildProjectsRecursively(rsrc.getNameKey(),
           rsrc.getControl().getUser());
-    } else {
-      return getDirectChildProjects(rsrc.getNameKey());
     }
+    return getDirectChildProjects(rsrc.getNameKey());
   }
 
   private List<ProjectInfo> getDirectChildProjects(Project.NameKey parent) {
-    List<ProjectInfo> childProjects = Lists.newArrayList();
+    List<ProjectInfo> childProjects = new ArrayList<>();
     for (Project.NameKey projectName : projectCache.all()) {
       ProjectState e = projectCache.get(projectName);
       if (e == null) {
@@ -81,7 +79,7 @@
 
   private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent,
       CurrentUser user) {
-    Map<Project.NameKey, ProjectNode> projects = Maps.newHashMap();
+    Map<Project.NameKey, ProjectNode> projects = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
       ProjectState p = projectCache.get(name);
       if (p == null) {
@@ -101,13 +99,12 @@
     ProjectNode n = projects.get(parent);
     if (n != null) {
       return getChildProjectsRecursively(n);
-    } else {
-      return Collections.emptyList();
     }
+    return Collections.emptyList();
   }
 
   private List<ProjectInfo> getChildProjectsRecursively(ProjectNode p) {
-    List<ProjectInfo> allChildren = Lists.newArrayList();
+    List<ProjectInfo> allChildren = new ArrayList<>();
     for (ProjectNode c : p.getChildren()) {
       if (c.isVisible()) {
         allChildren.add(json.format(c.getProject()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index b4bd9a3..2546ac6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -37,6 +36,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 
 class ListDashboards implements RestReadView<ProjectResource> {
@@ -61,7 +61,7 @@
       return scan(resource.getControl(), project, true);
     }
 
-    List<List<DashboardInfo>> all = Lists.newArrayList();
+    List<List<DashboardInfo>> all = new ArrayList<>();
     boolean setDefault = true;
     for (ProjectState ps : ctl.getProjectState().tree()) {
       ctl = ps.controlFor(ctl.getUser());
@@ -85,7 +85,7 @@
     Project.NameKey projectName = ctl.getProject().getNameKey();
     try (Repository git = gitManager.openRepository(projectName);
         RevWalk rw = new RevWalk(git)) {
-      List<DashboardInfo> all = Lists.newArrayList();
+      List<DashboardInfo> all = new ArrayList<>();
       for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
         if (ctl.controlForRef(ref.getName()).canRead()) {
           all.addAll(scanDashboards(ctl.getProject(), git, rw, ref,
@@ -101,7 +101,7 @@
   private List<DashboardInfo> scanDashboards(Project definingProject,
       Repository git, RevWalk rw, Ref ref, String project, boolean setDefault)
       throws IOException {
-    List<DashboardInfo> list = Lists.newArrayList();
+    List<DashboardInfo> list = new ArrayList<>();
     try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
       tw.addTree(rw.parseTree(ref.getObjectId()));
       tw.setRecursive(true);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index 0704cb9..bf17a37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -21,8 +21,6 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -61,8 +59,11 @@
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -76,7 +77,7 @@
 public class ListProjects implements RestReadView<TopLevelResource> {
   private static final Logger log = LoggerFactory.getLogger(ListProjects.class);
 
-  public static enum FilterType {
+  public enum FilterType {
     CODE {
       @Override
       boolean matches(Repository git) throws IOException {
@@ -179,9 +180,9 @@
     this.groupUuid = groupUuid;
   }
 
-  private final List<String> showBranch = Lists.newArrayList();
+  private final List<String> showBranch = new ArrayList<>();
   private boolean showTree;
-  private FilterType type = FilterType.CODE;
+  private FilterType type = FilterType.ALL;
   private boolean showDescription;
   private boolean all;
   private int limit;
@@ -256,8 +257,8 @@
 
     int foundIndex = 0;
     int found = 0;
-    TreeMap<String, ProjectInfo> output = Maps.newTreeMap();
-    Map<String, String> hiddenNames = Maps.newHashMap();
+    TreeMap<String, ProjectInfo> output = new TreeMap<>();
+    Map<String, String> hiddenNames = new HashMap<>();
     Set<String> rejected = new HashSet<>();
 
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
@@ -357,7 +358,7 @@
                   Ref ref = refs.get(i);
                   if (ref != null && ref.getObjectId() != null) {
                     if (info.branches == null) {
-                      info.branches = Maps.newLinkedHashMap();
+                      info.branches = new LinkedHashMap<>();
                     }
                     info.branches.put(showBranch.get(i), ref.getObjectId().name());
                   }
@@ -507,7 +508,7 @@
     Ref[] result = new Ref[showBranch.size()];
     try (Repository git = repoManager.openRepository(projectName)) {
       for (int i = 0; i < showBranch.size(); i++) {
-        Ref ref = git.getRef(showBranch.get(i));
+        Ref ref = git.findRef(showBranch.get(i));
         if (ref != null
           && ref.getObjectId() != null
           && (projectControl.controlForRef(ref.getLeaf().getName()).isVisible())
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 927d205..6088c54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -24,10 +24,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -43,6 +44,7 @@
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
@@ -52,7 +54,8 @@
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> dbProvider;
   private final TagCache tagCache;
-  private final ChangeCache changeCache;
+  private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final SearchingChangeCacheImpl changeCache;
 
   @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of tags to list")
   public void setLimit(int limit) {
@@ -83,17 +86,19 @@
   public ListTags(GitRepositoryManager repoManager,
       Provider<ReviewDb> dbProvider,
       TagCache tagCache,
-      ChangeCache changeCache) {
+      ChangeNotes.Factory changeNotesFactory,
+      @Nullable SearchingChangeCacheImpl changeCache) {
     this.repoManager = repoManager;
     this.dbProvider = dbProvider;
     this.tagCache = tagCache;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
   }
 
   @Override
   public List<TagInfo> apply(ProjectResource resource) throws IOException,
       ResourceNotFoundException, BadRequestException {
-    List<TagInfo> tags = Lists.newArrayList();
+    List<TagInfo> tags = new ArrayList<>();
 
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
@@ -136,22 +141,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private Repository getRepository(Project.NameKey project)
-      throws ResourceNotFoundException, IOException {
-    try {
-      return repoManager.openRepository(project);
-    } catch (RepositoryNotFoundException noGitRepository) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
-      Map<String, Ref> tags) {
-    return new VisibleRefFilter(tagCache, changeCache, repo,
-        control, dbProvider.get(), false).filter(tags, true);
-  }
-
-  private static TagInfo createTagInfo(Ref ref, RevWalk rw)
+  public static TagInfo createTagInfo(Ref ref, RevWalk rw)
       throws MissingObjectException, IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
     if (object instanceof RevTag) {
@@ -165,11 +155,25 @@
           tag.getFullMessage().trim(),
           tagger != null ?
               CommonConverters.toGitPerson(tag.getTaggerIdent()) : null);
-    } else {
-      // Lightweight tag
-      return new TagInfo(
-          ref.getName(),
-          ref.getObjectId().getName());
     }
+    // Lightweight tag
+    return new TagInfo(
+        ref.getName(),
+        ref.getObjectId().getName());
+  }
+
+  private Repository getRepository(Project.NameKey project)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return repoManager.openRepository(project);
+    } catch (RepositoryNotFoundException noGitRepository) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
+      Map<String, Ref> tags) {
+    return new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo,
+        control, dbProvider.get(), false).filter(tags, true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index 3ad6f8f..8a6145a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -45,6 +45,9 @@
     put(PROJECT_KIND, "description").to(PutDescription.class);
     delete(PROJECT_KIND, "description").to(PutDescription.class);
 
+    get(PROJECT_KIND, "access").to(GetAccess.class);
+    post(PROJECT_KIND, "access").to(SetAccess.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
@@ -65,6 +68,8 @@
     delete(BRANCH_KIND).to(DeleteBranch.class);
     post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
     factory(CreateBranch.Factory.class);
+    get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
+    factory(RefValidationHelper.Factory.class);
     get(BRANCH_KIND, "reflog").to(GetReflog.class);
     child(BRANCH_KIND, "files").to(FilesCollection.class);
     get(FILE_KIND, "content").to(GetContent.class);
@@ -75,6 +80,8 @@
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     get(TAG_KIND).to(GetTag.class);
+    put(TAG_KIND).to(PutTag.class);
+    factory(CreateTag.Factory.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
     get(DASHBOARD_KIND).to(GetDashboard.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
index a5cf5d6..ff0fd2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.project.RefControl.isRE;
+import static com.google.gerrit.server.project.RefPattern.isRE;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Lists;
@@ -26,15 +26,15 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -64,24 +64,23 @@
      *        priority order (project specific definitions must appear before
      *        inherited ones).
      * @param ref reference being accessed.
-     * @param usernameProvider if the reference is a per-user reference, access
-     *        sections using the parameter variable "${username}" will first
-     *        have each of {@code usernames} inserted into them before seeing if
-     *        they apply to the reference named by {@code ref}.
+     * @param user if the reference is a per-user reference, e.g. access
+     *        sections using the parameter variable "${username}" will have
+     *        each username inserted into them to see if they apply to the
+     *        reference named by {@code ref}.
      * @return map of permissions that apply to this reference, keyed by
      *         permission name.
      */
     PermissionCollection filter(Iterable<SectionMatcher> matcherList,
-        String ref, Provider<? extends Collection<String>> usernameProvider) {
+        String ref, CurrentUser user) {
       if (isRE(ref)) {
-        ref = RefControl.shortestExample(ref);
+        ref = RefPattern.shortestExample(ref);
       } else if (ref.endsWith("/*")) {
         ref = ref.substring(0, ref.length() - 1);
       }
 
-      Collection<String> usernames = null;
       boolean perUser = false;
-      Map<AccessSection, Project.NameKey> sectionToProject = Maps.newLinkedHashMap();
+      Map<AccessSection, Project.NameKey> sectionToProject = new LinkedHashMap<>();
       for (SectionMatcher sm : matcherList) {
         // If the matcher has to expand parameters and its prefix matches the
         // reference there is a very good chance the reference is actually user
@@ -100,14 +99,8 @@
             continue;
           }
           perUser = true;
-          if (usernames == null) {
-            usernames = usernameProvider.get();
-          }
-          for (String username : usernames) {
-            if (sm.match(ref, username)) {
-              sectionToProject.put(sm.section, sm.project);
-              break;
-            }
+          if (sm.match(ref, user)) {
+            sectionToProject.put(sm.section, sm.project);
           }
         } else if (sm.match(ref, null)) {
           sectionToProject.put(sm.section, sm.project);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
index d451b46..67bdc88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
@@ -23,10 +23,10 @@
 /** Cache of project information, including access rights. */
 public interface ProjectCache {
   /** @return the parent state for all projects on this server. */
-  public ProjectState getAllProjects();
+  ProjectState getAllProjects();
 
   /** @return the project state of the project storing meta data for all users. */
-  public ProjectState getAllUsers();
+  ProjectState getAllUsers();
 
   /**
    * Get the cached data for a project by its unique name.
@@ -35,7 +35,7 @@
    * @return the cached data; null if no such project exists or a error occurred.
    * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
    */
-  public ProjectState get(Project.NameKey projectName);
+  ProjectState get(Project.NameKey projectName);
 
   /**
    * Get the cached data for a project by its unique name.
@@ -44,14 +44,14 @@
    * @throws IOException when there was an error.
    * @return the cached data; null if no such project exists.
    */
-  public ProjectState checkedGet(Project.NameKey projectName)
+  ProjectState checkedGet(Project.NameKey projectName)
       throws IOException;
 
   /** Invalidate the cached information about the given project. */
-  public void evict(Project p);
+  void evict(Project p);
 
   /** Invalidate the cached information about the given project. */
-  public void evict(Project.NameKey p);
+  void evict(Project.NameKey p);
 
   /**
    * Remove information about the given project from the cache. It will no
@@ -60,14 +60,14 @@
   void remove(Project p);
 
   /** @return sorted iteration of projects. */
-  public abstract Iterable<Project.NameKey> all();
+  Iterable<Project.NameKey> all();
 
   /**
    * @return estimated set of relevant groups extracted from hot project access
    *         rules. If the cache is cold or too small for the entire project set
    *         of the server, this set may be incomplete.
    */
-  public abstract Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
+  Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
 
   /**
    * Filter the set of registered project names by common prefix.
@@ -75,8 +75,8 @@
    * @param prefix common prefix.
    * @return sorted iteration of projects sharing the same prefix.
    */
-  public abstract Iterable<Project.NameKey> byName(String prefix);
+  Iterable<Project.NameKey> byName(String prefix);
 
   /** Notify the cache that a new project was constructed. */
-  public void onCreateProject(Project.NameKey newProjectName);
+  void onCreateProject(Project.NameKey newProjectName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index ad94d64..8a08052 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.base.Predicate;
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -40,6 +43,7 @@
 
 import java.io.IOException;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.NoSuchElementException;
 import java.util.Set;
@@ -57,6 +61,14 @@
   private static final String CACHE_NAME = "projects";
   private static final String CACHE_LIST = "project_list";
 
+  private static final Predicate<AccountGroup.UUID> NON_NULL_UUID =
+      new Predicate<AccountGroup.UUID>() {
+        @Override
+        public boolean apply(AccountGroup.UUID uuid) {
+          return uuid != null && uuid.get() != null;
+        }
+      };
+
   public static Module module() {
     return new CacheModule() {
       @Override
@@ -199,22 +211,25 @@
   }
 
   @Override
-  public Iterable<Project.NameKey> all() {
+  public SortedSet<Project.NameKey> all() {
     try {
       return list.get(ListKey.ALL);
     } catch (ExecutionException e) {
       log.warn("Cannot list available projects", e);
-      return Collections.emptyList();
+      return ImmutableSortedSet.of();
     }
   }
 
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    Set<AccountGroup.UUID> groups = Sets.newHashSet();
+    Set<AccountGroup.UUID> groups = new HashSet<>();
     for (Project.NameKey n : all()) {
       ProjectState p = byName.getIfPresent(n.get());
       if (p != null) {
-        groups.addAll(p.getConfig().getAllGroupUUIDs());
+        groups.addAll(FluentIterable
+            .from(p.getConfig().getAllGroupUUIDs())
+            .filter(NON_NULL_UUID)
+            .toSet());
       }
     }
     return groups;
@@ -249,10 +264,9 @@
             if (r.get().startsWith(pfx)) {
               next = r;
               return true;
-            } else {
-              itr = Collections.<Project.NameKey> emptyList().iterator();
-              return false;
             }
+            itr = Collections.<Project.NameKey> emptyList().iterator();
+            return false;
           }
 
           @Override
@@ -278,20 +292,26 @@
   static class Loader extends CacheLoader<String, ProjectState> {
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
+    private final ProjectCacheClock clock;
 
     @Inject
-    Loader(ProjectState.Factory psf, GitRepositoryManager g) {
+    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
       projectStateFactory = psf;
       mgr = g;
+      this.clock = clock;
     }
 
     @Override
     public ProjectState load(String projectName) throws Exception {
+      long now = clock.read();
       Project.NameKey key = new Project.NameKey(projectName);
       try (Repository git = mgr.openRepository(key)) {
         ProjectConfig cfg = new ProjectConfig(key);
         cfg.load(git);
-        return projectStateFactory.create(cfg);
+
+        ProjectState state = projectStateFactory.create(cfg);
+        state.initLastCheck(now);
+        return state;
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index 9116dcc..22e5d69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -38,11 +37,12 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.ChangeCache;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -147,12 +147,12 @@
   private final String canonicalWebUrl;
   private final CurrentUser user;
   private final ProjectState state;
-  private final GitRepositoryManager repoManager;
-  private final ChangeControl.AssistedFactory changeControlFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final Collection<ContributorAgreement> contributorAgreements;
   private final TagCache tagCache;
-  private final ChangeCache changeCache;
+  @Nullable private final SearchingChangeCacheImpl changeCache;
 
   private List<SectionMatcher> allSections;
   private List<SectionMatcher> localSections;
@@ -165,14 +165,14 @@
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       ProjectCache pc,
       PermissionCollection.Factory permissionFilter,
-      GitRepositoryManager repoManager,
-      ChangeControl.AssistedFactory changeControlFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.Factory changeControlFactory,
       TagCache tagCache,
-      ChangeCache changeCache,
+      @Nullable SearchingChangeCacheImpl changeCache,
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
-    this.repoManager = repoManager;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeControlFactory = changeControlFactory;
     this.tagCache = tagCache;
     this.changeCache = changeCache;
@@ -192,8 +192,28 @@
     return r;
   }
 
-  public ChangeControl controlFor(final Change change) {
-    return changeControlFactory.create(controlForRef(change.getDest()), change);
+  public ChangeControl controlFor(ReviewDb db, Change change)
+      throws OrmException {
+    return changeControlFactory.create(controlForRef(change.getDest()), db,
+        change.getProject(), change.getId());
+  }
+
+  /**
+   * Create a change control for a change that was loaded from index. This
+   * method should only be used when database access is harmful and potentially
+   * stale data from the index is acceptable.
+   *
+   * @param change change loaded from secondary index
+   * @return change control
+   */
+  public ChangeControl controlForIndexedChange(Change change) {
+    return changeControlFactory
+        .createForIndexedChange(controlForRef(change.getDest()), change);
+  }
+
+  public ChangeControl controlFor(ChangeNotes notes) {
+    return changeControlFactory
+        .create(controlForRef(notes.getChange().getDest()), notes);
   }
 
   public RefControl controlForRef(Branch.NameKey ref) {
@@ -206,25 +226,8 @@
     }
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
-      Provider<List<String>> usernames = new Provider<List<String>>() {
-        @Override
-        public List<String> get() {
-          List<String> r;
-          if (user.isIdentifiedUser()) {
-            Set<String> emails = user.asIdentifiedUser().getEmailAddresses();
-            r = new ArrayList<>(emails.size() + 1);
-            r.addAll(emails);
-          } else {
-            r = new ArrayList<>(1);
-          }
-          if (user.getUserName() != null) {
-            r.add(user.getUserName());
-          }
-          return r;
-        }
-      };
       PermissionCollection relevant =
-          permissionFilter.filter(access(), refName, usernames);
+          permissionFilter.filter(access(), refName, user);
       ctl = new RefControl(this, refName, relevant);
       refControls.put(refName, ctl);
     }
@@ -250,15 +253,27 @@
     return labelTypes;
   }
 
-  private boolean isHidden() {
+  /** Returns whether the project is hidden. */
+  public boolean isHidden() {
     return getProject().getState().equals(
         com.google.gerrit.extensions.client.ProjectState.HIDDEN);
   }
 
-  /** Can this user see this project exists? */
-  public boolean isVisible() {
+  /**
+   * Returns whether the project is readable to the current user. Note
+   * that the project could still be hidden.
+   */
+  public boolean isReadable() {
     return (user.isInternalUser()
-        || canPerformOnAnyRef(Permission.READ)) && !isHidden();
+        || canPerformOnAnyRef(Permission.READ));
+  }
+
+  /**
+   * Returns whether the project is accessible to the current user, i.e.
+   * readable and not hidden.
+   */
+  public boolean isVisible() {
+    return isReadable() && !isHidden();
   }
 
   public boolean canAddRefs() {
@@ -351,7 +366,7 @@
     }
     final IdentifiedUser iUser = user.asIdentifiedUser();
 
-    List<AccountGroup.UUID> okGroupIds = Lists.newArrayList();
+    List<AccountGroup.UUID> okGroupIds = new ArrayList<>();
     for (ContributorAgreement ca : contributorAgreements) {
       List<AccountGroup.UUID> groupIds;
       groupIds = okGroupIds;
@@ -369,7 +384,7 @@
     }
 
     final StringBuilder msg = new StringBuilder();
-    msg.append(" A Contributor Agreement must be completed before uploading");
+    msg.append("A Contributor Agreement must be completed before uploading");
     if (canonicalWebUrl != null) {
       msg.append(":\n\n  ");
       msg.append(canonicalWebUrl);
@@ -402,9 +417,8 @@
         //
         if (controlForRef(section.getName()).canPerform(permissionName)) {
           return true;
-        } else {
-          break;
         }
+        break;
       }
     }
 
@@ -498,8 +512,8 @@
     return false;
   }
 
-  public boolean canReadCommit(ReviewDb db, RevWalk rw, RevCommit commit) {
-    try (Repository repo = openRepository()) {
+  public boolean canReadCommit(ReviewDb db, Repository repo, RevCommit commit) {
+    try (RevWalk rw = new RevWalk(repo)) {
       return isMergedIntoVisibleRef(repo, db, rw, commit,
           repo.getAllRefs().values());
     } catch (IOException e) {
@@ -513,8 +527,8 @@
 
   boolean isMergedIntoVisibleRef(Repository repo, ReviewDb db, RevWalk rw,
       RevCommit commit, Collection<Ref> unfilteredRefs) throws IOException {
-    VisibleRefFilter filter =
-        new VisibleRefFilter(tagCache, changeCache, repo, this, db, true);
+    VisibleRefFilter filter = new VisibleRefFilter(
+        tagCache, changeNotesFactory, changeCache, repo, this, db, true);
     Map<String, Ref> m = Maps.newHashMapWithExpectedSize(unfilteredRefs.size());
     for (Ref r : unfilteredRefs) {
       m.put(r.getName(), r);
@@ -523,8 +537,4 @@
     return !refs.isEmpty()
         && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
   }
-
-  Repository openRepository() throws IOException {
-    return repoManager.openRepository(getProject().getNameKey());
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
index 6415664..5b1d521 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -33,9 +32,9 @@
   private final WebLinks webLinks;
 
   @Inject
-  ProjectJson(AllProjectsNameProvider allProjectsNameProvider,
+  ProjectJson(AllProjectsName allProjectsName,
       WebLinks webLinks) {
-    this.allProjects = allProjectsNameProvider.get();
+    this.allProjects = allProjectsName;
     this.webLinks = webLinks;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 7094828..68d236e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -21,15 +21,18 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ThemeInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.rules.PrologEnvironment;
@@ -37,6 +40,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -61,8 +65,10 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -77,6 +83,7 @@
   }
 
   private final boolean isAllProjects;
+  private final boolean isAllUsers;
   private final SitePaths sitePaths;
   private final AllProjectsName allProjectsName;
   private final ProjectCache projectCache;
@@ -94,7 +101,7 @@
   private volatile PrologMachineCopy rulesMachine;
 
   /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckTime;
+  private volatile long lastCheckGeneration;
 
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
@@ -110,15 +117,18 @@
       final SitePaths sitePaths,
       final ProjectCache projectCache,
       final AllProjectsName allProjectsName,
+      final AllUsersName allUsersName,
       final ProjectControl.AssistedFactory projectControlFactory,
       final PrologEnvironment.Factory envFactory,
       final GitRepositoryManager gitMgr,
       final RulesCache rulesCache,
       final List<CommentLinkInfo> commentLinks,
+      final CapabilityCollection.Factory capabilityFactory,
       @Assisted final ProjectConfig config) {
     this.sitePaths = sitePaths;
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
+    this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
     this.allProjectsName = allProjectsName;
     this.projectControlFactory = projectControlFactory;
     this.envFactory = envFactory;
@@ -126,9 +136,9 @@
     this.rulesCache = rulesCache;
     this.commentLinks = commentLinks;
     this.config = config;
-    this.configs = Maps.newHashMap();
+    this.configs = new HashMap<>();
     this.capabilities = isAllProjects
-      ? new CapabilityCollection(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+      ? capabilityFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
       : null;
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
@@ -151,12 +161,16 @@
     }
   }
 
+  void initLastCheck(long generation) {
+    lastCheckGeneration = generation;
+  }
+
   boolean needsRefresh(long generation) {
     if (generation <= 0) {
       return isRevisionOutOfDate();
     }
-    if (lastCheckTime != generation) {
-      lastCheckTime = generation;
+    if (lastCheckGeneration != generation) {
+      lastCheckGeneration = generation;
       return isRevisionOutOfDate();
     }
     return false;
@@ -277,7 +291,7 @@
       return getLocalAccessSections();
     }
 
-    List<SectionMatcher> all = Lists.newArrayList();
+    List<SectionMatcher> all = new ArrayList<>();
     for (ProjectState s : tree()) {
       all.addAll(s.getLocalAccessSections());
     }
@@ -359,6 +373,10 @@
     return isAllProjects;
   }
 
+  public boolean isAllUsers() {
+    return isAllUsers;
+  }
+
   public boolean isUseContributorAgreements() {
     return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
       @Override
@@ -422,8 +440,17 @@
     });
   }
 
+  public boolean isRejectImplicitMerges() {
+    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
+      @Override
+      public InheritableBoolean apply(Project input) {
+        return input.getRejectImplicitMerges();
+      }
+    });
+  }
+
   public LabelTypes getLabelTypes() {
-    Map<String, LabelType> types = Maps.newLinkedHashMap();
+    Map<String, LabelType> types = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
       for (LabelType type : s.getConfig().getLabelSections().values()) {
         String lower = type.getName().toLowerCase();
@@ -443,12 +470,12 @@
   }
 
   public List<CommentLinkInfo> getCommentLinks() {
-    Map<String, CommentLinkInfo> cls = Maps.newLinkedHashMap();
+    Map<String, CommentLinkInfo> cls = new LinkedHashMap<>();
     for (CommentLinkInfo cl : commentLinks) {
       cls.put(cl.name.toLowerCase(), cl);
     }
     for (ProjectState s : treeInOrder()) {
-      for (CommentLinkInfo cl : s.getConfig().getCommentLinkSections()) {
+      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
         String name = cl.name.toLowerCase();
         if (cl.isOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
@@ -474,6 +501,15 @@
     return null;
   }
 
+  public Collection<SubscribeSection> getSubscribeSections(
+      Branch.NameKey branch) {
+    Collection<SubscribeSection> ret = new ArrayList<>();
+    for (ProjectState s : tree()) {
+      ret.addAll(s.getConfig().getSubscribeSections(branch));
+    }
+    return ret;
+  }
+
   public ThemeInfo getTheme() {
     ThemeInfo theme = this.theme;
     if (theme == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
index f8b201b..e06fb86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.project.CreateBranch.Input;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutBranch implements RestModifyView<BranchResource, Input> {
+public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
 
   @Override
-  public Object apply(BranchResource rsrc, Input input)
+  public Object apply(BranchResource rsrc, BranchInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("Branch \"" + rsrc.getBranchInfo().ref
         + "\" already exists");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 76ad2f0..19b5b26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -17,22 +17,20 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.EnableSignedPush;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -40,14 +38,12 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.project.PutConfig.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -56,27 +52,11 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Objects;
 
 @Singleton
-public class PutConfig implements RestModifyView<ProjectResource, Input> {
+public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
   private static final Logger log = LoggerFactory.getLogger(PutConfig.class);
 
-  public static class Input {
-    public String description;
-    public InheritableBoolean useContributorAgreements;
-    public InheritableBoolean useContentMerge;
-    public InheritableBoolean useSignedOffBy;
-    public InheritableBoolean createNewChangeForAllNotInTarget;
-    public InheritableBoolean requireChangeId;
-    public InheritableBoolean enableSignedPush;
-    public InheritableBoolean requireSignedPush;
-    public String maxObjectSizeLimit;
-    public SubmitType submitType;
-    public com.google.gerrit.extensions.client.ProjectState state;
-    public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-  }
-
   private final boolean serverEnableSignedPush;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
@@ -85,10 +65,9 @@
   private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
-  private final AllProjectsNameProvider allProjects;
+  private final AllProjectsName allProjects;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
-  private final ChangeHooks hooks;
 
   @Inject
   PutConfig(@EnableSignedPush boolean serverEnableSignedPush,
@@ -99,9 +78,8 @@
       TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
-      AllProjectsNameProvider allProjects,
+      AllProjectsName allProjects,
       DynamicMap<RestView<ProjectResource>> views,
-      ChangeHooks hooks,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -113,12 +91,11 @@
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
     this.views = views;
-    this.hooks = hooks;
     this.user = user;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, Input input)
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException,
       ResourceConflictException {
     if (!rsrc.getControl().isOwner()) {
@@ -127,7 +104,7 @@
     return apply(rsrc.getControl(), input);
   }
 
-  public ConfigInfo apply(ProjectControl ctrl, Input input)
+  public ConfigInfo apply(ProjectControl ctrl, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException,
       ResourceConflictException {
     Project.NameKey projectName = ctrl.getProject().getNameKey();
@@ -135,15 +112,7 @@
       throw new BadRequestException("config is required");
     }
 
-    final MetaDataUpdate md;
-    try {
-      md = metaDataUpdateFactory.get().create(projectName);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(projectName.get());
-    } catch (IOException e) {
-      throw new ResourceNotFoundException(projectName.get(), e);
-    }
-    try {
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
       ProjectConfig projectConfig = ProjectConfig.read(md);
       Project p = projectConfig.getProject();
 
@@ -176,6 +145,10 @@
         }
       }
 
+      if (input.rejectImplicitMerges != null) {
+        p.setRejectImplicitMerges(input.rejectImplicitMerges);
+      }
+
       if (input.maxObjectSizeLimit != null) {
         p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
       }
@@ -195,37 +168,29 @@
 
       md.setMessage("Modified project settings\n");
       try {
-        ObjectId baseRev = projectConfig.getRevision();
-        ObjectId commitRev = projectConfig.commit(md);
-        // Only fire hook if project was actually changed.
-        if (!Objects.equals(baseRev, commitRev)) {
-          hooks.doRefUpdatedHook(
-            new Branch.NameKey(projectName, RefNames.REFS_CONFIG),
-            baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
-        }
+        projectConfig.commit(md);
         projectCache.evict(projectConfig.getProject());
         gitMgr.setProjectDescription(projectName, p.getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
           throw new ResourceConflictException("Cannot update " + projectName
               + ": " + e.getCause().getMessage());
-        } else {
-          log.warn(String.format("Failed to update config of project %s.",
-              projectName), e);
-          throw new ResourceConflictException("Cannot update " + projectName);
         }
+        log.warn(String.format("Failed to update config of project %s.",
+            projectName), e);
+        throw new ResourceConflictException("Cannot update " + projectName);
       }
 
       ProjectState state = projectStateFactory.create(projectConfig);
-      return new ConfigInfo(serverEnableSignedPush,
+      return new ConfigInfoImpl(serverEnableSignedPush,
           state.controlFor(user.get()), config, pluginConfigEntries,
           cfgFactory, allProjects, views);
+    } catch (RepositoryNotFoundException notFound) {
+      throw new ResourceNotFoundException(projectName.get());
     } catch (ConfigInvalidException err) {
       throw new ResourceConflictException("Cannot read project " + projectName, err);
     } catch (IOException err) {
       throw new ResourceConflictException("Cannot update project " + projectName, err);
-    } finally {
-      md.close();
     }
   }
 
@@ -246,7 +211,7 @@
           }
           String oldValue = cfg.getString(v.getKey());
           String value = v.getValue().value;
-          if (projectConfigEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
+          if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
             List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
             oldValue = Joiner.on("\n").join(l);
             value = Joiner.on("\n").join(v.getValue().values);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
index d589865..17401fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
@@ -16,16 +16,13 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
@@ -35,35 +32,30 @@
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 
 import java.io.IOException;
-import java.util.Objects;
 
 @Singleton
-public class PutDescription implements RestModifyView<ProjectResource, PutDescriptionInput> {
+public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
   private final GitRepositoryManager gitMgr;
-  private final ChangeHooks hooks;
 
   @Inject
   PutDescription(ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
-      ChangeHooks hooks,
       GitRepositoryManager gitMgr) {
     this.cache = cache;
     this.updateFactory = updateFactory;
-    this.hooks = hooks;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource,
-      PutDescriptionInput input) throws AuthException,
+      DescriptionInput input) throws AuthException,
       ResourceConflictException, ResourceNotFoundException, IOException {
     if (input == null) {
-      input = new PutDescriptionInput(); // Delete would set description to null.
+      input = new DescriptionInput(); // Delete would set description to null.
     }
 
     ProjectControl ctl = resource.getControl();
@@ -72,40 +64,28 @@
       throw new AuthException("not project owner");
     }
 
-    try {
-      MetaDataUpdate md = updateFactory.create(resource.getNameKey());
-      try {
-        ProjectConfig config = ProjectConfig.read(md);
-        Project project = config.getProject();
-        project.setDescription(Strings.emptyToNull(input.description));
+    try (MetaDataUpdate md = updateFactory.create(resource.getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      project.setDescription(Strings.emptyToNull(input.description));
 
-        String msg = MoreObjects.firstNonNull(
-          Strings.emptyToNull(input.commitMessage),
-          "Updated description.\n");
-        if (!msg.endsWith("\n")) {
-          msg += "\n";
-        }
-        md.setAuthor(user);
-        md.setMessage(msg);
-        ObjectId baseRev = config.getRevision();
-        ObjectId commitRev = config.commit(md);
-        // Only fire hook if project was actually changed.
-        if (!Objects.equals(baseRev, commitRev)) {
-          hooks.doRefUpdatedHook(
-            new Branch.NameKey(resource.getNameKey(), RefNames.REFS_CONFIG),
-            baseRev, commitRev, user.getAccount());
-        }
-        cache.evict(ctl.getProject());
-        gitMgr.setProjectDescription(
-            resource.getNameKey(),
-            project.getDescription());
-
-        return Strings.isNullOrEmpty(project.getDescription())
-            ? Response.<String>none()
-            : Response.ok(project.getDescription());
-      } finally {
-        md.close();
+      String msg = MoreObjects.firstNonNull(
+        Strings.emptyToNull(input.commitMessage),
+        "Updated description.\n");
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
       }
+      md.setAuthor(user);
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(ctl.getProject());
+      gitMgr.setProjectDescription(
+          resource.getNameKey(),
+          project.getDescription());
+
+      return Strings.isNullOrEmpty(project.getDescription())
+          ? Response.<String>none()
+          : Response.ok(project.getDescription());
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(resource.getName());
     } catch (ConfigInvalidException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
new file mode 100644
index 0000000..a87882e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+
+public class PutTag implements RestModifyView<TagResource, TagInput> {
+
+  @Override
+  public Object apply(TagResource resource, TagInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref
+        + "\" already exists");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 964554d..ad41522 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -14,22 +14,15 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.common.errors.InvalidNameException;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 
-import dk.brics.automaton.RegExp;
-
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -45,11 +38,10 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
 
 
 /** Manages access control for Git references (aka branches, tags). */
@@ -94,9 +86,8 @@
     ProjectControl newCtl = projectControl.forUser(who);
     if (relevant.isUserSpecific()) {
       return newCtl.controlForRef(getRefName());
-    } else {
-      return new RefControl(newCtl, getRefName(), relevant);
     }
+    return new RefControl(newCtl, getRefName(), relevant);
   }
 
   /** Is this user a ref owner? */
@@ -128,8 +119,8 @@
   public boolean isVisibleByRegisteredUsers() {
     List<PermissionRule> access = relevant.getPermission(Permission.READ);
     List<PermissionRule> overridden = relevant.getOverridden(Permission.READ);
-    Set<ProjectRef> allows = Sets.newHashSet();
-    Set<ProjectRef> blocks = Sets.newHashSet();
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
     for (PermissionRule rule : access) {
       if (rule.isBlock()) {
         blocks.add(relevant.getRuleProps(rule));
@@ -159,6 +150,13 @@
         && canWrite();
   }
 
+  /** @return true if this user can add a new patch set to this ref */
+  public boolean canAddPatchSet() {
+    return projectControl.controlForRef("refs/for/" + getRefName())
+        .canPerform(Permission.ADD_PATCH_SET)
+        && canWrite();
+  }
+
   /** @return true if this user can submit merge patch sets to this ref */
   public boolean canUploadMerges() {
     return projectControl.controlForRef("refs/for/" + getRefName())
@@ -245,12 +243,11 @@
    * Determines whether the user can create a new Git ref.
    *
    * @param db db for checking change visibility.
-   * @param rw revision pool {@code object} was parsed in; must be reset before
-   *     calling this method.
+   * @param repo repository on which user want to create
    * @param object the object the user will start the reference with.
    * @return {@code true} if the user specified can create a new Git ref
    */
-  public boolean canCreate(ReviewDb db, RevWalk rw, RevObject object) {
+  public boolean canCreate(ReviewDb db, Repository repo, RevObject object) {
     if (!canWrite()) {
       return false;
     }
@@ -264,6 +261,9 @@
         admin = getUser().getCapabilities().canAdministrateServer();
         break;
 
+      case GIT:
+      case SSH_COMMAND:
+      case WEB_BROWSER:
       default:
         owner = false;
         admin = false;
@@ -280,7 +280,7 @@
         // If the user has push permissions, they can create the ref regardless
         // of whether they are pushing any new objects along with the create.
         return true;
-      } else if (isMergedIntoBranchOrTag(db, rw, (RevCommit) object)) {
+      } else if (isMergedIntoBranchOrTag(db, repo, (RevCommit) object)) {
         // If the user has no push permissions, check whether the object is
         // merged into a branch or tag readable by this user. If so, they are
         // not effectively "pushing" more objects, so they can create the ref
@@ -290,7 +290,7 @@
       return false;
     } else if (object instanceof RevTag) {
       final RevTag tag = (RevTag) object;
-      try {
+      try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
         return false;
@@ -317,17 +317,16 @@
       //
       if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
         return owner || canPerform(Permission.PUSH_SIGNED_TAG);
-      } else {
-        return owner || canPerform(Permission.PUSH_TAG);
       }
+      return owner || canPerform(Permission.PUSH_TAG);
     } else {
       return false;
     }
   }
 
-  private boolean isMergedIntoBranchOrTag(ReviewDb db, RevWalk rw,
+  private boolean isMergedIntoBranchOrTag(ReviewDb db, Repository repo,
       RevCommit commit) {
-    try (Repository repo = projectControl.openRepository()) {
+    try (RevWalk rw = new RevWalk(repo)) {
       List<Ref> refs = new ArrayList<>(
           repo.getRefDatabase().getRefs(Constants.R_HEADS).values());
       refs.addAll(repo.getRefDatabase().getRefs(Constants.R_TAGS).values());
@@ -362,6 +361,11 @@
       case GIT:
         return canPushWithForce();
 
+      case JSON_RPC:
+      case REST_API:
+      case SSH_COMMAND:
+      case UNKNOWN:
+      case WEB_BROWSER:
       default:
         return getUser().getCapabilities().canAdministrateServer()
             || (isOwner() && !isForceBlocked(Permission.PUSH))
@@ -465,8 +469,8 @@
   }
 
   private static class AllowedRange {
-    private int allowMin = 0;
-    private int allowMax = 0;
+    private int allowMin;
+    private int allowMax;
     private int blockMin = Integer.MIN_VALUE;
     private int blockMax = Integer.MAX_VALUE;
 
@@ -498,7 +502,7 @@
 
   private PermissionRange toRange(String permissionName,
       List<PermissionRule> ruleList) {
-    Map<ProjectRef, AllowedRange> ranges = Maps.newHashMap();
+    Map<ProjectRef, AllowedRange> ranges = new HashMap<>();
     for (PermissionRule rule : ruleList) {
       ProjectRef p = relevant.getRuleProps(rule);
       AllowedRange r = ranges.get(p);
@@ -538,8 +542,8 @@
   private boolean doCanPerform(String permissionName, boolean blockOnly) {
     List<PermissionRule> access = access(permissionName);
     List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = Sets.newHashSet();
-    Set<ProjectRef> blocks = Sets.newHashSet();
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
     for (PermissionRule rule : access) {
       if (rule.isBlock() && !rule.getForce()) {
         blocks.add(relevant.getRuleProps(rule));
@@ -558,8 +562,8 @@
   private boolean canForcePerform(String permissionName) {
     List<PermissionRule> access = access(permissionName);
     List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = Sets.newHashSet();
-    Set<ProjectRef> blocks = Sets.newHashSet();
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
     for (PermissionRule rule : access) {
       if (rule.isBlock()) {
         blocks.add(relevant.getRuleProps(rule));
@@ -580,8 +584,8 @@
   private boolean isForceBlocked(String permissionName) {
     List<PermissionRule> access = access(permissionName);
     List<PermissionRule> overridden = relevant.getOverridden(permissionName);
-    Set<ProjectRef> allows = Sets.newHashSet();
-    Set<ProjectRef> blocks = Sets.newHashSet();
+    Set<ProjectRef> allows = new HashSet<>();
+    Set<ProjectRef> blocks = new HashSet<>();
     for (PermissionRule rule : access) {
       if (rule.isBlock()) {
         blocks.add(relevant.getRuleProps(rule));
@@ -613,19 +617,6 @@
 
     rules = relevant.getPermission(permissionName);
 
-    if (rules.isEmpty()) {
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
-    if (rules.size() == 1) {
-      if (!projectControl.match(rules.get(0), isChangeOwner)) {
-        rules = Collections.emptyList();
-      }
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
     List<PermissionRule> mine = new ArrayList<>(rules.size());
     for (PermissionRule rule : rules) {
       if (projectControl.match(rule, isChangeOwner)) {
@@ -639,53 +630,4 @@
     effective.put(permissionName, mine);
     return mine;
   }
-
-  public static boolean isRE(String refPattern) {
-    return refPattern.startsWith(AccessSection.REGEX_PREFIX);
-  }
-
-  public static String shortestExample(String pattern) {
-    if (isRE(pattern)) {
-      // Since Brics will substitute dot [.] with \0 when generating
-      // shortest example, any usage of dot will fail in
-      // Repository.isValidRefName() if not combined with star [*].
-      // To get around this, we substitute the \0 with an arbitrary
-      // accepted character.
-      return toRegExp(pattern).toAutomaton().getShortestExample(true).replace('\0', '-');
-    } else if (pattern.endsWith("/*")) {
-      return pattern.substring(0, pattern.length() - 1) + '1';
-    } else {
-      return pattern;
-    }
-  }
-
-  public static RegExp toRegExp(String refPattern) {
-    if (isRE(refPattern)) {
-      refPattern = refPattern.substring(1);
-    }
-    return new RegExp(refPattern, RegExp.NONE);
-  }
-
-  public static void validateRefPattern(String refPattern)
-      throws InvalidNameException {
-    if (refPattern.startsWith(RefConfigSection.REGEX_PREFIX)) {
-      if (!Repository.isValidRefName(RefControl.shortestExample(refPattern))) {
-        throw new InvalidNameException(refPattern);
-      }
-    } else if (refPattern.equals(RefConfigSection.ALL)) {
-      // This is a special case we have to allow, it fails below.
-    } else if (refPattern.endsWith("/*")) {
-      String prefix = refPattern.substring(0, refPattern.length() - 2);
-      if (!Repository.isValidRefName(prefix)) {
-        throw new InvalidNameException(refPattern);
-      }
-    } else if (!Repository.isValidRefName(refPattern)) {
-      throw new InvalidNameException(refPattern);
-    }
-    try {
-      Pattern.compile(refPattern.replace("${username}/", ""));
-    } catch (PatternSyntaxException e) {
-      throw new InvalidNameException(refPattern + " " + e.getMessage());
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
new file mode 100644
index 0000000..ed50a54
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.Throwables;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.common.errors.InvalidNameException;
+
+import dk.brics.automaton.RegExp;
+
+import org.eclipse.jgit.lib.Repository;
+
+import java.util.concurrent.ExecutionException;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+public class RefPattern {
+  public static final String USERID_SHARDED = "shardeduserid";
+  public static final String USERNAME = "username";
+
+  private static final LoadingCache<String, String> exampleCache = CacheBuilder
+      .newBuilder()
+      .maximumSize(4000)
+      .build(new CacheLoader<String, String>() {
+        @Override
+        public String load(String refPattern) {
+          return example(refPattern);
+        }
+      });
+
+  public static String shortestExample(String refPattern) {
+    if (isRE(refPattern)) {
+      try {
+        return exampleCache.get(refPattern);
+      } catch (ExecutionException e) {
+        Throwables.propagateIfPossible(e.getCause());
+        throw new RuntimeException(e);
+      }
+    } else if (refPattern.endsWith("/*")) {
+      return refPattern.substring(0, refPattern.length() - 1) + '1';
+    } else {
+      return refPattern;
+    }
+  }
+
+  static String example(String refPattern) {
+    // Since Brics will substitute dot [.] with \0 when generating
+    // shortest example, any usage of dot will fail in
+    // Repository.isValidRefName() if not combined with star [*].
+    // To get around this, we substitute the \0 with an arbitrary
+    // accepted character.
+    return toRegExp(refPattern).toAutomaton().getShortestExample(true)
+        .replace('\0', '-');
+  }
+
+  public static boolean isRE(String refPattern) {
+    return refPattern.startsWith(AccessSection.REGEX_PREFIX);
+  }
+
+  public static RegExp toRegExp(String refPattern) {
+    if (isRE(refPattern)) {
+      refPattern = refPattern.substring(1);
+    }
+    return new RegExp(refPattern, RegExp.NONE);
+  }
+
+  public static void validate(String refPattern)
+      throws InvalidNameException {
+    if (refPattern.startsWith(RefConfigSection.REGEX_PREFIX)) {
+      if (!Repository.isValidRefName(shortestExample(refPattern))) {
+        throw new InvalidNameException(refPattern);
+      }
+    } else if (refPattern.equals(RefConfigSection.ALL)) {
+      // This is a special case we have to allow, it fails below.
+    } else if (refPattern.endsWith("/*")) {
+      String prefix = refPattern.substring(0, refPattern.length() - 2);
+      if (!Repository.isValidRefName(prefix)) {
+        throw new InvalidNameException(refPattern);
+      }
+    } else if (!Repository.isValidRefName(refPattern)) {
+      throw new InvalidNameException(refPattern);
+    }
+    validateRegExp(refPattern);
+  }
+
+  public static void validateRegExp(String refPattern)
+      throws InvalidNameException {
+    try {
+      refPattern = refPattern.replace("${" + USERID_SHARDED + "}", "");
+      refPattern = refPattern.replace("${" + USERNAME + "}", "");
+      Pattern.compile(refPattern);
+    } catch (PatternSyntaxException e) {
+      throw new InvalidNameException(refPattern + " " + e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
index d80323f..fe87b6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -14,13 +14,21 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.server.project.RefControl.isRE;
+import static com.google.gerrit.server.project.RefPattern.isRE;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 
 import dk.brics.automaton.Automaton;
 
-import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
 import java.util.regex.Pattern;
 
 public abstract class RefPatternMatcher {
@@ -36,7 +44,7 @@
     }
   }
 
-  public abstract boolean match(String ref, String username);
+  public abstract boolean match(String ref, CurrentUser user);
 
   private static class Exact extends RefPatternMatcher {
     private final String expect;
@@ -46,7 +54,7 @@
     }
 
     @Override
-    public boolean match(String ref, String username) {
+    public boolean match(String ref, CurrentUser user) {
       return expect.equals(ref);
     }
   }
@@ -59,7 +67,7 @@
     }
 
     @Override
-    public boolean match(String ref, String username) {
+    public boolean match(String ref, CurrentUser user) {
       return ref.startsWith(prefix);
     }
   }
@@ -72,7 +80,7 @@
     }
 
     @Override
-    public boolean match(String ref, String username) {
+    public boolean match(String ref, CurrentUser user) {
       return pattern.matcher(ref).matches();
     }
   }
@@ -85,52 +93,84 @@
       template = new ParameterizedString(pattern);
 
       if (isRE(pattern)) {
-        // Replace ${username} with ":USERNAME:" as : is not legal
-        // in a reference and the string :USERNAME: is not likely to
-        // be a valid part of the regex. This later allows the pattern
-        // prefix to be clipped, saving time on evaluation.
+        // Replace ${username} and ${shardeduserid} with ":PLACEHOLDER:"
+        // as : is not legal in a reference and the string :PLACEHOLDER:
+        // is not likely to be a valid part of the regex. This later
+        // allows the pattern prefix to be clipped, saving time on
+        // evaluation.
+        String replacement = ":PLACEHOLDER:";
+        Map<String, String> params = ImmutableMap.of(
+            RefPattern.USERID_SHARDED, replacement,
+            RefPattern.USERNAME, replacement);
         Automaton am =
-            RefControl.toRegExp(
-                template.replace(Collections.singletonMap("username",
-                    ":USERNAME:"))).toAutomaton();
+            RefPattern.toRegExp(template.replace(params)).toAutomaton();
         String rePrefix = am.getCommonPrefix();
-        prefix = rePrefix.substring(0, rePrefix.indexOf(":USERNAME:"));
+        prefix = rePrefix.substring(0, rePrefix.indexOf(replacement));
       } else {
         prefix = pattern.substring(0, pattern.indexOf("${"));
       }
     }
 
     @Override
-    public boolean match(String ref, String username) {
-      if (!ref.startsWith(prefix) || username == null) {
+    public boolean match(String ref, CurrentUser user) {
+      if (!ref.startsWith(prefix)) {
         return false;
       }
 
-      String u;
-      if (isRE(template.getPattern())) {
-        u = Pattern.quote(username);
-      } else {
-        u = username;
-      }
+      for (String username : getUsernames(user)) {
+        String u;
+        if (isRE(template.getPattern())) {
+          u = Pattern.quote(username);
+        } else {
+          u = username;
+        }
 
-      RefPatternMatcher next = getMatcher(expand(template, u));
-      return next != null ? next.match(expand(ref, u), username) : false;
+        Account.Id accountId = user.isIdentifiedUser()
+            ? user.getAccountId()
+            : null;
+        RefPatternMatcher next = getMatcher(expand(template, u, accountId));
+        if (next != null && next.match(expand(ref, u, accountId), user)) {
+          return true;
+        }
+      }
+      return false;
+    }
+
+    private Iterable<String> getUsernames(CurrentUser user) {
+      if (user.isIdentifiedUser()) {
+        Set<String> emails = user.asIdentifiedUser().getEmailAddresses();
+        if (user.getUserName() == null) {
+          return emails;
+        } else if (emails.isEmpty()) {
+          return ImmutableSet.of(user.getUserName());
+        }
+        return Iterables.concat(emails, ImmutableSet.of(user.getUserName()));
+      }
+      if (user.getUserName() != null) {
+        return ImmutableSet.of(user.getUserName());
+      }
+      return ImmutableSet.of();
     }
 
     boolean matchPrefix(String ref) {
       return ref.startsWith(prefix);
     }
 
-    private String expand(String parameterizedRef, String userName) {
+    private String expand(String parameterizedRef, String userName, Account.Id accountId) {
       if (parameterizedRef.contains("${")) {
-        return expand(new ParameterizedString(parameterizedRef), userName);
+        return expand(new ParameterizedString(parameterizedRef), userName, accountId);
       }
       return parameterizedRef;
     }
 
-    private String expand(ParameterizedString parameterizedRef, String userName) {
-      return parameterizedRef.replace(Collections.singletonMap("username",
-          userName));
+    private String expand(ParameterizedString parameterizedRef, String userName,
+        Account.Id accountId) {
+      Map<String, String> params = new HashMap<>();
+      params.put(RefPattern.USERNAME, userName);
+      if (accountId != null) {
+        params.put(RefPattern.USERID_SHARDED, RefNames.shard(accountId.get()));
+      }
+      return parameterizedRef.replace(params);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
new file mode 100644
index 0000000..9d8fe10
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ObjectWalk;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+
+public class RefUtil {
+  private static final Logger log = LoggerFactory.getLogger(RefUtil.class);
+
+  public static ObjectId parseBaseRevision(Repository repo,
+      Project.NameKey projectName, String baseRevision)
+      throws InvalidRevisionException {
+    try {
+      ObjectId revid = repo.resolve(baseRevision);
+      if (revid == null) {
+        throw new InvalidRevisionException();
+      }
+      return revid;
+    } catch (IOException err) {
+      log.error("Cannot resolve \"" + baseRevision + "\" in project \""
+          + projectName.get() + "\"", err);
+      throw new InvalidRevisionException();
+    } catch (RevisionSyntaxException err) {
+      log.error("Invalid revision syntax \"" + baseRevision + "\"", err);
+      throw new InvalidRevisionException();
+    }
+  }
+
+  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
+      throws InvalidRevisionException {
+    try {
+      ObjectWalk rw = new ObjectWalk(repo);
+      try {
+        rw.markStart(rw.parseCommit(revid));
+      } catch (IncorrectObjectTypeException err) {
+        throw new InvalidRevisionException();
+      }
+      RefDatabase refDb = repo.getRefDatabase();
+      Iterable<Ref> refs = Iterables.concat(
+          refDb.getRefs(Constants.R_HEADS).values(),
+          refDb.getRefs(Constants.R_TAGS).values());
+      Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
+      if (rc != null) {
+        refs = Iterables.concat(refs, Collections.singleton(rc));
+      }
+      for (Ref r : refs) {
+        try {
+          rw.markUninteresting(rw.parseAny(r.getObjectId()));
+        } catch (MissingObjectException err) {
+          continue;
+        }
+      }
+      rw.checkConnectivity();
+      return rw;
+    } catch (IncorrectObjectTypeException | MissingObjectException err) {
+      throw new InvalidRevisionException();
+    } catch (IOException err) {
+      log.error("Repository \"" + repo.getDirectory()
+          + "\" may be corrupt; suggest running git fsck", err);
+      throw new InvalidRevisionException();
+    }
+  }
+
+  public static String getRefPrefix(String refName) {
+    int i = refName.lastIndexOf('/');
+    if (i > Constants.R_HEADS.length() - 1) {
+      return refName.substring(0, i);
+    }
+    return Constants.R_HEADS;
+  }
+
+  /** Error indicating the revision is invalid as supplied. */
+  static class InvalidRevisionException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public static final String MESSAGE = "Invalid Revision";
+
+    InvalidRevisionException() {
+      super(MESSAGE);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
new file mode 100644
index 0000000..6e2fd5d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.validators.RefOperationValidators;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.ReceiveCommand.Type;
+
+public class RefValidationHelper {
+  public interface Factory {
+    RefValidationHelper create(Type operationType);
+  }
+
+  private final RefOperationValidators.Factory refValidatorsFactory;
+  private final Type operationType;
+
+  @Inject
+  RefValidationHelper(RefOperationValidators.Factory refValidatorsFactory,
+      @Assisted Type operationType) {
+    this.refValidatorsFactory = refValidatorsFactory;
+    this.operationType = operationType;
+  }
+
+  public void validateRefOperation(String projectName, IdentifiedUser user,
+      RefUpdate update) throws ResourceConflictException {
+    RefOperationValidators refValidators =
+        refValidatorsFactory.create(
+            new Project(new Project.NameKey(projectName)),
+            user,
+            RefOperationValidators.getCommand(update, operationType));
+    try {
+      refValidators.validateForRefOperation();
+    } catch (ValidationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
index d8294c0..de045b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
@@ -23,7 +23,7 @@
 class RepositoryStatistics extends TreeMap<String, Object> {
   private static final long serialVersionUID = 1L;
 
-  public RepositoryStatistics(Properties p) {
+  RepositoryStatistics(Properties p) {
     for (Entry<Object, Object> e : p.entrySet()) {
       put(CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE,
           e.getKey().toString()), e.getValue());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
index 4d1688f..478357a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 
 /**
  * Matches an AccessSection against a reference name.
@@ -28,9 +29,8 @@
     String ref = section.getName();
     if (AccessSection.isValid(ref)) {
       return new SectionMatcher(project, section, getMatcher(ref));
-    } else {
-      return null;
     }
+    return null;
   }
 
   final Project.NameKey project;
@@ -45,7 +45,7 @@
   }
 
   @Override
-  public boolean match(String ref, String username) {
-    return this.matcher.match(ref, username);
+  public boolean match(String ref, CurrentUser user) {
+    return this.matcher.match(ref, user);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
new file mode 100644
index 0000000..1e5a7c9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -0,0 +1,296 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccess implements
+    RestModifyView<ProjectResource, ProjectAccessInput> {
+  protected final GroupBackend groupBackend;
+  private final GroupsCollection groupsCollection;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllProjectsName allProjects;
+  private final Provider<SetParent> setParent;
+  private final GetAccess getAccess;
+  private final ProjectCache projectCache;
+  private final Provider<IdentifiedUser> identifiedUser;
+
+  @Inject
+  private SetAccess(GroupBackend groupBackend,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent,
+      GroupsCollection groupsCollection,
+      ProjectCache projectCache,
+      GetAccess getAccess,
+      Provider<IdentifiedUser> identifiedUser) {
+    this.groupBackend = groupBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+    this.groupsCollection = groupsCollection;
+    this.getAccess = getAccess;
+    this.projectCache = projectCache;
+    this.identifiedUser = identifiedUser;
+  }
+
+  @Override
+  public ProjectAccessInfo apply(ProjectResource rsrc,
+      ProjectAccessInput input)
+      throws ResourceNotFoundException, ResourceConflictException,
+      IOException, AuthException, BadRequestException,
+      UnprocessableEntityException{
+    List<AccessSection> removals = getAccessSections(input.remove);
+    List<AccessSection> additions = getAccessSections(input.add);
+    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+
+    ProjectControl projectControl = rsrc.getControl();
+    ProjectConfig config;
+
+    Project.NameKey newParentProjectName = input.parent == null ?
+        null : new Project.NameKey(input.parent);
+
+    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
+      config = ProjectConfig.read(md);
+
+      // Perform removal checks
+      for (AccessSection section : removals) {
+        boolean isGlobalCapabilities =
+            AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+
+        if (isGlobalCapabilities) {
+          checkGlobalCapabilityPermissions(config.getName());
+        } else if (!projectControl.controlForRef(section.getName()).isOwner()) {
+          throw new AuthException("You are not allowed to edit permissions"
+              + "for ref: " + section.getName());
+        }
+      }
+      // Perform addition checks
+      for (AccessSection section : additions) {
+        String name = section.getName();
+        boolean isGlobalCapabilities =
+            AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+        if (isGlobalCapabilities) {
+          checkGlobalCapabilityPermissions(config.getName());
+        } else {
+          if (!AccessSection.isValid(name)) {
+            throw new BadRequestException("invalid section name");
+          }
+          if (!projectControl.controlForRef(name).isOwner()) {
+            throw new AuthException("You are not allowed to edit permissions"
+                + "for ref: " + name);
+          }
+          RefPattern.validate(name);
+        }
+
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (isGlobalCapabilities
+              && !GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException("Cannot add non-global capability "
+                + p.getName() + " to global capabilities");
+          }
+        }
+      }
+
+      // Apply removals
+      for (AccessSection section : removals) {
+        if (section.getPermissions().isEmpty()) {
+          // Remove entire section
+          config.remove(config.getAccessSection(section.getName()));
+        }
+        // Remove specific permissions
+        for (Permission p : section.getPermissions()) {
+          if (p.getRules().isEmpty()) {
+            config.remove(config.getAccessSection(section.getName()), p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              config.remove(config.getAccessSection(section.getName()), p, r);
+            }
+          }
+        }
+      }
+
+      // Apply additions
+      for (AccessSection section : additions) {
+        AccessSection currentAccessSection =
+            config.getAccessSection(section.getName());
+
+        if (currentAccessSection == null) {
+          // Add AccessSection
+          config.replace(section);
+        } else {
+          for (Permission p : section.getPermissions()) {
+            Permission currentPermission =
+                currentAccessSection.getPermission(p.getName());
+            if (currentPermission == null) {
+              // Add Permission
+              currentAccessSection.addPermission(p);
+            } else {
+              for (PermissionRule r : p.getRules()) {
+                // AddPermissionRule
+                currentPermission.add(r);
+              }
+            }
+          }
+        }
+      }
+
+      if (newParentProjectName != null &&
+          !config.getProject().getNameKey().equals(allProjects) &&
+          !config.getProject().getParent(allProjects)
+              .equals(newParentProjectName)) {
+        try {
+          setParent.get().validateParentUpdate(projectControl,
+              MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
+              true);
+        } catch (UnprocessableEntityException e) {
+          throw new ResourceConflictException(e.getMessage(), e);
+        }
+        config.getProject().setParentName(newParentProjectName);
+      }
+
+      if (!Strings.isNullOrEmpty(input.message)) {
+        if (!input.message.endsWith("\n")) {
+          input.message += "\n";
+        }
+        md.setMessage(input.message);
+      } else {
+        md.setMessage("Modify access rules\n");
+      }
+
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(rsrc.getName());
+    }
+
+    return getAccess.apply(rsrc.getNameKey());
+  }
+
+  private List<AccessSection> getAccessSections(
+      Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    List<AccessSection> sections = new LinkedList<>();
+    if (sectionInfos == null) {
+      return sections;
+    }
+
+    for (Map.Entry<String, AccessSectionInfo> entry :
+      sectionInfos.entrySet()) {
+      AccessSection accessSection = new AccessSection(entry.getKey());
+
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      for (Map.Entry<String, PermissionInfo> permissionEntry : entry
+          .getValue().permissions
+          .entrySet()) {
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+
+          GroupDescription.Basic group = groupsCollection
+              .parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+              permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+          PermissionRule r = new PermissionRule(
+              GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    return sections;
+  }
+
+  private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
+    throws BadRequestException, AuthException {
+
+    if (!allProjects.equals(projectName)) {
+      throw new BadRequestException("Cannot edit global capabilities "
+        + "for projects other than " + allProjects.get());
+    }
+
+    if (!identifiedUser.get().getCapabilities().canAdministrateServer()) {
+      throw new AuthException("Editing global capabilities "
+        + "requires " + GlobalCapability.ADMINISTRATE_SERVER);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
index ac01de5..641c3a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
@@ -84,39 +84,34 @@
       }
     }
 
-    try {
-      MetaDataUpdate md = updateFactory.create(ctl.getProject().getNameKey());
-      try {
-        ProjectConfig config = ProjectConfig.read(md);
-        Project project = config.getProject();
-        if (inherited) {
-          project.setDefaultDashboard(input.id);
-        } else {
-          project.setLocalDefaultDashboard(input.id);
-        }
-
-        String msg = MoreObjects.firstNonNull(
-          Strings.emptyToNull(input.commitMessage),
-          input.id == null
-            ? "Removed default dashboard.\n"
-            : String.format("Changed default dashboard to %s.\n", input.id));
-        if (!msg.endsWith("\n")) {
-          msg += "\n";
-        }
-        md.setAuthor(ctl.getUser().asIdentifiedUser());
-        md.setMessage(msg);
-        config.commit(md);
-        cache.evict(ctl.getProject());
-
-        if (target != null) {
-          DashboardInfo info = get.get().apply(target);
-          info.isDefault = true;
-          return Response.ok(info);
-        }
-        return Response.none();
-      } finally {
-        md.close();
+    try (MetaDataUpdate md = updateFactory.create(ctl.getProject().getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      if (inherited) {
+        project.setDefaultDashboard(input.id);
+      } else {
+        project.setLocalDefaultDashboard(input.id);
       }
+
+      String msg = MoreObjects.firstNonNull(
+        Strings.emptyToNull(input.commitMessage),
+        input.id == null
+          ? "Removed default dashboard.\n"
+          : String.format("Changed default dashboard to %s.\n", input.id));
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      md.setAuthor(ctl.getUser().asIdentifiedUser());
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(ctl.getProject());
+
+      if (target != null) {
+        DashboardInfo info = get.get().apply(target);
+        info.isDefault = true;
+        return Response.ok(info);
+      }
+      return Response.none();
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(ctl.getProject().getName());
     } catch (ConfigInvalidException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
index 25e2db3..442447f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.SetHead.Input;
 import com.google.inject.Inject;
@@ -53,15 +55,15 @@
 
   private final GitRepositoryManager repoManager;
   private final Provider<IdentifiedUser> identifiedUser;
-  private final DynamicSet<HeadUpdatedListener> headUpdatedListener;
+  private final DynamicSet<HeadUpdatedListener> headUpdatedListeners;
 
   @Inject
   SetHead(GitRepositoryManager repoManager,
       Provider<IdentifiedUser> identifiedUser,
-      DynamicSet<HeadUpdatedListener> headUpdatedListener) {
+      DynamicSet<HeadUpdatedListener> headUpdatedListeners) {
     this.repoManager = repoManager;
     this.identifiedUser = identifiedUser;
-    this.headUpdatedListener = headUpdatedListener;
+    this.headUpdatedListeners = headUpdatedListeners;
   }
 
   @Override
@@ -90,43 +92,69 @@
         final RefUpdate u = repo.updateRef(Constants.HEAD, true);
         u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
         RefUpdate.Result res = u.link(newHead);
-        switch(res) {
+        switch (res) {
           case NO_CHANGE:
           case RENAMED:
           case FORCED:
           case NEW:
             break;
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
           default:
             throw new IOException("Setting HEAD failed with " + res);
         }
 
-        HeadUpdatedListener.Event event = new HeadUpdatedListener.Event() {
-          @Override
-          public String getProjectName() {
-            return rsrc.getNameKey().get();
-          }
-
-          @Override
-          public String getOldHeadName() {
-            return oldHead;
-          }
-
-          @Override
-          public String getNewHeadName() {
-            return newHead;
-          }
-        };
-        for (HeadUpdatedListener l : headUpdatedListener) {
-          try {
-            l.onHeadUpdated(event);
-          } catch (RuntimeException e) {
-            log.warn("Failure in HeadUpdatedListener", e);
-          }
-        }
+        fire(rsrc.getNameKey(), oldHead, newHead);
       }
       return ref;
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(rsrc.getName());
     }
   }
+
+  private void fire(Project.NameKey nameKey, String oldHead, String newHead) {
+    if (!headUpdatedListeners.iterator().hasNext()) {
+      return;
+    }
+    Event event = new Event(nameKey, oldHead, newHead);
+    for (HeadUpdatedListener l : headUpdatedListeners) {
+      try {
+        l.onHeadUpdated(event);
+      } catch (RuntimeException e) {
+        log.warn("Failure in HeadUpdatedListener", e);
+      }
+    }
+  }
+
+  static class Event extends AbstractNoNotifyEvent
+      implements HeadUpdatedListener.Event {
+    private final Project.NameKey nameKey;
+    private final String oldHead;
+    private final String newHead;
+
+    Event(Project.NameKey nameKey, String oldHead, String newHead) {
+      this.nameKey = nameKey;
+      this.oldHead = oldHead;
+      this.newHead = newHead;
+    }
+
+    @Override
+    public String getProjectName() {
+      return nameKey.get();
+    }
+
+    @Override
+    public String getOldHeadName() {
+      return oldHead;
+    }
+
+    @Override
+    public String getNewHeadName() {
+      return newHead;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index 284d419..01aacfb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -75,31 +75,26 @@
     String parentName = MoreObjects.firstNonNull(
         Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(ctl, parentName, checkIfAdmin);
-    try {
-      MetaDataUpdate md = updateFactory.create(rsrc.getNameKey());
-      try {
-        ProjectConfig config = ProjectConfig.read(md);
-        Project project = config.getProject();
-        project.setParentName(parentName);
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      project.setParentName(parentName);
 
-        String msg = Strings.emptyToNull(input.commitMessage);
-        if (msg == null) {
-          msg = String.format(
+      String msg = Strings.emptyToNull(input.commitMessage);
+      if (msg == null) {
+        msg = String.format(
               "Changed parent to %s.\n", parentName);
-        } else if (!msg.endsWith("\n")) {
-          msg += "\n";
-        }
-        md.setAuthor(ctl.getUser().asIdentifiedUser());
-        md.setMessage(msg);
-        config.commit(md);
-        cache.evict(ctl.getProject());
-
-        Project.NameKey parent = project.getParent(allProjects);
-        checkNotNull(parent);
-        return parent.get();
-      } finally {
-        md.close();
+      } else if (!msg.endsWith("\n")) {
+        msg += "\n";
       }
+      md.setAuthor(ctl.getUser().asIdentifiedUser());
+      md.setMessage(msg);
+      config.commit(md);
+      cache.evict(ctl.getProject());
+
+      Project.NameKey parent = project.getParent(allProjects);
+      checkNotNull(parent);
+      return parent.get();
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(rsrc.getName());
     } catch (ConfigInvalidException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index da90566..5d0f4f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
@@ -73,14 +72,7 @@
   }
 
   public static SubmitTypeRecord defaultTypeError() {
-    return createTypeError(DEFAULT_MSG);
-  }
-
-  public static SubmitTypeRecord createTypeError(String err) {
-    SubmitTypeRecord rec = new SubmitTypeRecord();
-    rec.status = SubmitTypeRecord.Status.RULE_ERROR;
-    rec.errorMessage = err;
-    return rec;
+    return SubmitTypeRecord.error(DEFAULT_MSG);
   }
 
   /**
@@ -90,7 +82,7 @@
   private static class UserTermExpected extends Exception {
     private static final long serialVersionUID = 1L;
 
-    public UserTermExpected(SubmitRecord.Label label) {
+    UserTermExpected(SubmitRecord.Label label) {
       super(String.format("A label with the status %s must contain a user.",
           label.toString()));
     }
@@ -231,7 +223,8 @@
       // required for this change to be submittable. Each label will indicate
       // whether or not that is actually possible given the permissions.
       return ruleError(String.format("Submit rule '%s' for change %s of %s has "
-            + "no solution.", getSubmitRule(), cd.getId(), getProjectName()));
+            + "no solution.", getSubmitRuleName(), cd.getId(),
+            getProjectName()));
     }
 
     return resultsToSubmitRecord(getSubmitRule(), results);
@@ -241,11 +234,12 @@
     try {
       if (!control.isDraftVisible(cd.db(), cd)) {
         return createRuleError("Patch set " + patchSet.getId() + " not found");
-      } else if (patchSet.isDraft()) {
-        return createRuleError("Cannot submit draft patch sets");
-      } else {
-        return createRuleError("Cannot submit draft changes");
       }
+      initPatchSet();
+      if (patchSet.isDraft()) {
+        return createRuleError("Cannot submit draft patch sets");
+      }
+      return createRuleError("Cannot submit draft changes");
     } catch (OrmException err) {
       String msg = "Cannot check visibility of patch set " + patchSet.getId();
       log.error(msg, err);
@@ -364,9 +358,8 @@
         log.error(err, e);
       }
       return defaultRuleError();
-    } else {
-      return createRuleError(err);
     }
+    return createRuleError(err);
   }
 
   /**
@@ -385,15 +378,17 @@
     try {
       if (control.getChange().getStatus() == Change.Status.DRAFT
           && !control.isDraftVisible(cd.db(), cd)) {
-        return createTypeError("Patch set " + patchSet.getId() + " not found");
+        return SubmitTypeRecord.error(
+            "Patch set " + patchSet.getId() + " not found");
       }
       if (patchSet.isDraft() && !control.isDraftVisible(cd.db(), cd)) {
-        return createTypeError("Patch set " + patchSet.getId() + " not found");
+        return SubmitTypeRecord.error(
+            "Patch set " + patchSet.getId() + " not found");
       }
     } catch (OrmException err) {
       String msg = "Cannot read patch set " + patchSet.getId();
       log.error(msg, err);
-      return createTypeError(msg);
+      return SubmitTypeRecord.error(msg);
     }
 
     List<Term> results;
@@ -410,13 +405,13 @@
 
     if (results.isEmpty()) {
       // Should never occur for a well written rule
-      return typeError("Submit rule '" + getSubmitRule() + "' for change "
+      return typeError("Submit rule '" + getSubmitRuleName() + "' for change "
           + cd.getId() + " of " + getProjectName() + " has no solution.");
     }
 
     Term typeTerm = results.get(0);
     if (!(typeTerm instanceof SymbolTerm)) {
-      return typeError("Submit rule '" + getSubmitRule() + "' for change "
+      return typeError("Submit rule '" + getSubmitRuleName() + "' for change "
           + cd.getId() + " of " + getProjectName()
           + " did not return a symbol.");
     }
@@ -444,9 +439,8 @@
         log.error(err, e);
       }
       return defaultTypeError();
-    } else {
-      return createTypeError(err);
     }
+    return SubmitTypeRecord.error(err);
   }
 
   private List<Term> evaluateImpl(
@@ -487,7 +481,7 @@
       }
       List<Term> r;
       if (resultsTerm instanceof ListTerm) {
-        r = Lists.newArrayList();
+        r = new ArrayList<>();
         for (Term t = resultsTerm; t instanceof ListTerm;) {
           ListTerm l = (ListTerm) t;
           r.add(l.car().dereference());
@@ -609,6 +603,10 @@
     return submitRule;
   }
 
+  public String getSubmitRuleName() {
+    return submitRule != null ? submitRule.toString() : "<unknown rule>";
+  }
+
   private void initPatchSet() throws OrmException {
     if (patchSet == null) {
       patchSet = cd.currentPatchSet();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
index 1b6b888..b324fe0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -27,15 +28,19 @@
 
 @Singleton
 public class TagsCollection implements
-    ChildCollection<ProjectResource, TagResource> {
+    ChildCollection<ProjectResource, TagResource>,
+    AcceptsCreate<ProjectResource> {
   private final DynamicMap<RestView<TagResource>> views;
   private final Provider<ListTags> list;
+  private final CreateTag.Factory createTagFactory;
 
   @Inject
   public TagsCollection(DynamicMap<RestView<TagResource>> views,
-     Provider<ListTags> list) {
+     Provider<ListTags> list,
+     CreateTag.Factory createTagFactory) {
     this.views = views;
     this.list = list;
+    this.createTagFactory = createTagFactory;
   }
 
   @Override
@@ -53,4 +58,10 @@
   public DynamicMap<RestView<TagResource>> views() {
     return views;
   }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public CreateTag create(ProjectResource resource, IdString name) {
+    return createTagFactory.create(name.get());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java
deleted file mode 100644
index 4fcc4a6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-public class ThemeInfo {
-  static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
-
-  public final String css;
-  public final String header;
-  public final String footer;
-
-  ThemeInfo(String css, String header, String footer) {
-    this.css = css;
-    this.header = header;
-    this.footer = footer;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
index 39b0fa3..899e789 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -23,7 +25,7 @@
 import java.util.List;
 
 /** Requires all predicates to be true. */
-public class AndPredicate<T> extends Predicate<T> {
+public class AndPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -39,11 +41,11 @@
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
-          c += gp.getCost();
+          c += gp.estimateCost();
         }
       } else {
         t.add(p);
-        c += p.getCost();
+        c += p.estimateCost();
       }
     }
     children = t;
@@ -71,9 +73,21 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    for (Predicate<T> c : children) {
+      if (!c.isMatchable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
-    for (final Predicate<T> c : children) {
-      if (!c.match(object)) {
+    for (Predicate<T> c : children) {
+      checkState(c.isMatchable(), "match invoked, but child predicate %s "
+          + "doesn't implement %s", c, Matchable.class.getName());
+      if (!c.asMatchable().match(object)) {
         return false;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
new file mode 100644
index 0000000..168be5d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Function;
+import com.google.common.base.Throwables;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class AndSource<T> extends AndPredicate<T>
+    implements DataSource<T>, Comparator<Predicate<T>> {
+  protected final DataSource<T> source;
+
+  private final IsVisibleToPredicate<T> isVisibleToPredicate;
+  private final int start;
+  private final int cardinality;
+
+  public AndSource(Collection<? extends Predicate<T>> that) {
+    this(that, null, 0);
+  }
+
+  public AndSource(Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate) {
+    this(that, isVisibleToPredicate, 0);
+  }
+
+  public AndSource(Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+    this(ImmutableList.of(that), isVisibleToPredicate, start);
+  }
+
+  public AndSource(Collection<? extends Predicate<T>> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+    super(that);
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.isVisibleToPredicate = isVisibleToPredicate;
+    this.start = start;
+
+    int c = Integer.MAX_VALUE;
+    DataSource<T> s = null;
+    int minCost = Integer.MAX_VALUE;
+    for (Predicate<T> p : sort(getChildren())) {
+      if (p instanceof DataSource) {
+        c = Math.min(c, ((DataSource<?>) p).getCardinality());
+
+        int cost = p.estimateCost();
+        if (cost < minCost) {
+          s = toDataSource(p);
+          minCost = cost;
+        }
+      }
+    }
+    this.source = s;
+    this.cardinality = c;
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    try {
+      return readImpl();
+    } catch (OrmRuntimeException err) {
+      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
+      throw new OrmException(err);
+    }
+  }
+
+  private ResultSet<T> readImpl() throws OrmException {
+    if (source == null) {
+      throw new OrmException("No DataSource: " + this);
+    }
+    List<T> r = new ArrayList<>();
+    T last = null;
+    int nextStart = 0;
+    boolean skipped = false;
+    for (T data : buffer(source.read())) {
+      if (!isMatchable() || match(data)) {
+        r.add(data);
+      } else {
+        skipped = true;
+      }
+      last = data;
+      nextStart++;
+    }
+
+    if (skipped && last != null && source instanceof Paginated) {
+      // If our source is a paginated source and we skipped at
+      // least one of its results, we may not have filled the full
+      // limit the caller wants.  Restart the source and continue.
+      //
+      @SuppressWarnings("unchecked")
+      Paginated<T> p = (Paginated<T>) source;
+      while (skipped && r.size() < p.getOptions().limit() + start) {
+        skipped = false;
+        ResultSet<T> next = p.restart(nextStart);
+
+        for (T data : buffer(next)) {
+          if (match(data)) {
+            r.add(data);
+          } else {
+            skipped = true;
+          }
+          nextStart++;
+        }
+      }
+    }
+
+    if (start >= r.size()) {
+      r = ImmutableList.of();
+    } else if (start > 0) {
+      r = ImmutableList.copyOf(r.subList(start, r.size()));
+    }
+    return new ListResultSet<>(r);
+  }
+
+  @Override
+  public boolean isMatchable() {
+    return isVisibleToPredicate != null || super.isMatchable();
+  }
+
+  @Override
+  public boolean match(T object) throws OrmException {
+    if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
+      return false;
+    }
+
+    if (super.isMatchable() && !super.match(object)) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, 50))
+        .transformAndConcat(new Function<List<T>, List<T>>() {
+          @Override
+          public List<T> apply(List<T> buffer) {
+            return transformBuffer(buffer);
+          }
+        });
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    List<Predicate<T>> r = new ArrayList<>(that);
+    Collections.sort(r, this);
+    return r;
+  }
+
+  @Override
+  public int compare(Predicate<T> a, Predicate<T> b) {
+    int ai = a instanceof DataSource ? 0 : 1;
+    int bi = b instanceof DataSource ? 0 : 1;
+    int cmp = ai - bi;
+
+    if (cmp == 0) {
+      cmp = a.estimateCost() - b.estimateCost();
+    }
+
+    if (cmp == 0
+        && a instanceof DataSource
+        && b instanceof DataSource) {
+      DataSource<?> as = (DataSource<?>) a;
+      DataSource<?> bs = (DataSource<?>) b;
+      cmp = as.getCardinality() - bs.getCardinality();
+    }
+    return cmp;
+  }
+
+  @SuppressWarnings("unchecked")
+  private DataSource<T> toDataSource(Predicate<T> pred) {
+    return (DataSource<T>) pred;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
index 83cdc80..8a1718d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
@@ -19,8 +19,8 @@
 
 public interface DataSource<T> {
   /** @return an estimate of the number of results from {@link #read()}. */
-  public int getCardinality();
+  int getCardinality();
 
   /** @return read from the database and return the results. */
-  public ResultSet<T> read() throws OrmException;
+  ResultSet<T> read() throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
new file mode 100644
index 0000000..36e5792
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.Schema;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Execute a single query over a secondary index, for use by Gerrit internals.
+ * <p>
+ * By default, visibility of returned entities is not enforced (unlike in {@link
+ * QueryProcessor}). The methods in this class are not typically used by
+ * user-facing paths, but rather by internal callers that need to process all
+ * matching results.
+ */
+public class InternalQuery<T> {
+  private final QueryProcessor<T> queryProcessor;
+  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+
+  protected final IndexConfig indexConfig;
+
+  protected InternalQuery(QueryProcessor<T> queryProcessor,
+      IndexCollection<?, T, ? extends Index<?, T>> indexes,
+          IndexConfig indexConfig) {
+    this.queryProcessor = queryProcessor.enforceVisibility(false);
+    this.indexes = indexes;
+    this.indexConfig = indexConfig;
+  }
+
+  public InternalQuery<T> setLimit(int n) {
+    queryProcessor.setLimit(n);
+    return this;
+  }
+
+  public InternalQuery<T> enforceVisibility(boolean enforce) {
+    queryProcessor.enforceVisibility(enforce);
+    return this;
+  }
+
+  public InternalQuery<T> setRequestedFields(Set<String> fields) {
+    queryProcessor.setRequestedFields(fields);
+    return this;
+  }
+
+  public InternalQuery<T> noFields() {
+    queryProcessor.setRequestedFields(ImmutableSet.<String> of());
+    return this;
+  }
+
+  public List<T> query(Predicate<T> p) throws OrmException {
+    try {
+      return queryProcessor.query(p).entities();
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  protected Schema<T> schema() {
+    Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
+    return index != null ? index.getSchema() : null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
new file mode 100644
index 0000000..38411e3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+public abstract class IsVisibleToPredicate<T> extends OperatorPredicate<T>
+    implements Matchable<T> {
+  public IsVisibleToPredicate(String name, String value) {
+    super(name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
new file mode 100644
index 0000000..7c38e5a8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
+  @SuppressWarnings("unchecked")
+  public static Integer getLimit(String fieldName, Predicate<?> p) {
+    IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
+    return ip != null ? ip.intValue() : null;
+  }
+
+  public LimitPredicate(String fieldName, int limit) throws QueryParseException {
+    super(fieldName, limit);
+    if (limit <= 0) {
+      throw new QueryParseException("limit must be positive: " + limit);
+    }
+  }
+
+  @Override
+  public boolean match(T object) {
+    return true;
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
new file mode 100644
index 0000000..b37e112
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.gwtorm.server.OrmException;
+
+public interface Matchable<T> {
+  /**
+   * Does this predicate match this object?
+   *
+   * @throws OrmException
+   */
+  boolean match(T object) throws OrmException;
+
+  /** @return a cost estimate to run this predicate, higher figures cost more. */
+  int getCost();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
index 248fb9c..8ffba72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Collection;
@@ -21,7 +23,7 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> {
+public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
   protected NotPredicate(final Predicate<T> that) {
@@ -58,13 +60,20 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    return that.isMatchable();
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
-    return !that.match(object);
+    checkState(that.isMatchable(), "match invoked, but child predicate %s "
+        + "doesn't implement %s", that, Matchable.class.getName());
+    return !that.asMatchable().match(object);
   }
 
   @Override
   public int getCost() {
-    return that.getCost();
+    return that.estimateCost();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
index 87460d2..2cb70af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
@@ -22,7 +22,7 @@
   private final String name;
   private final String value;
 
-  public OperatorPredicate(final String name, final String value) {
+  protected OperatorPredicate(final String name, final String value) {
     this.name = name;
     this.value = value;
   }
@@ -66,8 +66,7 @@
     final String val = getValue();
     if (QueryParser.isSingleWord(val)) {
       return getOperator() + ":" + val;
-    } else {
-      return getOperator() + ":\"" + val + "\"";
     }
+    return getOperator() + ":\"" + val + "\"";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
index 2432a41..ad15286 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -23,7 +25,7 @@
 import java.util.List;
 
 /** Requires one predicate to be true. */
-public class OrPredicate<T> extends Predicate<T> {
+public class OrPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -39,11 +41,11 @@
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
-          c += gp.getCost();
+          c += gp.estimateCost();
         }
       } else {
         t.add(p);
-        c += p.getCost();
+        c += p.estimateCost();
       }
     }
     children = t;
@@ -71,9 +73,21 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    for (Predicate<T> c : children) {
+      if (!c.isMatchable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
     for (final Predicate<T> c : children) {
-      if (c.match(object)) {
+      checkState(c.isMatchable(), "match invoked, but child predicate %s "
+          + "doesn't implement %s", c, Matchable.class.getName());
+      if (c.asMatchable().match(object)) {
         return true;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
new file mode 100644
index 0000000..a51555e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public interface Paginated<T> {
+  QueryOptions getOptions();
+
+  ResultSet<T> restart(int start) throws OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
index f4be013..3a38da6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.collect.Iterables;
-import com.google.gwtorm.server.OrmException;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -113,15 +114,23 @@
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
-  /**
-   * Does this predicate match this object?
-   *
-   * @throws OrmException
-   */
-  public abstract boolean match(T object) throws OrmException;
+  public boolean isMatchable() {
+    return this instanceof Matchable;
+  }
+
+  @SuppressWarnings("unchecked")
+  public Matchable<T> asMatchable() {
+    checkState(isMatchable(), "not matchable");
+    return (Matchable<T>) this;
+  }
 
   /** @return a cost estimate to run this predicate, higher figures cost more. */
-  public abstract int getCost();
+  public int estimateCost() {
+    if (!isMatchable()) {
+      return 1;
+    }
+    return asMatchable().getCost();
+  }
 
   @Override
   public abstract int hashCode();
@@ -129,7 +138,7 @@
   @Override
   public abstract boolean equals(Object other);
 
-  private static class Any<T> extends Predicate<T> {
+  private static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
     private Any() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
index c8f9972..3a21ce4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
@@ -73,6 +73,11 @@
  * @param <T> type of object the predicates can evaluate in memory.
  */
 public abstract class QueryBuilder<T> {
+  /** Converts a value string passed to an operator into a {@link Predicate}. */
+  public interface OperatorFactory<T, Q extends QueryBuilder<T>> {
+    Predicate<T> create(Q builder, String value) throws QueryParseException;
+  }
+
   /**
    * Defines the operators known by a QueryBuilder.
    *
@@ -162,7 +167,7 @@
   protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
 
   @SuppressWarnings("rawtypes")
-  private final Map<String, OperatorFactory> opFactories;
+  protected final Map<String, OperatorFactory> opFactories;
 
   @SuppressWarnings({"unchecked", "rawtypes"})
   protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
@@ -323,11 +328,6 @@
     return new QueryParseException(msg, why);
   }
 
-  /** Converts a value string passed to an operator into a {@link Predicate}. */
-  protected interface OperatorFactory<T, Q extends QueryBuilder<T>> {
-    Predicate<T> create(Q builder, String value) throws QueryParseException;
-  }
-
   /** Denotes a method which is a query operator. */
   @Retention(RetentionPolicy.RUNTIME)
   @Target(ElementType.METHOD)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
new file mode 100644
index 0000000..8373d4d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
@@ -0,0 +1,265 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.SchemaDefinitions;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+public abstract class QueryProcessor<T> {
+  @Singleton
+  protected static class Metrics {
+    final Timer1<String> executionTime;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      Field<String> index = Field.ofString("index", "index name");
+      executionTime = metricMaker.newTimer("query/query_latency",
+          new Description("Successful query latency,"
+              + " accumulated over the life of the process").setCumulative()
+                  .setUnit(Description.Units.MILLISECONDS),
+          index);
+    }
+  }
+
+  protected final Provider<CurrentUser> userProvider;
+
+  private final Metrics metrics;
+  private final SchemaDefinitions<T> schemaDef;
+  private final IndexConfig indexConfig;
+  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  private final IndexRewriter<T> rewriter;
+  private final String limitField;
+
+  protected int start;
+
+  private boolean enforceVisibility = true;
+  private int limitFromCaller;
+  private Set<String> requestedFields;
+
+  protected QueryProcessor(
+      Provider<CurrentUser> userProvider,
+      Metrics metrics,
+      SchemaDefinitions<T> schemaDef,
+      IndexConfig indexConfig,
+      IndexCollection<?, T, ? extends Index<?, T>> indexes,
+      IndexRewriter<T> rewriter,
+      String limitField) {
+    this.userProvider = userProvider;
+    this.metrics = metrics;
+    this.schemaDef = schemaDef;
+    this.indexConfig = indexConfig;
+    this.indexes = indexes;
+    this.rewriter = rewriter;
+    this.limitField = limitField;
+  }
+
+  public QueryProcessor<T> setStart(int n) {
+    start = n;
+    return this;
+  }
+
+  public QueryProcessor<T> enforceVisibility(boolean enforce) {
+    enforceVisibility = enforce;
+    return this;
+  }
+
+  public QueryProcessor<T> setLimit(int n) {
+    limitFromCaller = n;
+    return this;
+  }
+
+  public QueryProcessor<T> setRequestedFields(Set<String> fields) {
+    requestedFields = fields;
+    return this;
+  }
+
+  /**
+   * Query for entities that match a structured query.
+   *
+   * @see #query(List)
+   * @param query the query.
+   * @return results of the query.
+   */
+  public QueryResult<T> query(Predicate<T> query)
+      throws OrmException, QueryParseException {
+    return query(ImmutableList.of(query)).get(0);
+  }
+
+  /*
+   * Perform multiple queries over a list of query strings.
+   * <p>
+   * If a limit was specified using {@link #setLimit(int)} this method may
+   * return up to {@code limit + 1} results, allowing the caller to determine if
+   * there are more than {@code limit} matches and suggest to its own caller
+   * that the query could be retried with {@link #setStart(int)}.
+   *
+   * @param queries the queries.
+   * @return results of the queries, one list per input query.
+   */
+  public List<QueryResult<T>> query(List<Predicate<T>> queries)
+      throws OrmException, QueryParseException {
+    try {
+      return query(null, queries);
+    } catch (OrmRuntimeException e) {
+      throw new OrmException(e.getMessage(), e);
+    } catch (OrmException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), QueryParseException.class);
+      throw e;
+    }
+  }
+
+  private List<QueryResult<T>> query(List<String> queryStrings,
+      List<Predicate<T>> queries)
+      throws OrmException, QueryParseException {
+    long startNanos = System.nanoTime();
+
+    int cnt = queries.size();
+    // Parse and rewrite all queries.
+    List<Integer> limits = new ArrayList<>(cnt);
+    List<Predicate<T>> predicates = new ArrayList<>(cnt);
+    List<DataSource<T>> sources = new ArrayList<>(cnt);
+    for (Predicate<T> q : queries) {
+      int limit = getEffectiveLimit(q);
+      limits.add(limit);
+
+      if (limit == getBackendSupportedLimit()) {
+        limit--;
+      }
+
+      int page = (start / limit) + 1;
+      if (page > indexConfig.maxPages()) {
+        throw new QueryParseException(
+            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+      }
+
+      // Always bump limit by 1, even if this results in exceeding the permitted
+      // max for this user. The only way to see if there are more entities is to
+      // ask for one more result from the query.
+      QueryOptions opts =
+          createOptions(indexConfig, start, limit + 1, getRequestedFields());
+      Predicate<T> pred = rewriter.rewrite(q, opts);
+      if (enforceVisibility) {
+        pred = enforceVisibility(pred);
+      }
+      predicates.add(pred);
+
+      @SuppressWarnings("unchecked")
+      DataSource<T> s = (DataSource<T>) pred;
+      sources.add(s);
+    }
+
+    // Run each query asynchronously, if supported.
+    List<ResultSet<T>> matches = new ArrayList<>(cnt);
+    for (DataSource<T> s : sources) {
+      matches.add(s.read());
+    }
+
+    List<QueryResult<T>> out = new ArrayList<>(cnt);
+    for (int i = 0; i < cnt; i++) {
+      out.add(QueryResult.create(
+          queryStrings != null ? queryStrings.get(i) : null,
+          predicates.get(i),
+          limits.get(i),
+          matches.get(i).toList()));
+    }
+
+    // only measure successful queries
+    metrics.executionTime.record(schemaDef.getName(),
+        System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+    return out;
+  }
+
+  protected QueryOptions createOptions(IndexConfig indexConfig, int start,
+      int limit, Set<String> requestedFields) {
+    return QueryOptions.create(indexConfig, start, limit, requestedFields);
+  }
+
+  /**
+   * Invoked after the query was rewritten. Subclasses must overwrite this
+   * method to filter out results that are not visible to the calling user.
+   *
+   * @param pred the query
+   * @return the modified query
+   */
+  protected abstract Predicate<T> enforceVisibility(Predicate<T> pred);
+
+  private Set<String> getRequestedFields() {
+    if (requestedFields != null) {
+      return requestedFields;
+    }
+    Index<?, T> index = indexes.getSearchIndex();
+    return index != null
+        ? index.getSchema().getStoredFields().keySet()
+        : ImmutableSet.<String> of();
+  }
+
+  public boolean isDisabled() {
+    return getPermittedLimit() <= 0;
+  }
+
+  private int getPermittedLimit() {
+    if (enforceVisibility) {
+      return userProvider.get().getCapabilities()
+        .getRange(GlobalCapability.QUERY_LIMIT)
+        .getMax();
+    }
+    return Integer.MAX_VALUE;
+  }
+
+  private int getBackendSupportedLimit() {
+    return indexConfig.maxLimit();
+  }
+
+  private int getEffectiveLimit(Predicate<T> p) {
+    List<Integer> possibleLimits = new ArrayList<>(4);
+    possibleLimits.add(getBackendSupportedLimit());
+    possibleLimits.add(getPermittedLimit());
+    if (limitFromCaller > 0) {
+      possibleLimits.add(limitFromCaller);
+    }
+    if (limitField != null) {
+      Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
+      if (limitFromPredicate != null) {
+        possibleLimits.add(limitFromPredicate);
+      }
+    }
+    return Ordering.natural().min(possibleLimits);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
new file mode 100644
index 0000000..b35bde3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+import java.util.List;
+
+/** Results of a query over entities. */
+@AutoValue
+public abstract class QueryResult<T> {
+  static <T> QueryResult<T> create(@Nullable String query,
+      Predicate<T> predicate, int limit, List<T> entites) {
+    boolean more;
+    if (entites.size() > limit) {
+      more = true;
+      entites = entites.subList(0, limit);
+    } else {
+      more = false;
+    }
+    return new AutoValue_QueryResult<>(query, predicate, entites, more);
+  }
+
+  /**
+   * @return the original query string, or null if the query was created
+   *     programmatically.
+   */
+  @Nullable public abstract String query();
+
+  /**
+   * @return the predicate after all rewriting and other modification by the
+   *     query subsystem.
+   */
+  public abstract Predicate<T> predicate();
+
+  /** @return the query results. */
+  public abstract List<T> entities();
+
+  /**
+   * @return whether the query could be retried with
+   *     {@link QueryProcessor#setStart(int)} to produce more results. Never
+   *     true if {@link #entities()} is empty.
+   */
+  public abstract boolean more();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
new file mode 100644
index 0000000..dc68a61
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gwtorm.server.OrmException;
+
+public class AccountIsVisibleToPredicate
+    extends IsVisibleToPredicate<AccountState> {
+  private static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups() //
+          .iterator().next().toString();
+    }
+    return user.toString();
+  }
+
+  private final AccountControl accountControl;
+
+  AccountIsVisibleToPredicate(AccountControl accountControl) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO,
+        describe(accountControl.getUser()));
+    this.accountControl = accountControl;
+  }
+
+  @Override
+  public boolean match(AccountState accountState) throws OrmException {
+    return accountControl.canSee(accountState);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
new file mode 100644
index 0000000..b3f92ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryBuilder;
+
+import java.util.List;
+
+public class AccountPredicates {
+  public static boolean hasActive(Predicate<AccountState> p) {
+    return QueryBuilder.find(p, AccountPredicate.class,
+        AccountField.ACTIVE.getName()) != null;
+  }
+
+  static Predicate<AccountState> defaultPredicate(String query) {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
+    Integer id = Ints.tryParse(query);
+    if (id != null) {
+      preds.add(id(new Account.Id(id)));
+    }
+    preds.add(equalsName(query));
+    preds.add(username(query));
+    // Adapt the capacity of the "predicates" list when adding more default
+    // predicates.
+    return Predicate.or(preds);
+  }
+
+  static Predicate<AccountState> id(Account.Id accountId) {
+    return new AccountPredicate(AccountField.ID,
+        AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
+  }
+
+  static Predicate<AccountState> email(String email) {
+    return new AccountPredicate(AccountField.EMAIL,
+        AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+  }
+
+  static Predicate<AccountState> equalsName(String name) {
+    return new AccountPredicate(AccountField.NAME_PART,
+        AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+  }
+
+  static Predicate<AccountState> externalId(String externalId) {
+    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
+  }
+
+  static Predicate<AccountState> fullName(String fullName) {
+    return new AccountPredicate(AccountField.FULL_NAME, fullName);
+  }
+
+  public static Predicate<AccountState> isActive() {
+    return new AccountPredicate(AccountField.ACTIVE, "1");
+  }
+
+  static Predicate<AccountState> isInactive() {
+    return new AccountPredicate(AccountField.ACTIVE, "0");
+  }
+
+  static Predicate<AccountState> username(String username) {
+    return new AccountPredicate(AccountField.USERNAME,
+        AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+  }
+
+  static Predicate<AccountState> watchedProject(Project.NameKey project) {
+    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
+  }
+
+  static class AccountPredicate extends IndexPredicate<AccountState> {
+    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
+      super(def, value);
+    }
+
+    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
+      super(def, name, value);
+    }
+  }
+
+  private AccountPredicates() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
new file mode 100644
index 0000000..0288cb2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.query.LimitPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryBuilder;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+
+/**
+ * Parses a query string meant to be applied to account objects.
+ */
+public class AccountQueryBuilder extends QueryBuilder<AccountState> {
+  public interface ChangeOperatorFactory
+      extends OperatorFactory<AccountState, AccountQueryBuilder> {
+  }
+
+  public static final String FIELD_ACCOUNT = "account";
+  public static final String FIELD_EMAIL = "email";
+  public static final String FIELD_LIMIT = "limit";
+  public static final String FIELD_NAME = "name";
+  public static final String FIELD_USERNAME = "username";
+  public static final String FIELD_VISIBLETO = "visibleto";
+
+  private static final QueryBuilder.Definition<AccountState, AccountQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(AccountQueryBuilder.class);
+
+  public static class Arguments {
+    private final Provider<CurrentUser> self;
+
+    @Inject
+    public Arguments(Provider<CurrentUser> self) {
+      this.self = self;
+    }
+
+    IdentifiedUser getIdentifiedUser() throws QueryParseException {
+      try {
+        CurrentUser u = getUser();
+        if (u.isIdentifiedUser()) {
+          return u.asIdentifiedUser();
+        }
+        throw new QueryParseException(NotSignedInException.MESSAGE);
+      } catch (ProvisionException e) {
+        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+      }
+    }
+
+    CurrentUser getUser() throws QueryParseException {
+      try {
+        return self.get();
+      } catch (ProvisionException e) {
+        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+      }
+    }
+  }
+
+  private final Arguments args;
+
+  @Inject
+  AccountQueryBuilder(Arguments args) {
+    super(mydef);
+    this.args = args;
+  }
+
+  @Operator
+  public Predicate<AccountState> email(String email) {
+    return AccountPredicates.email(email);
+  }
+
+  @Operator
+  public Predicate<AccountState> is(String value) throws QueryParseException {
+    if ("active".equalsIgnoreCase(value)) {
+      return AccountPredicates.isActive();
+    }
+    if ("inactive".equalsIgnoreCase(value)) {
+      return AccountPredicates.isInactive();
+    }
+    throw error("Invalid query");
+  }
+
+  @Operator
+  public Predicate<AccountState> limit(String query)
+      throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+
+  @Operator
+  public Predicate<AccountState> name(String name) {
+    return AccountPredicates.equalsName(name);
+  }
+
+  @Operator
+  public Predicate<AccountState> username(String username) {
+    return AccountPredicates.username(username);
+  }
+
+  public Predicate<AccountState> defaultQuery(String query) {
+    return Predicate.and(
+        Lists.transform(Splitter.on(' ').omitEmptyStrings().splitToList(query),
+            new Function<String, Predicate<AccountState>>() {
+              @Override
+              public Predicate<AccountState> apply(String s) {
+                return defaultField(s);
+              }
+            }));
+  }
+
+  @Override
+  protected Predicate<AccountState> defaultField(String query) {
+    Predicate<AccountState> defaultPredicate =
+        AccountPredicates.defaultPredicate(query);
+    if ("self".equalsIgnoreCase(query)) {
+      try {
+        return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
+      } catch (QueryParseException e) {
+        // Skip.
+      }
+    }
+    return defaultPredicate;
+  }
+
+  private Account.Id self() throws QueryParseException {
+    return args.getIdentifiedUser().getAccountId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
new file mode 100644
index 0000000..48d0897
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.account.AccountQueryBuilder.FIELD_LIMIT;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.query.AndSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryProcessor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class AccountQueryProcessor extends QueryProcessor<AccountState> {
+  private final AccountControl.Factory accountControlFactory;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !AccountIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "AccountQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  protected AccountQueryProcessor(Provider<CurrentUser> userProvider,
+      Metrics metrics,
+      IndexConfig indexConfig,
+      AccountIndexCollection indexes,
+      AccountIndexRewriter rewriter,
+      AccountControl.Factory accountControlFactory) {
+    super(userProvider, metrics, AccountSchemaDefinitions.INSTANCE, indexConfig,
+        indexes, rewriter, FIELD_LIMIT);
+    this.accountControlFactory = accountControlFactory;
+  }
+
+  @Override
+  protected Predicate<AccountState> enforceVisibility(
+      Predicate<AccountState> pred) {
+    return new AndSource<>(pred,
+        new AccountIsVisibleToPredicate(accountControlFactory.get()), start);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
new file mode 100644
index 0000000..7bc3144
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.InternalQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Set;
+
+public class InternalAccountQuery extends InternalQuery<AccountState> {
+  private static final Logger log =
+      LoggerFactory.getLogger(InternalAccountQuery.class);
+
+  @Inject
+  InternalAccountQuery(AccountQueryProcessor queryProcessor,
+      AccountIndexCollection indexes,
+      IndexConfig indexConfig) {
+    super(queryProcessor, indexes, indexConfig);
+  }
+
+  @Override
+  public InternalAccountQuery setLimit(int n) {
+    super.setLimit(n);
+    return this;
+  }
+
+  @Override
+  public InternalAccountQuery enforceVisibility(boolean enforce) {
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @Override
+  public InternalAccountQuery setRequestedFields(Set<String> fields) {
+    super.setRequestedFields(fields);
+    return this;
+  }
+
+  @Override
+  public InternalAccountQuery noFields() {
+    super.noFields();
+    return this;
+  }
+
+  public List<AccountState> byDefault(String query)
+      throws OrmException {
+    return query(AccountPredicates.defaultPredicate(query));
+  }
+
+  public List<AccountState> byExternalId(String externalId)
+      throws OrmException {
+    return query(AccountPredicates.externalId(externalId));
+  }
+
+  public AccountState oneByExternalId(String externalId) throws OrmException {
+    List<AccountState> accountStates = byExternalId(externalId);
+    if (accountStates.size() == 1) {
+      return accountStates.get(0);
+    } else if (accountStates.size() > 0) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("Ambiguous external ID ")
+          .append(externalId)
+          .append("for accounts: ");
+      Joiner.on(", ").appendTo(msg,
+          Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
+      log.warn(msg.toString());
+    }
+    return null;
+  }
+
+  public List<AccountState> byFullName(String fullName)
+      throws OrmException {
+    return query(AccountPredicates.fullName(fullName));
+  }
+
+  public List<AccountState> byWatchedProject(Project.NameKey project)
+      throws OrmException {
+    return query(AccountPredicates.watchedProject(project));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AbstractResultSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AbstractResultSet.java
deleted file mode 100644
index 9bbc02f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AbstractResultSet.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.ArrayList;
-import java.util.List;
-
-abstract class AbstractResultSet<T> implements ResultSet<T> {
-  @Override
-  public List<T> toList() {
-    ArrayList<T> r = new ArrayList<>();
-    for (T t : this) {
-      r.add(t);
-    }
-    return r;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
index 95da72a..b3cdd6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-public class AddedPredicate extends IntegerRangePredicate<ChangeData> {
+public class AddedPredicate extends IntegerRangeChangePredicate {
   AddedPredicate(String value) throws QueryParseException {
     super(ChangeField.ADDED, value);
   }
 
   @Override
-  protected int getValueInt(ChangeData changeData) throws OrmException {
-    return changeData.changedLines().insertions;
+  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+    return ChangeField.ADDED.get(changeData, null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index aeb9619..477bf16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Date;
 
-public class AfterPredicate extends TimestampRangePredicate<ChangeData> {
+public class AfterPredicate extends TimestampRangeChangePredicate {
   private final Date cut;
 
   AfterPredicate(String value) throws QueryParseException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index 9a4ef19..fd6cbee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -20,13 +20,12 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.sql.Timestamp;
 
-public class AgePredicate extends TimestampRangePredicate<ChangeData> {
+public class AgePredicate extends TimestampRangeChangePredicate {
   private final long cut;
 
   AgePredicate(String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
new file mode 100644
index 0000000..bd7daed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.query.AndSource;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+
+import java.util.Collection;
+import java.util.List;
+
+public class AndChangeSource extends AndSource<ChangeData>
+    implements ChangeDataSource {
+
+  public AndChangeSource(Collection<Predicate<ChangeData>> that) {
+    super(that);
+  }
+
+  public AndChangeSource(Predicate<ChangeData> that,
+      IsVisibleToPredicate<ChangeData> isVisibleToPredicate, int start) {
+    super(that, isVisibleToPredicate, start);
+  }
+
+  @Override
+  public boolean hasChange() {
+    return source != null && source instanceof ChangeDataSource
+        && ((ChangeDataSource) source).hasChange();
+  }
+
+  @Override
+  protected List<ChangeData> transformBuffer(List<ChangeData> buffer)
+      throws OrmRuntimeException {
+    if (!hasChange()) {
+      try {
+        ChangeData.ensureChangeLoaded(buffer);
+      } catch (OrmException e) {
+        throw new OrmRuntimeException(e);
+      }
+    }
+    return super.transformBuffer(buffer);
+  }
+
+  @Override
+  public int compare(Predicate<ChangeData> a, Predicate<ChangeData> b) {
+    int cmp = super.compare(a, b);
+    if (cmp == 0 && a instanceof ChangeDataSource
+        && b instanceof ChangeDataSource) {
+      ChangeDataSource as = (ChangeDataSource) a;
+      ChangeDataSource bs = (ChangeDataSource) b;
+      cmp = (as.hasChange() ? 0 : 1) - (bs.hasChange() ? 0 : 1);
+    }
+    return cmp;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
deleted file mode 100644
index 9ed0447..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
+++ /dev/null
@@ -1,200 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Function;
-import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-public class AndSource extends AndPredicate<ChangeData>
-    implements ChangeDataSource {
-  private static final Comparator<Predicate<ChangeData>> CMP =
-      new Comparator<Predicate<ChangeData>>() {
-        @Override
-        public int compare(Predicate<ChangeData> a, Predicate<ChangeData> b) {
-          int ai = a instanceof ChangeDataSource ? 0 : 1;
-          int bi = b instanceof ChangeDataSource ? 0 : 1;
-          int cmp = ai - bi;
-
-          if (cmp == 0) {
-            cmp = a.getCost() - b.getCost();
-          }
-
-          if (cmp == 0 //
-              && a instanceof ChangeDataSource //
-              && b instanceof ChangeDataSource) {
-            ChangeDataSource as = (ChangeDataSource) a;
-            ChangeDataSource bs = (ChangeDataSource) b;
-            cmp = as.getCardinality() - bs.getCardinality();
-
-            if (cmp == 0) {
-              cmp = (as.hasChange() ? 0 : 1)
-                  - (bs.hasChange() ? 0 : 1);
-            }
-          }
-
-          return cmp;
-        }
-      };
-
-  private static List<Predicate<ChangeData>> sort(
-      Collection<? extends Predicate<ChangeData>> that) {
-    List<Predicate<ChangeData>> r = new ArrayList<>(that);
-    Collections.sort(r, CMP);
-    return r;
-  }
-
-  private final int start;
-  private int cardinality = -1;
-
-  public AndSource(Collection<? extends Predicate<ChangeData>> that) {
-    this(that, 0);
-  }
-
-  public AndSource(Collection<? extends Predicate<ChangeData>> that,
-      int start) {
-    super(sort(that));
-    checkArgument(start >= 0, "negative start: %s", start);
-    this.start = start;
-  }
-
-  @Override
-  public boolean hasChange() {
-    ChangeDataSource source = source();
-    return source != null && source.hasChange();
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    try {
-      return readImpl();
-    } catch (OrmRuntimeException err) {
-      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
-      throw new OrmException(err);
-    }
-  }
-
-  private ResultSet<ChangeData> readImpl() throws OrmException {
-    ChangeDataSource source = source();
-    if (source == null) {
-      throw new OrmException("No ChangeDataSource: " + this);
-    }
-    List<ChangeData> r = Lists.newArrayList();
-    ChangeData last = null;
-    int nextStart = 0;
-    boolean skipped = false;
-    for (ChangeData data : buffer(source, source.read())) {
-      if (match(data)) {
-        r.add(data);
-      } else {
-        skipped = true;
-      }
-      last = data;
-      nextStart++;
-    }
-
-    if (skipped && last != null && source instanceof Paginated) {
-      // If our source is a paginated source and we skipped at
-      // least one of its results, we may not have filled the full
-      // limit the caller wants.  Restart the source and continue.
-      //
-      Paginated p = (Paginated) source;
-      while (skipped && r.size() < p.getOptions().limit() + start) {
-        skipped = false;
-        ResultSet<ChangeData> next = p.restart(nextStart);
-
-        for (ChangeData data : buffer(source, next)) {
-          if (match(data)) {
-            r.add(data);
-          } else {
-            skipped = true;
-          }
-          nextStart++;
-        }
-      }
-    }
-
-    if (start >= r.size()) {
-      r = ImmutableList.of();
-    } else if (start > 0) {
-      r = ImmutableList.copyOf(r.subList(start, r.size()));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  private Iterable<ChangeData> buffer(
-      ChangeDataSource source,
-      ResultSet<ChangeData> scanner) {
-    final boolean loadChange = !source.hasChange();
-    return FluentIterable
-      .from(Iterables.partition(scanner, 50))
-      .transformAndConcat(new Function<List<ChangeData>, List<ChangeData>>() {
-        @Override
-        public List<ChangeData> apply(List<ChangeData> buffer) {
-          if (loadChange) {
-            try {
-              ChangeData.ensureChangeLoaded(buffer);
-            } catch (OrmException e) {
-              throw new OrmRuntimeException(e);
-            }
-          }
-          return buffer;
-        }
-      });
-  }
-
-  private ChangeDataSource source() {
-    int minCost = Integer.MAX_VALUE;
-    Predicate<ChangeData> s = null;
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (p instanceof ChangeDataSource && p.getCost() < minCost) {
-        s = p;
-        minCost = p.getCost();
-      }
-    }
-    return (ChangeDataSource) s;
-  }
-
-  @Override
-  public int getCardinality() {
-    if (cardinality < 0) {
-      cardinality = Integer.MAX_VALUE;
-      for (Predicate<ChangeData> p : getChildren()) {
-        if (p instanceof ChangeDataSource) {
-          int c = ((ChangeDataSource) p).getCardinality();
-          cardinality = Math.min(cardinality, c);
-        }
-      }
-    }
-    return cardinality;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index 6264f3a..ebaaab9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.AUTHOR;
+import static com.google.gerrit.server.index.change.ChangeField.AUTHOR;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class AuthorPredicate extends IndexPredicate<ChangeData>  {
+public class AuthorPredicate extends ChangeIndexPredicate {
   AuthorPredicate(String value) {
     super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 8f51476..f36a1631 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Date;
 
-public class BeforePredicate extends TimestampRangePredicate<ChangeData> {
+public class BeforePredicate extends TimestampRangeChangePredicate {
   private final Date cut;
 
   BeforePredicate(String value) throws QueryParseException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 0618db9..ba58113 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -15,17 +15,22 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
+import com.google.common.base.Optional;
 import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.reviewdb.client.Account;
@@ -35,6 +40,7 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -42,12 +48,15 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -114,9 +123,10 @@
       for (ChangeData cd : changes) {
         cd.change();
       }
+      return;
     }
 
-    Map<Change.Id, ChangeData> missing = Maps.newHashMap();
+    Map<Change.Id, ChangeData> missing = new HashMap<>();
     for (ChangeData cd : changes) {
       if (cd.change == null) {
         missing.put(cd.getId(), cd);
@@ -125,8 +135,9 @@
     if (missing.isEmpty()) {
       return;
     }
-    for (Change change : first.db.changes().get(missing.keySet())) {
-      missing.get(change.getId()).change = change;
+    for (ChangeNotes notes : first.notesFactory.create(
+        first.db, missing.keySet())) {
+      missing.get(notes.getChangeId()).change = notes.getChange();
     }
   }
 
@@ -139,6 +150,7 @@
       for (ChangeData cd : changes) {
         cd.patchSets();
       }
+      return;
     }
 
     List<ResultSet<PatchSet>> results = new ArrayList<>(BATCH_SIZE);
@@ -169,9 +181,10 @@
       for (ChangeData cd : changes) {
         cd.currentPatchSet();
       }
+      return;
     }
 
-    Map<PatchSet.Id, ChangeData> missing = Maps.newHashMap();
+    Map<PatchSet.Id, ChangeData> missing = new HashMap<>();
     for (ChangeData cd : changes) {
       if (cd.currentPatchSet == null && cd.patchSets == null) {
         missing.put(cd.change().currentPatchSetId(), cd);
@@ -194,6 +207,7 @@
       for (ChangeData cd : changes) {
         cd.currentApprovals();
       }
+      return;
     }
 
     List<ResultSet<PatchSetApproval>> results = new ArrayList<>(BATCH_SIZE);
@@ -267,9 +281,13 @@
   }
 
   public interface Factory {
-    ChangeData create(ReviewDb db, Change.Id id);
+    ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id);
     ChangeData create(ReviewDb db, Change c);
+    ChangeData create(ReviewDb db, ChangeNotes cn);
     ChangeData create(ReviewDb db, ChangeControl c);
+
+    // TODO(dborowitz): Remove when deleting index schemas <27.
+    ChangeData createOnlyWhenNoteDbDisabled(ReviewDb db, Change.Id id);
   }
 
   /**
@@ -281,9 +299,10 @@
    * @param id change ID
    * @return instance for testing.
    */
-  public static ChangeData createForTest(Change.Id id, int currentPatchSetId) {
+  public static ChangeData createForTest(Project.NameKey project, Change.Id id,
+      int currentPatchSetId) {
     ChangeData cd = new ChangeData(null, null, null, null, null, null, null,
-        null, null, null, null, null, null, id);
+        null, null, null, null, null, null, null, null, project, id);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
     return cd;
   }
@@ -298,11 +317,13 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchLineCommentsUtil plcUtil;
+  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final NotesMigration notesMigration;
   private final MergeabilityCache mergeabilityCache;
+  private final StarredChangesUtil starredChangesUtil;
   private final Change.Id legacyId;
-  private ChangeDataSource returnedBySource;
+  private Project.NameKey project;
   private Change change;
   private ChangeNotes notes;
   private String commitMessage;
@@ -311,16 +332,25 @@
   private Collection<PatchSet> patchSets;
   private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals;
   private List<PatchSetApproval> currentApprovals;
-  private Map<Integer, List<String>> files = new HashMap<>();
+  private Map<Integer, List<String>> files;
+  private Map<Integer, Optional<PatchList>> patchLists;
   private Collection<PatchLineComment> publishedComments;
   private CurrentUser visibleTo;
   private ChangeControl changeControl;
   private List<ChangeMessage> messages;
   private List<SubmitRecord> submitRecords;
-  private ChangedLines changedLines;
+  private Optional<ChangedLines> changedLines;
+  private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
+  private Set<String> hashtags;
   private Set<Account.Id> editsByUser;
   private Set<Account.Id> reviewedBy;
+  private Set<Account.Id> draftsByUser;
+  @Deprecated
+  private Set<Account.Id> starredByUser;
+  private ImmutableMultimap<Account.Id, String> stars;
+  private ReviewerSet reviewers;
+  private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
 
@@ -335,10 +365,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
       MergeabilityCache mergeabilityCache,
+      @Nullable StarredChangesUtil starredChangesUtil,
       @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
       @Assisted Change.Id id) {
     this.db = db;
     this.repoManager = repoManager;
@@ -350,10 +383,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
     this.mergeabilityCache = mergeabilityCache;
-    legacyId = id;
+    this.starredChangesUtil = starredChangesUtil;
+    this.project = project;
+    this.legacyId = id;
   }
 
   @AssistedInject
@@ -367,9 +403,11 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
       MergeabilityCache mergeabilityCache,
+      @Nullable StarredChangesUtil starredChangesUtil,
       @Assisted ReviewDb db,
       @Assisted Change c) {
     this.db = db;
@@ -382,11 +420,14 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
     this.mergeabilityCache = mergeabilityCache;
+    this.starredChangesUtil = starredChangesUtil;
     legacyId = c.getId();
     change = c;
+    project = c.getProject();
   }
 
   @AssistedInject
@@ -400,9 +441,50 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
       MergeabilityCache mergeabilityCache,
+      @Nullable StarredChangesUtil starredChangesUtil,
+      @Assisted ReviewDb db,
+      @Assisted ChangeNotes cn) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.changeControlFactory = changeControlFactory;
+    this.userFactory = userFactory;
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.notesFactory = notesFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.cmUtil = cmUtil;
+    this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+    this.notesMigration = notesMigration;
+    this.mergeabilityCache = mergeabilityCache;
+    this.starredChangesUtil = starredChangesUtil;
+    legacyId = cn.getChangeId();
+    change = cn.getChange();
+    project = cn.getProjectName();
+    notes = cn;
+  }
+
+  @AssistedInject
+  private ChangeData(
+      GitRepositoryManager repoManager,
+      ChangeControl.GenericFactory changeControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory,
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache,
+      NotesMigration notesMigration,
+      MergeabilityCache mergeabilityCache,
+      @Nullable StarredChangesUtil starredChangesUtil,
       @Assisted ReviewDb db,
       @Assisted ChangeControl c) {
     this.db = db;
@@ -415,31 +497,72 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
     this.mergeabilityCache = mergeabilityCache;
-    legacyId = c.getChange().getId();
+    this.starredChangesUtil = starredChangesUtil;
+    legacyId = c.getId();
     change = c.getChange();
     changeControl = c;
     notes = c.getNotes();
+    project = notes.getProjectName();
+  }
+
+  @AssistedInject
+  private ChangeData(
+      GitRepositoryManager repoManager,
+      ChangeControl.GenericFactory changeControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      ProjectCache projectCache,
+      MergeUtil.Factory mergeUtilFactory,
+      ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      PatchLineCommentsUtil plcUtil,
+      PatchSetUtil psUtil,
+      PatchListCache patchListCache,
+      NotesMigration notesMigration,
+      MergeabilityCache mergeabilityCache,
+      @Nullable StarredChangesUtil starredChangesUtil,
+      @Assisted ReviewDb db,
+      @Assisted Change.Id id) {
+    checkState(!notesMigration.readChanges(),
+        "do not call createOnlyWhenNoteDbDisabled when NoteDb is enabled");
+    this.db = db;
+    this.repoManager = repoManager;
+    this.changeControlFactory = changeControlFactory;
+    this.userFactory = userFactory;
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.notesFactory = notesFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.cmUtil = cmUtil;
+    this.plcUtil = plcUtil;
+    this.psUtil = psUtil;
+    this.patchListCache = patchListCache;
+    this.notesMigration = notesMigration;
+    this.mergeabilityCache = mergeabilityCache;
+    this.starredChangesUtil = starredChangesUtil;
+    this.legacyId = id;
+    this.project = null;
   }
 
   public ReviewDb db() {
     return db;
   }
 
-  public boolean isFromSource(ChangeDataSource s) {
-    return s == returnedBySource;
-  }
-
-  public void cacheFromSource(ChangeDataSource s) {
-    returnedBySource = s;
+  private Map<Integer, List<String>> initFiles() {
+    if (files == null) {
+      files = new HashMap<>();
+    }
+    return files;
   }
 
   public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
     PatchSet ps = currentPatchSet();
     if (ps != null) {
-      files.put(ps.getPatchSetId(), ImmutableList.copyOf(filePaths));
+      initFiles().put(ps.getPatchSetId(), ImmutableList.copyOf(filePaths));
     }
   }
 
@@ -452,23 +575,23 @@
   }
 
   public List<String> filePaths(PatchSet ps) throws OrmException {
-    if (!files.containsKey(ps.getPatchSetId())) {
+    Integer psId = ps.getPatchSetId();
+    List<String> r = initFiles().get(psId);
+    if (r == null) {
       Change c = change();
       if (c == null) {
         return null;
       }
 
-      PatchList p;
-      try {
-        p = patchListCache.get(c, ps);
-      } catch (PatchListNotAvailableException e) {
+      Optional<PatchList> p = getPatchList(c, ps);
+      if (!p.isPresent()) {
         List<String> emptyFileList = Collections.emptyList();
         files.put(ps.getPatchSetId(), emptyFileList);
         return emptyFileList;
       }
 
-      List<String> r = new ArrayList<>(p.getPatches().size());
-      for (PatchListEntry e : p.getPatches()) {
+      r = new ArrayList<>(p.get().getPatches().size());
+      for (PatchListEntry e : p.get().getPatches()) {
         if (Patch.COMMIT_MSG.equals(e.getNewName())) {
           continue;
         }
@@ -488,43 +611,74 @@
         }
       }
       Collections.sort(r);
-      files.put(ps.getPatchSetId(), Collections.unmodifiableList(r));
+      r = Collections.unmodifiableList(r);
+      files.put(psId, r);
     }
-    return files.get(ps.getPatchSetId());
+    return r;
   }
 
-  public ChangedLines changedLines() throws OrmException {
-    if (changedLines == null) {
-      Change c = change();
-      if (c == null) {
-        return null;
-      }
-
-      PatchSet ps = currentPatchSet();
-      if (ps == null) {
-        return null;
-      }
-
-      PatchList p;
+  private Optional<PatchList> getPatchList(Change c, PatchSet ps) {
+    Integer psId = ps.getId().get();
+    if (patchLists == null) {
+      patchLists = new HashMap<>();
+    }
+    Optional<PatchList> r = patchLists.get(psId);
+    if (r == null) {
       try {
-        p = patchListCache.get(c, ps);
+        r = Optional.of(patchListCache.get(c, ps));
       } catch (PatchListNotAvailableException e) {
-        return null;
+        r = Optional.absent();
       }
+      patchLists.put(psId, r);
+    }
+    return r;
+  }
 
-      changedLines = new ChangedLines(p.getInsertions(), p.getDeletions());
+  private Optional<ChangedLines> computeChangedLines() throws OrmException {
+    Change c = change();
+    if (c == null) {
+      return Optional.absent();
+    }
+    PatchSet ps = currentPatchSet();
+    if (ps == null) {
+      return Optional.absent();
+    }
+    Optional<PatchList> p = getPatchList(c, ps);
+    if (!p.isPresent()) {
+      return Optional.absent();
+    }
+    return Optional.of(
+        new ChangedLines(p.get().getInsertions(), p.get().getDeletions()));
+  }
+
+  public Optional<ChangedLines> changedLines() throws OrmException {
+    if (changedLines == null) {
+      changedLines = computeChangedLines();
     }
     return changedLines;
   }
 
   public void setChangedLines(int insertions, int deletions) {
-    changedLines = new ChangedLines(insertions, deletions);
+    changedLines = Optional.of(new ChangedLines(insertions, deletions));
+  }
+
+  public void setNoChangedLines() {
+    changedLines = Optional.absent();
   }
 
   public Change.Id getId() {
     return legacyId;
   }
 
+  public Project.NameKey project() throws OrmException {
+    if (project == null) {
+      checkState(!notesMigration.readChanges(), "should not have created "
+          + " ChangeData without a project when NoteDb is enabled");
+      project = change().getProject();
+    }
+    return project;
+  }
+
   boolean fastIsVisibleTo(CurrentUser user) {
     return visibleTo == user;
   }
@@ -537,8 +691,8 @@
     if (changeControl == null) {
       Change c = change();
       try {
-        changeControl =
-            changeControlFactory.controlFor(c, userFactory.create(c.getOwner()));
+        changeControl = changeControlFactory.controlFor(
+            db, c, userFactory.create(c.getOwner()));
       } catch (NoSuchChangeException e) {
         throw new OrmException(e);
       }
@@ -546,6 +700,31 @@
     return changeControl;
   }
 
+  public ChangeControl changeControl(CurrentUser user) throws OrmException {
+    if (changeControl != null) {
+      CurrentUser oldUser = user;
+      // TODO(dborowitz): This is a hack; general CurrentUser equality would be
+      // better.
+      if (user.isIdentifiedUser() && oldUser.isIdentifiedUser()
+          && user.getAccountId().equals(oldUser.getAccountId())) {
+        return changeControl;
+      }
+      throw new IllegalStateException(
+          "user already specified: " + changeControl.getUser());
+    }
+    try {
+      if (change != null) {
+        changeControl = changeControlFactory.controlFor(db, change, user);
+      } else {
+        changeControl =
+            changeControlFactory.controlFor(db, project(), legacyId, user);
+      }
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+    return changeControl;
+  }
+
   void cacheVisibleTo(ChangeControl ctl) {
     visibleTo = ctl.getUser();
     changeControl = ctl;
@@ -563,7 +742,12 @@
   }
 
   public Change reloadChange() throws OrmException {
-    change = db.changes().get(legacyId);
+    if (project == null) {
+      notes = notesFactory.createFromIdOnlyWhenNoteDbDisabled(db, legacyId);
+    } else {
+      notes = notesFactory.create(db, project, legacyId);
+    }
+    change = notes.getChange();
     if (change == null) {
       throw new OrmException("Unable to load change " + legacyId);
     }
@@ -572,7 +756,7 @@
 
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
-      notes = notesFactory.create(change());
+      notes = notesFactory.create(db, project(), legacyId);
     }
     return notes;
   }
@@ -655,7 +839,7 @@
       return false;
     }
     String sha1 = ps.getRevision().get();
-    try (Repository repo = repoManager.openRepository(change().getProject());
+    try (Repository repo = repoManager.openRepository(project());
         RevWalk walk = new RevWalk(repo)) {
       RevCommit c = walk.parseCommit(ObjectId.fromString(sha1));
       commitMessage = c.getFullMessage();
@@ -667,13 +851,12 @@
   }
 
   /**
-   * @return patches for the change.
+   * @return patches for the change, in patch set ID order.
    * @throws OrmException an error occurred reading the database.
    */
-  public Collection<PatchSet> patchSets()
-      throws OrmException {
+  public Collection<PatchSet> patchSets() throws OrmException {
     if (patchSets == null) {
-      patchSets = db.patchSets().byChange(legacyId).toList();
+      patchSets = psUtil.byChange(db, notes());
     }
     return patchSets;
   }
@@ -683,7 +866,7 @@
    * @throws OrmException an error occurred reading the database.
    */
   public Collection<PatchSet> visiblePatchSets() throws OrmException {
-    return FluentIterable.from(patchSets()).filter(new Predicate<PatchSet>() {
+    Predicate<PatchSet> predicate = new Predicate<PatchSet>() {
       @Override
       public boolean apply(PatchSet input) {
         try {
@@ -691,7 +874,9 @@
         } catch (OrmException e) {
           return false;
         }
-      }}).toList();
+      }
+    };
+    return FluentIterable.from(patchSets()).filter(predicate).toList();
   }
 
   public void setPatchSets(Collection<PatchSet> patchSets) {
@@ -728,9 +913,48 @@
     return allApprovals;
   }
 
-  public SetMultimap<ReviewerState, Account.Id> reviewers()
-      throws OrmException {
-    return approvalsUtil.getReviewers(notes(), approvals().values());
+  /**
+   * @return The submit ('SUBM') approval label
+   * @throws OrmException an error occurred reading the database.
+   */
+  public Optional<PatchSetApproval> getSubmitApproval()
+    throws OrmException {
+    for (PatchSetApproval psa : currentApprovals()) {
+      if (psa.isLegacySubmit()) {
+        return Optional.fromNullable(psa);
+      }
+    }
+    return Optional.absent();
+  }
+
+  public ReviewerSet reviewers() throws OrmException {
+    if (reviewers == null) {
+      reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
+    }
+    return reviewers;
+  }
+
+  public void setReviewers(ReviewerSet reviewers) {
+    this.reviewers = reviewers;
+  }
+
+  public ReviewerSet getReviewers() {
+    return reviewers;
+  }
+
+  public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
+    if (reviewerUpdates == null) {
+      reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
+    }
+    return reviewerUpdates;
+  }
+
+  public void setReviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates) {
+    this.reviewerUpdates = reviewerUpdates;
+  }
+
+  public List<ReviewerStatusUpdate> getReviewerUpdates() {
+    return reviewerUpdates;
   }
 
   public Collection<PatchLineComment> publishedComments()
@@ -757,6 +981,13 @@
     return submitRecords;
   }
 
+  public SubmitTypeRecord submitTypeRecord() throws OrmException {
+    if (submitTypeRecord == null) {
+      submitTypeRecord = new SubmitRuleEvaluator(this).getSubmitType();
+    }
+    return submitTypeRecord;
+  }
+
   public void setMergeable(Boolean mergeable) {
     this.mergeable = mergeable;
   }
@@ -774,20 +1005,20 @@
         if (ps == null || !changeControl().isPatchVisible(ps, db)) {
           return null;
         }
-        try (Repository repo = repoManager.openRepository(c.getProject())) {
+        try (Repository repo = repoManager.openRepository(project())) {
           Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
-          SubmitTypeRecord rec = new SubmitRuleEvaluator(this)
-              .getSubmitType();
-          if (rec.status != SubmitTypeRecord.Status.OK) {
-            throw new OrmException(
-                "Error in mergeability check: " + rec.errorMessage);
+          SubmitTypeRecord str = submitTypeRecord();
+          if (!str.isOk()) {
+            // If submit type rules are broken, it's definitely not mergeable.
+            // No need to log, as SubmitRuleEvaluator already did it for us.
+            return false;
           }
           String mergeStrategy = mergeUtilFactory
-              .create(projectCache.get(c.getProject()))
+              .create(projectCache.get(project()))
               .mergeStrategyName();
           mergeable = mergeabilityCache.get(
               ObjectId.fromString(ps.getRevision().get()),
-              ref, rec.type, mergeStrategy, c.getDest(), repo, db);
+              ref, str.type, mergeStrategy, c.getDest(), repo);
         } catch (IOException e) {
           throw new OrmException(e);
         }
@@ -804,7 +1035,7 @@
       }
       editsByUser = new HashSet<>();
       Change.Id id = checkNotNull(change.getId());
-      try (Repository repo = repoManager.openRepository(change.getProject())) {
+      try (Repository repo = repoManager.openRepository(project())) {
         for (String ref
             : repo.getRefDatabase().getRefs(RefNames.REFS_USERS).keySet()) {
           if (id.equals(Change.Id.fromEditRefPart(ref))) {
@@ -818,6 +1049,20 @@
     return editsByUser;
   }
 
+  public Set<Account.Id> draftsByUser() throws OrmException {
+    if (draftsByUser == null) {
+      Change c = change();
+      if (c == null) {
+        return Collections.emptySet();
+      }
+      draftsByUser = new HashSet<>();
+      for (PatchLineComment sc : plcUtil.draftByChange(db, notes())) {
+        draftsByUser.add(sc.getAuthor());
+      }
+    }
+    return draftsByUser;
+  }
+
   public Set<Account.Id> reviewedBy() throws OrmException {
     if (reviewedBy == null) {
       Change c = change();
@@ -830,10 +1075,7 @@
           events.add(ReviewedByEvent.create(msg));
         }
       }
-      for (PatchSet ps : patchSets()) {
-        events.add(ReviewedByEvent.create(ps));
-      }
-      Collections.sort(events, Collections.reverseOrder());
+      events = Lists.reverse(events);
       reviewedBy = new LinkedHashSet<>();
       Account.Id owner = c.getOwner();
       for (ReviewedByEvent event : events) {
@@ -850,13 +1092,44 @@
     this.reviewedBy = reviewedBy;
   }
 
-  @AutoValue
-  abstract static class ReviewedByEvent implements Comparable<ReviewedByEvent> {
-    private static ReviewedByEvent create(PatchSet ps) {
-      return new AutoValue_ChangeData_ReviewedByEvent(
-          ps.getUploader(), ps.getCreatedOn());
+  public Set<String> hashtags() throws OrmException {
+    if (hashtags == null) {
+      hashtags = notes().getHashtags();
     }
+    return hashtags;
+  }
 
+  public void setHashtags(Set<String> hashtags) {
+    this.hashtags = hashtags;
+  }
+
+  @Deprecated
+  public Set<Account.Id> starredBy() throws OrmException {
+    if (starredByUser == null) {
+      starredByUser = checkNotNull(starredChangesUtil).byChange(
+          legacyId, StarredChangesUtil.DEFAULT_LABEL);
+    }
+    return starredByUser;
+  }
+
+  @Deprecated
+  public void setStarredBy(Set<Account.Id> starredByUser) {
+    this.starredByUser = starredByUser;
+  }
+
+  public ImmutableMultimap<Account.Id, String> stars() throws OrmException {
+    if (stars == null) {
+      stars = checkNotNull(starredChangesUtil).byChange(legacyId);
+    }
+    return stars;
+  }
+
+  public void setStars(Multimap<Account.Id, String> stars) {
+    this.stars = ImmutableMultimap.copyOf(stars);
+  }
+
+  @AutoValue
+  abstract static class ReviewedByEvent {
     private static ReviewedByEvent create(ChangeMessage msg) {
       return new AutoValue_ChangeData_ReviewedByEvent(
           msg.getAuthor(), msg.getWrittenOn());
@@ -864,11 +1137,6 @@
 
     public abstract Account.Id author();
     public abstract Timestamp ts();
-
-    @Override
-    public int compareTo(ReviewedByEvent other) {
-      return ts().compareTo(other.ts());
-    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataResultSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataResultSet.java
deleted file mode 100644
index 52a5b7b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataResultSet.java
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Provider;
-
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-import java.util.Set;
-
-abstract class ChangeDataResultSet<T> extends AbstractResultSet<ChangeData> {
-  static ResultSet<ChangeData> change(final ChangeData.Factory factory,
-      final Provider<ReviewDb> db, final ResultSet<Change> rs) {
-    return new ChangeDataResultSet<Change>(rs, true) {
-      @Override
-      ChangeData convert(Change t) {
-        return factory.create(db.get(), t);
-      }
-    };
-  }
-
-  static ResultSet<ChangeData> patchSet(final ChangeData.Factory factory,
-      final Provider<ReviewDb> db, final ResultSet<PatchSet> rs) {
-    return new ChangeDataResultSet<PatchSet>(rs, false) {
-      @Override
-      ChangeData convert(PatchSet t) {
-        return factory.create(db.get(), t.getId().getParentKey());
-      }
-    };
-  }
-
-  private final ResultSet<T> source;
-  private final boolean unique;
-
-  ChangeDataResultSet(ResultSet<T> source, boolean unique) {
-    this.source = source;
-    this.unique = unique;
-  }
-
-  @Override
-  public Iterator<ChangeData> iterator() {
-    if (unique) {
-      return new Iterator<ChangeData>() {
-        private final Iterator<T> itr = source.iterator();
-
-        @Override
-        public boolean hasNext() {
-          return itr.hasNext();
-        }
-
-        @Override
-        public ChangeData next() {
-          return convert(itr.next());
-        }
-
-        @Override
-        public void remove() {
-          throw new UnsupportedOperationException();
-        }
-      };
-
-    } else {
-      return new Iterator<ChangeData>() {
-        private final Iterator<T> itr = source.iterator();
-        private final Set<Change.Id> seen = new HashSet<>();
-        private ChangeData next;
-
-        @Override
-        public boolean hasNext() {
-          if (next != null) {
-            return true;
-          }
-          while (itr.hasNext()) {
-            ChangeData d = convert(itr.next());
-            if (seen.add(d.getId())) {
-              next = d;
-              return true;
-            }
-          }
-          return false;
-        }
-
-        @Override
-        public ChangeData next() {
-          if (hasNext()) {
-            ChangeData r = next;
-            next = null;
-            return r;
-          }
-          throw new NoSuchElementException();
-        }
-
-        @Override
-        public void remove() {
-          throw new UnsupportedOperationException();
-        }
-      };
-    }
-  }
-
-  @Override
-  public void close() {
-    source.close();
-  }
-
-  abstract ChangeData convert(T t);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
index 47bf82d..c32ff0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
@@ -18,5 +18,5 @@
 
 public interface ChangeDataSource extends DataSource<ChangeData> {
   /** @return true if all returned ChangeData.hasChange() will be true. */
-  public boolean hasChange();
+  boolean hasChange();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 7f517d8..85d433a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
-class ChangeIdPredicate extends IndexPredicate<ChangeData> {
+class ChangeIdPredicate extends ChangeIndexPredicate {
   ChangeIdPredicate(String id) {
     super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
new file mode 100644
index 0000000..80951fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.query.Matchable;
+
+public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name,
+      String value) {
+    super(def, name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
new file mode 100644
index 0000000..303c9f8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+
+class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
+  private static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups() //
+          .iterator().next().toString();
+    }
+    return user.toString();
+  }
+
+  private final Provider<ReviewDb> db;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeControl.GenericFactory changeControl;
+  private final CurrentUser user;
+
+  ChangeIsVisibleToPredicate(Provider<ReviewDb> db,
+      ChangeNotes.Factory notesFactory,
+      ChangeControl.GenericFactory changeControlFactory, CurrentUser user) {
+    super(ChangeQueryBuilder.FIELD_VISIBLETO, describe(user));
+    this.db = db;
+    this.notesFactory = notesFactory;
+    this.changeControl = changeControlFactory;
+    this.user = user;
+  }
+
+  @Override
+  public boolean match(final ChangeData cd) throws OrmException {
+    if (cd.fastIsVisibleTo(user)) {
+      return true;
+    }
+    try {
+      Change c = cd.change();
+      if (c == null) {
+        return false;
+      }
+
+      ChangeNotes notes = notesFactory.createFromIndexedChange(c);
+      ChangeControl cc = changeControl.controlFor(notes, user);
+      if (cc.isVisible(db.get(), cd)) {
+        cd.cacheVisibleTo(cc);
+        return true;
+      }
+    } catch (NoSuchChangeException e) {
+      // Ignored
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
new file mode 100644
index 0000000..6bec598
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.OperatorPredicate;
+
+public abstract class ChangeOperatorPredicate extends
+    OperatorPredicate<ChangeData> implements Matchable<ChangeData> {
+
+  protected ChangeOperatorPredicate(String name, String value) {
+    super(name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index a4cff07..b3fc729 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -22,9 +23,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NotSignedInException;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -34,6 +37,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
@@ -43,22 +48,24 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.strategy.SubmitStrategyFactory;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
 import com.google.gerrit.server.group.ListMembers;
-import com.google.gerrit.server.index.ChangeIndex;
 import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexRewriter;
 import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
@@ -86,9 +93,12 @@
  * Parses a query string meant to be applied to change objects.
  */
 public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
+  public interface ChangeOperatorFactory
+      extends OperatorFactory<ChangeData, ChangeQueryBuilder> {
+  }
+
   private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
-  private static final Pattern PAT_CHANGE_ID =
-      Pattern.compile("^[iI][0-9a-f]{4,}.*$");
+  private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE = Pattern.compile(
       "^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
 
@@ -96,12 +106,11 @@
   // SearchSuggestOracle up to date.
 
   public static final String FIELD_ADDED = "added";
-  public static final String FIELD_AFTER = "after";
   public static final String FIELD_AGE = "age";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_BEFORE = "before";
-  public static final String FIELD_BRANCH = "branch";
   public static final String FIELD_CHANGE = "change";
+  public static final String FIELD_CHANGE_ID = "change_id";
   public static final String FIELD_COMMENT = "comment";
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
@@ -112,14 +121,15 @@
   public static final String FIELD_DESTINATION = "destination";
   public static final String FIELD_DRAFTBY = "draftby";
   public static final String FIELD_EDITBY = "editby";
+  public static final String FIELD_EXACTCOMMIT = "exactcommit";
   public static final String FIELD_FILE = "file";
-  public static final String FIELD_IS = "is";
-  public static final String FIELD_HAS = "has";
+  public static final String FIELD_FILEPART = "filepart";
+  public static final String FIELD_GROUP = "group";
   public static final String FIELD_HASHTAG = "hashtag";
   public static final String FIELD_LABEL = "label";
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_MERGE = "merge";
-  public static final String FIELD_MERGEABLE = "mergeable";
+  public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
@@ -127,14 +137,15 @@
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PROJECT = "project";
   public static final String FIELD_PROJECTS = "projects";
-  public static final String FIELD_QUERY = "query";
   public static final String FIELD_REF = "ref";
   public static final String FIELD_REVIEWEDBY = "reviewedby";
   public static final String FIELD_REVIEWER = "reviewer";
   public static final String FIELD_REVIEWERIN = "reviewerin";
+  public static final String FIELD_STAR = "star";
+  public static final String FIELD_STARBY = "starby";
   public static final String FIELD_STARREDBY = "starredby";
   public static final String FIELD_STATUS = "status";
-  public static final String FIELD_TOPIC = "topic";
+  public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
@@ -150,27 +161,31 @@
   public static class Arguments {
     final Provider<ReviewDb> db;
     final Provider<InternalChangeQuery> queryProvider;
-    final IndexRewriter rewriter;
+    final ChangeIndexRewriter rewriter;
+    final DynamicMap<ChangeOperatorFactory> opFactories;
     final IdentifiedUser.GenericFactory userFactory;
     final CapabilityControl.Factory capabilityControlFactory;
     final ChangeControl.GenericFactory changeControlGenericFactory;
+    final ChangeNotes.Factory notesFactory;
     final ChangeData.Factory changeDataFactory;
     final FieldDef.FillArgs fillArgs;
     final PatchLineCommentsUtil plcUtil;
     final AccountResolver accountResolver;
     final GroupBackend groupBackend;
     final AllProjectsName allProjectsName;
-    final AllUsersNameProvider allUsersName;
+    final AllUsersName allUsersName;
     final PatchListCache patchListCache;
     final GitRepositoryManager repoManager;
     final ProjectCache projectCache;
     final Provider<ListChildProjects> listChildProjects;
-    final SubmitStrategyFactory submitStrategyFactory;
+    final SubmitDryRun submitDryRun;
     final ConflictsCache conflictsCache;
     final TrackingFooters trackingFooters;
     final ChangeIndex index;
     final IndexConfig indexConfig;
     final Provider<ListMembers> listMembers;
+    final StarredChangesUtil starredChangesUtil;
+    final AccountCache accountCache;
     final boolean allowsDrafts;
 
     private final Provider<CurrentUser> self;
@@ -179,72 +194,81 @@
     @VisibleForTesting
     public Arguments(Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
-        IndexRewriter rewriter,
+        ChangeIndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
+        ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
         PatchLineCommentsUtil plcUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
-        AllUsersNameProvider allUsersName,
+        AllUsersName allUsersName,
         PatchListCache patchListCache,
         GitRepositoryManager repoManager,
         ProjectCache projectCache,
         Provider<ListChildProjects> listChildProjects,
-        IndexCollection indexes,
-        SubmitStrategyFactory submitStrategyFactory,
+        ChangeIndexCollection indexes,
+        SubmitDryRun submitDryRun,
         ConflictsCache conflictsCache,
         TrackingFooters trackingFooters,
         IndexConfig indexConfig,
         Provider<ListMembers> listMembers,
+        StarredChangesUtil starredChangesUtil,
+        AccountCache accountCache,
         @GerritServerConfig Config cfg) {
-      this(db, queryProvider, rewriter, userFactory, self,
-          capabilityControlFactory, changeControlGenericFactory,
+      this(db, queryProvider, rewriter, opFactories, userFactory, self,
+          capabilityControlFactory, changeControlGenericFactory, notesFactory,
           changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
           allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitStrategyFactory,
-          conflictsCache, trackingFooters,
-          indexes != null ? indexes.getSearchIndex() : null,
-          indexConfig, listMembers,
+          projectCache, listChildProjects, submitDryRun, conflictsCache,
+          trackingFooters, indexes != null ? indexes.getSearchIndex() : null,
+          indexConfig, listMembers, starredChangesUtil, accountCache,
           cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true));
     }
 
     private Arguments(
         Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
-        IndexRewriter rewriter,
+        ChangeIndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
+        ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
         PatchLineCommentsUtil plcUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
-        AllUsersNameProvider allUsersName,
+        AllUsersName allUsersName,
         PatchListCache patchListCache,
         GitRepositoryManager repoManager,
         ProjectCache projectCache,
         Provider<ListChildProjects> listChildProjects,
-        SubmitStrategyFactory submitStrategyFactory,
+        SubmitDryRun submitDryRun,
         ConflictsCache conflictsCache,
         TrackingFooters trackingFooters,
         ChangeIndex index,
         IndexConfig indexConfig,
         Provider<ListMembers> listMembers,
+        StarredChangesUtil starredChangesUtil,
+        AccountCache accountCache,
         boolean allowsDrafts) {
      this.db = db;
      this.queryProvider = queryProvider;
      this.rewriter = rewriter;
+     this.opFactories = opFactories;
      this.userFactory = userFactory;
      this.self = self;
      this.capabilityControlFactory = capabilityControlFactory;
+     this.notesFactory = notesFactory;
      this.changeControlGenericFactory = changeControlGenericFactory;
      this.changeDataFactory = changeDataFactory;
      this.fillArgs = fillArgs;
@@ -257,24 +281,26 @@
      this.repoManager = repoManager;
      this.projectCache = projectCache;
      this.listChildProjects = listChildProjects;
-     this.submitStrategyFactory = submitStrategyFactory;
+     this.submitDryRun = submitDryRun;
      this.conflictsCache = conflictsCache;
      this.trackingFooters = trackingFooters;
      this.index = index;
      this.indexConfig = indexConfig;
      this.listMembers = listMembers;
+     this.starredChangesUtil = starredChangesUtil;
+     this.accountCache = accountCache;
      this.allowsDrafts = allowsDrafts;
     }
 
     Arguments asUser(CurrentUser otherUser) {
-      return new Arguments(db, queryProvider, rewriter, userFactory,
+      return new Arguments(db, queryProvider, rewriter, opFactories, userFactory,
           Providers.of(otherUser),
-          capabilityControlFactory, changeControlGenericFactory,
+          capabilityControlFactory, changeControlGenericFactory, notesFactory,
           changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
           allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitStrategyFactory,
+          projectCache, listChildProjects, submitDryRun,
           conflictsCache, trackingFooters, index, indexConfig, listMembers,
-          allowsDrafts);
+          starredChangesUtil, accountCache, allowsDrafts);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -286,7 +312,7 @@
       } catch (ProvisionException e) {
         // Doesn't match current user, continue.
       }
-      return asUser(userFactory.create(db, otherId));
+      return asUser(userFactory.create(otherId));
     }
 
     IdentifiedUser getIdentifiedUser() throws QueryParseException {
@@ -320,6 +346,7 @@
   ChangeQueryBuilder(Arguments args) {
     super(mydef);
     this.args = args;
+    setupDynamicOperators();
   }
 
   @VisibleForTesting
@@ -330,6 +357,13 @@
     this.args = args;
   }
 
+  private void setupDynamicOperators() {
+    for (DynamicMap.Entry<ChangeOperatorFactory> e : args.opFactories) {
+      String name = e.getExportName() + "_" + e.getPluginName();
+      opFactories.put(name, e.getProvider().get());
+    }
+  }
+
   public ChangeQueryBuilder asUser(CurrentUser user) {
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
@@ -361,12 +395,6 @@
 
   @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
-    if (PAT_LEGACY_ID.matcher(query).matches()) {
-      return new LegacyChangeIdPredicate(
-          args.getSchema(), Change.Id.parse(query));
-    } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      return new ChangeIdPredicate(parseChangeId(query));
-    }
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
       return Predicate.and(
@@ -374,6 +402,11 @@
           branch(triplet.get().branch().get()),
           new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
     }
+    if (PAT_LEGACY_ID.matcher(query).matches()) {
+      return new LegacyChangeIdPredicate(Change.Id.parse(query));
+    } else if (PAT_CHANGE_ID.matcher(query).matches()) {
+      return new ChangeIdPredicate(parseChangeId(query));
+    }
 
     throw new QueryParseException("Invalid change format");
   }
@@ -386,10 +419,9 @@
   @Operator
   public Predicate<ChangeData> status(String statusName) {
     if ("reviewed".equalsIgnoreCase(statusName)) {
-      return IsReviewedPredicate.create(args.getSchema());
-    } else {
-      return ChangeStatusPredicate.parse(statusName);
+      return IsReviewedPredicate.create();
     }
+    return ChangeStatusPredicate.parse(statusName);
   }
 
   public Predicate<ChangeData> status_open() {
@@ -399,11 +431,15 @@
   @Operator
   public Predicate<ChangeData> has(String value) throws QueryParseException {
     if ("star".equalsIgnoreCase(value)) {
-      return new IsStarredByPredicate(args);
+      return starredby(self());
+    }
+
+    if ("stars".equalsIgnoreCase(value)) {
+      return new HasStarsPredicate(self());
     }
 
     if ("draft".equalsIgnoreCase(value)) {
-      return new HasDraftByPredicate(args, self());
+      return draftby(self());
     }
 
     if ("edit".equalsIgnoreCase(value)) {
@@ -415,7 +451,7 @@
   @Operator
   public Predicate<ChangeData> is(String value) throws QueryParseException {
     if ("starred".equalsIgnoreCase(value)) {
-      return new IsStarredByPredicate(args);
+      return starredby(self());
     }
 
     if ("watched".equalsIgnoreCase(value)) {
@@ -427,7 +463,7 @@
     }
 
     if ("reviewed".equalsIgnoreCase(value)) {
-      return IsReviewedPredicate.create(args.getSchema());
+      return IsReviewedPredicate.create();
     }
 
     if ("owner".equalsIgnoreCase(value)) {
@@ -435,7 +471,7 @@
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      return new ReviewerPredicate(self(), args.allowsDrafts);
+      return ReviewerPredicate.create(args, self());
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
@@ -448,12 +484,12 @@
       // not status: alias?
     }
 
-    throw new IllegalArgumentException();
+    throw error("Invalid query");
   }
 
   @Operator
   public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(args.getSchema(), id);
+    return new CommitPredicate(id);
   }
 
   @Operator
@@ -501,18 +537,18 @@
 
   @Operator
   public Predicate<ChangeData> topic(String name) {
-    return new ExactTopicPredicate(args.getSchema(), name);
+    return new ExactTopicPredicate(name);
   }
 
   @Operator
   public Predicate<ChangeData> intopic(String name) {
     if (name.startsWith("^")) {
-      return new RegexTopicPredicate(args.getSchema(), name);
+      return new RegexTopicPredicate(name);
     }
     if (name.isEmpty()) {
-      return new ExactTopicPredicate(args.getSchema(), name);
+      return new ExactTopicPredicate(name);
     }
-    return new FuzzyTopicPredicate(args.getSchema(), name, args.index);
+    return new FuzzyTopicPredicate(name, args.index);
   }
 
   @Operator
@@ -532,18 +568,16 @@
   public Predicate<ChangeData> file(String file) {
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
-    } else {
-      return EqualsFilePredicate.create(args, file);
     }
+    return EqualsFilePredicate.create(args, file);
   }
 
   @Operator
   public Predicate<ChangeData> path(String path) {
     if (path.startsWith("^")) {
       return new RegexPathPredicate(path);
-    } else {
-      return new EqualsPathPredicate(FIELD_PATH, path);
     }
+    return new EqualsPathPredicate(FIELD_PATH, path);
   }
 
   @Operator
@@ -606,10 +640,10 @@
                   return new Account.Id(accountInfo._accountId);
                 }
               }));
-      int maxTerms = args.indexConfig.maxLimit();
-      if (allMembers.size() > maxTerms) {
+      int maxLimit = args.indexConfig.maxLimit();
+      if (allMembers.size() > maxLimit) {
         // limit the number of query terms otherwise Gerrit will barf
-        accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxTerms));
+        accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxLimit));
       } else {
         accounts = allMembers;
       }
@@ -626,19 +660,46 @@
   }
 
   @Operator
+  public Predicate<ChangeData> star(String label) throws QueryParseException {
+    return new StarPredicate(self(), label);
+  }
+
+  @Operator
   public Predicate<ChangeData> starredby(String who)
       throws QueryParseException, OrmException {
-    if ("self".equals(who)) {
-      return new IsStarredByPredicate(args);
-    }
-    Set<Account.Id> m = parseAccount(who);
-    List<IsStarredByPredicate> p = Lists.newArrayListWithCapacity(m.size());
-    for (Account.Id id : m) {
-      p.add(new IsStarredByPredicate(args.asUser(id)));
+    return starredby(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> starredby(Set<Account.Id> who)
+      throws QueryParseException {
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(starredby(id));
     }
     return Predicate.or(p);
   }
 
+  @SuppressWarnings("deprecation")
+  private Predicate<ChangeData> starredby(Account.Id who)
+      throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.STAR)) {
+      return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
+    }
+
+    if (args.getSchema().hasField(ChangeField.STARREDBY)) {
+      return new IsStarredByPredicate(who);
+    }
+
+    try {
+      // starred changes are not contained in the index, we must read them from
+      // git
+      return new IsStarredByLegacyPredicate(who, args.starredChangesUtil
+          .byAccount(who, StarredChangesUtil.DEFAULT_LABEL));
+    } catch (OrmException e) {
+      throw new QueryParseException("Failed to query starred changes.", e);
+    }
+  }
+
   @Operator
   public Predicate<ChangeData> watchedby(String who)
       throws QueryParseException, OrmException {
@@ -668,24 +729,31 @@
   public Predicate<ChangeData> draftby(String who) throws QueryParseException,
       OrmException {
     Set<Account.Id> m = parseAccount(who);
-    List<HasDraftByPredicate> p = Lists.newArrayListWithCapacity(m.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
     for (Account.Id id : m) {
-      p.add(new HasDraftByPredicate(args, id));
+      p.add(draftby(id));
     }
     return Predicate.or(p);
   }
 
+  @SuppressWarnings("deprecation")
+  private Predicate<ChangeData> draftby(Account.Id who) {
+    return args.getSchema().hasField(ChangeField.DRAFTBY)
+        ? new HasDraftByPredicate(who)
+        : new HasDraftByLegacyPredicate(args, who);
+  }
+
   @Operator
   public Predicate<ChangeData> visibleto(String who)
       throws QueryParseException, OrmException {
     if ("self".equals(who)) {
       return is_visible();
     }
-    Set<Account.Id> m = args.accountResolver.findAll(who);
+    Set<Account.Id> m = args.accountResolver.findAll(args.db.get(), who);
     if (!m.isEmpty()) {
       List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
       for (Account.Id id : m) {
-        return visibleto(args.userFactory.create(args.db, id));
+        return visibleto(args.userFactory.create(id));
       }
       return Predicate.or(p);
     }
@@ -705,9 +773,8 @@
   }
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new IsVisibleToPredicate(args.db, //
-        args.changeControlGenericFactory, //
-        user);
+    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory,
+        args.changeControlGenericFactory, user);
   }
 
   public Predicate<ChangeData> is_visible() throws QueryParseException {
@@ -741,7 +808,7 @@
     if (g == null) {
       throw error("Group " + group + " not found");
     }
-    return new OwnerinPredicate(args.db, args.userFactory, g.getUUID());
+    return new OwnerinPredicate(args.userFactory, g.getUUID());
   }
 
   @Operator
@@ -754,9 +821,9 @@
   public Predicate<ChangeData> reviewer(String who)
       throws QueryParseException, OrmException {
     Set<Account.Id> m = parseAccount(who);
-    List<ReviewerPredicate> p = Lists.newArrayListWithCapacity(m.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
     for (Account.Id id : m) {
-      p.add(new ReviewerPredicate(id, args.allowsDrafts));
+      p.add(ReviewerPredicate.create(args, id));
     }
     return Predicate.or(p);
   }
@@ -768,7 +835,7 @@
     if (g == null) {
       throw error("Group " + group + " not found");
     }
-    return new ReviewerinPredicate(args.db, args.userFactory, g.getUUID());
+    return new ReviewerinPredicate(args.userFactory, g.getUUID());
   }
 
   @Operator
@@ -782,8 +849,12 @@
   }
 
   @Operator
-  public Predicate<ChangeData> limit(String limit) throws QueryParseException {
-    return new LimitPredicate(Integer.parseInt(limit));
+  public Predicate<ChangeData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
 
   @Operator
@@ -833,8 +904,7 @@
 
   @Operator
   public Predicate<ChangeData> query(String name) throws QueryParseException {
-    AllUsersName allUsers = args.allUsersName.get();
-    try (Repository git = args.repoManager.openRepository(allUsers)) {
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
       q.load(git);
       String query = q.getQueryList().getQuery(name);
@@ -843,7 +913,7 @@
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException("Unknown named query (no " +
-          allUsers.get() +" repo): " + name, e);
+          args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
       throw new QueryParseException("Error parsing named query: " + name, e);
     }
@@ -853,14 +923,13 @@
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
       throws QueryParseException, OrmException {
-    return IsReviewedPredicate.create(args.getSchema(), parseAccount(who));
+    return IsReviewedPredicate.create(parseAccount(who));
   }
 
   @Operator
   public Predicate<ChangeData> destination(String name)
       throws QueryParseException {
-    AllUsersName allUsers = args.allUsersName.get();
-    try (Repository git = args.repoManager.openRepository(allUsers)) {
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       VersionedAccountDestinations d =
           VersionedAccountDestinations.forUser(self());
       d.load(git);
@@ -871,7 +940,7 @@
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException("Unknown named destination (no " +
-          allUsers.get() +" repo): " + name, e);
+          args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
       throw new QueryParseException("Error parsing named destination: " + name, e);
     }
@@ -900,7 +969,8 @@
       }
     }
 
-    List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(9);
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
     try {
       predicates.add(commit(query));
     } catch (IllegalArgumentException e) {
@@ -928,6 +998,8 @@
     predicates.add(ref(query));
     predicates.add(branch(query));
     predicates.add(topic(query));
+    // Adapt the capacity of the "predicates" list when adding more default
+    // predicates.
     return Predicate.or(predicates);
   }
 
@@ -936,7 +1008,7 @@
     if ("self".equals(who)) {
       return Collections.singleton(self());
     }
-    Set<Account.Id> matches = args.accountResolver.findAll(who);
+    Set<Account.Id> matches = args.accountResolver.findAll(args.db.get(), who);
     if (matches.isEmpty()) {
       throw error("User " + who + " not found");
     }
@@ -955,8 +1027,8 @@
   private List<Change> parseChange(String value) throws OrmException,
       QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
-      return Collections.singletonList(args.db.get().changes()
-          .get(Change.Id.parse(value)));
+      return asChanges(
+          args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
       List<Change> changes =
           asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
new file mode 100644
index 0000000..0ff5ac7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryProcessor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.util.Set;
+
+public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
+  private final Provider<ReviewDb> db;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeNotes.Factory notesFactory;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !ChangeIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "ChangeQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  ChangeQueryProcessor(Provider<CurrentUser> userProvider,
+      Metrics metrics,
+      IndexConfig indexConfig,
+      ChangeIndexCollection indexes,
+      ChangeIndexRewriter rewriter,
+      Provider<ReviewDb> db,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeNotes.Factory notesFactory) {
+    super(userProvider, metrics, ChangeSchemaDefinitions.INSTANCE, indexConfig, indexes,
+        rewriter, FIELD_LIMIT);
+    this.db = db;
+    this.changeControlFactory = changeControlFactory;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public ChangeQueryProcessor enforceVisibility(boolean enforce) {
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @Override
+  protected QueryOptions createOptions(IndexConfig indexConfig, int start,
+      int limit, Set<String> requestedFields) {
+    return IndexedChangeQuery.createOptions(indexConfig, start, limit,
+        requestedFields);
+  }
+
+  @Override
+  protected Predicate<ChangeData> enforceVisibility(
+      Predicate<ChangeData> pred) {
+    return new AndChangeSource(pred, new ChangeIsVisibleToPredicate(db,
+        notesFactory, changeControlFactory, userProvider.get()), start);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
new file mode 100644
index 0000000..747d72d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.query.Matchable;
+
+public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name,
+      String value) {
+    super(def, name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 8cbe71f..1c92ecf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -16,8 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 
@@ -36,7 +35,7 @@
  * <p>
  * Status names are looked up by prefix case-insensitively.
  */
-public final class ChangeStatusPredicate extends IndexPredicate<ChangeData> {
+public final class ChangeStatusPredicate extends ChangeIndexPredicate {
   private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
   private static final Predicate<ChangeData> CLOSED;
   private static final Predicate<ChangeData> OPEN;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index dee7086..48d6e05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -17,13 +17,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Objects;
 
-class CommentByPredicate extends IndexPredicate<ChangeData> {
+class CommentByPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   CommentByPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index dd3c3b3..b351740 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class CommentPredicate extends IndexPredicate<ChangeData> {
+class CommentPredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
   CommentPredicate(ChangeIndex index, String value) {
@@ -33,10 +33,9 @@
   public boolean match(ChangeData object) throws OrmException {
     try {
       Predicate<ChangeData> p = Predicate.and(
-          new LegacyChangeIdPredicate(index.getSchema(), object.getId()),
-          this);
+          new LegacyChangeIdPredicate(object.getId()), this);
       for (ChangeData cData
-          : index.getSource(p, QueryOptions.oneResult()).read()) {
+          : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
         if (cData.getId().equals(object.getId())) {
           return true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 5184c53..aa3dde3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.COMMIT;
-import static com.google.gerrit.server.index.ChangeField.EXACT_COMMIT;
+import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
 import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
 
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.Schema;
 import com.google.gwtorm.server.OrmException;
 
-class CommitPredicate extends IndexPredicate<ChangeData> {
-  static FieldDef<ChangeData, ?> commitField(Schema<ChangeData> schema,
-      String id) {
-    if (id.length() == OBJECT_ID_STRING_LENGTH
-        && schema != null && schema.hasField(EXACT_COMMIT)) {
+class CommitPredicate extends ChangeIndexPredicate {
+  static FieldDef<ChangeData, ?> commitField(String id) {
+    if (id.length() == OBJECT_ID_STRING_LENGTH) {
       return EXACT_COMMIT;
     }
     return COMMIT;
   }
 
-  CommitPredicate(Schema<ChangeData> schema, String id) {
-    super(commitField(schema, id), id);
+  CommitPredicate(String id) {
+    super(commitField(id), id);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index 3cb7f8e..06f5379 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.COMMITTER;
+import static com.google.gerrit.server.index.change.ChangeField.COMMITTER;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class CommitterPredicate extends IndexPredicate<ChangeData>  {
+public class CommitterPredicate extends ChangeIndexPredicate {
   CommitterPredicate(String value) {
     super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
index bf7a5dd..e8b2fef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
@@ -18,8 +18,8 @@
 
 public interface ConflictsCache {
 
-  public void put(ConflictKey key, Boolean value);
+  void put(ConflictKey key, Boolean value);
 
   @Nullable
-  public Boolean getIfPresent(ConflictKey key);
+  Boolean getIfPresent(ConflictKey key);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 9b47302..69bc2ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -15,33 +15,26 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.strategy.SubmitStrategy;
+import com.google.gerrit.server.git.strategy.SubmitDryRun;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.revwalk.filter.RevFilter;
 import org.eclipse.jgit.treewalk.TreeWalk;
@@ -49,6 +42,7 @@
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
@@ -80,7 +74,7 @@
       List<Predicate<ChangeData>> predicatesForOneChange =
           Lists.newArrayListWithCapacity(5);
       predicatesForOneChange.add(
-          not(new LegacyChangeIdPredicate(args.getSchema(), c.getId())));
+          not(new LegacyChangeIdPredicate(c.getId())));
       predicatesForOneChange.add(
           new ProjectPredicate(c.getProject().get()));
       predicatesForOneChange.add(
@@ -89,7 +83,7 @@
       predicatesForOneChange.add(or(or(filePredicates),
           new IsMergePredicate(args, value)));
 
-      predicatesForOneChange.add(new OperatorPredicate<ChangeData>(
+      predicatesForOneChange.add(new ChangeOperatorPredicate(
           ChangeQueryBuilder.FIELD_CONFLICTS, value) {
 
         @Override
@@ -101,14 +95,14 @@
           if (!otherChange.getDest().equals(c.getDest())) {
             return false;
           }
-          SubmitType submitType = getSubmitType(object);
-          if (submitType == null) {
+          SubmitTypeRecord str = object.submitTypeRecord();
+          if (!str.isOk()) {
             return false;
           }
           ObjectId other = ObjectId.fromString(
               object.currentPatchSet().getRevision().get());
           ConflictKey conflictsKey =
-              new ConflictKey(changeDataCache.getTestAgainst(), other, submitType,
+              new ConflictKey(changeDataCache.getTestAgainst(), other, str.type,
                   changeDataCache.getProjectState().isUseContentMerge());
           Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
           if (conflicts != null) {
@@ -117,16 +111,10 @@
           try (Repository repo =
                 args.repoManager.openRepository(otherChange.getProject());
               CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-            RevFlag canMergeFlag = rw.newFlag("CAN_MERGE");
-            CodeReviewCommit commit =
-                rw.parseCommit(changeDataCache.getTestAgainst());
-            SubmitStrategy strategy = args.submitStrategyFactory.create(
-                submitType, db.get(), repo, rw, null, canMergeFlag,
-                getAlreadyAccepted(repo, rw, commit), otherChange.getDest(),
-                null);
-            CodeReviewCommit otherCommit = rw.parseCommit(other);
-            otherCommit.add(canMergeFlag);
-            conflicts = !strategy.dryRun(commit, otherCommit);
+            conflicts = !args.submitDryRun.run(
+                str.type, repo, rw, otherChange.getDest(),
+                changeDataCache.getTestAgainst(), other,
+                getAlreadyAccepted(repo, rw));
             args.conflictsCache.put(conflictsKey, conflicts);
             return conflicts;
           } catch (IntegrationException | NoSuchProjectException
@@ -140,36 +128,21 @@
           return 5;
         }
 
-        private SubmitType getSubmitType(ChangeData cd) throws OrmException {
-          SubmitTypeRecord r = new SubmitRuleEvaluator(cd).getSubmitType();
-          if (r.status != SubmitTypeRecord.Status.OK) {
-            return null;
-          }
-          return r.type;
-        }
-
-        private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw,
-            CodeReviewCommit tip) throws IntegrationException {
-          Set<RevCommit> alreadyAccepted = Sets.newHashSet();
-
-          if (tip != null) {
-            alreadyAccepted.add(tip);
-          }
-
+        private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
+            throws IntegrationException {
           try {
-            for (ObjectId id : changeDataCache.getAlreadyAccepted(repo)) {
-              try {
-                alreadyAccepted.add(rw.parseCommit(id));
-              } catch (IncorrectObjectTypeException iote) {
-                // Not a commit? Skip over it.
-              }
+            Set<RevCommit> accepted = new HashSet<>();
+            SubmitDryRun.addCommits(
+                changeDataCache.getAlreadyAccepted(repo), rw, accepted);
+            ObjectId tip = changeDataCache.getTestAgainst();
+            if (tip != null) {
+              accepted.add(rw.parseCommit(tip));
             }
-          } catch (IOException e) {
+            return accepted;
+          } catch (OrmException | IOException e) {
             throw new IntegrationException(
                 "Failed to determine already accepted commits.", e);
           }
-
-          return alreadyAccepted;
         }
       });
       changePredicates.add(and(predicatesForOneChange));
@@ -205,9 +178,8 @@
           }
         }
         return files;
-      } else {
-        return args.changeDataFactory.create(args.db.get(), c).currentFilePaths();
       }
+      return args.changeDataFactory.create(args.db.get(), c).currentFilePaths();
     } catch (IOException e) {
       throw new OrmException(e);
     }
@@ -226,7 +198,7 @@
 
     private ObjectId testAgainst;
     private ProjectState projectState;
-    private Set<ObjectId> alreadyAccepted;
+    private Iterable<ObjectId> alreadyAccepted;
 
     ChangeDataCache(Change change, Provider<ReviewDb> db,
         ChangeData.Factory changeDataFactory, ProjectCache projectCache) {
@@ -257,17 +229,9 @@
       return projectState;
     }
 
-    Set<ObjectId> getAlreadyAccepted(Repository repo) {
+    Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
       if (alreadyAccepted == null) {
-        alreadyAccepted = Sets.newHashSet();
-        for (Ref r : repo.getAllRefs().values()) {
-          if (r.getName().startsWith(Constants.R_HEADS)
-              || r.getName().startsWith(Constants.R_TAGS)) {
-            if (r.getObjectId() != null) {
-              alreadyAccepted.add(r.getObjectId());
-            }
-          }
-        }
+        alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
       }
       return alreadyAccepted;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 478990d..9e49269 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-public class DeletedPredicate extends IntegerRangePredicate<ChangeData> {
+public class DeletedPredicate extends IntegerRangeChangePredicate {
   DeletedPredicate(String value) throws QueryParseException {
     super(ChangeField.DELETED, value);
   }
 
   @Override
-  protected int getValueInt(ChangeData changeData) throws OrmException {
-    return changeData.changedLines().deletions;
+  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+    return ChangeField.DELETED.get(changeData, null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index 39b860a..ce33225 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -14,20 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gwtorm.server.OrmException;
 
-public class DeltaPredicate extends IntegerRangePredicate<ChangeData> {
+public class DeltaPredicate extends IntegerRangeChangePredicate {
   DeltaPredicate(String value) throws QueryParseException {
     super(ChangeField.DELTA, value);
   }
 
   @Override
-  protected int getValueInt(ChangeData changeData) throws OrmException {
-    ChangedLines changedLines = changeData.changedLines();
-    return changedLines.insertions + changedLines.deletions;
+  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+    return ChangeField.DELTA.get(changeData, null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 25fa09f..7e573dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Set;
 
-class DestinationPredicate extends OperatorPredicate<ChangeData> {
+class DestinationPredicate extends ChangeOperatorPredicate {
   Set<Branch.NameKey> destinations;
 
   DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
index b544da6..8be5235 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class EditByPredicate extends IndexPredicate<ChangeData> {
+class EditByPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   EditByPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index a21c590..6877761 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class EqualsFilePredicate extends IndexPredicate<ChangeData> {
+class EqualsFilePredicate extends ChangeIndexPredicate {
   static Predicate<ChangeData> create(Arguments args, String value) {
     Predicate<ChangeData> eqPath =
         new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 173060b..e752b05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -23,8 +23,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -32,7 +31,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class EqualsLabelPredicate extends IndexPredicate<ChangeData> {
+class EqualsLabelPredicate extends ChangeIndexPredicate {
   private final ProjectCache projectCache;
   private final ChangeControl.GenericFactory ccFactory;
   private final IdentifiedUser.GenericFactory userFactory;
@@ -112,9 +111,10 @@
     if (psVal == expVal) {
       // Double check the value is still permitted for the user.
       //
-      IdentifiedUser reviewer = userFactory.create(dbProvider, approver);
+      IdentifiedUser reviewer = userFactory.create(approver);
       try {
-        ChangeControl cc = ccFactory.controlFor(change, reviewer);
+        ChangeControl cc =
+            ccFactory.controlFor(dbProvider.get(), change, reviewer);
         if (!cc.isVisible(dbProvider.get())) {
           // The user can't see the change anymore.
           //
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 055b6d5..5edd06c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Collections;
 import java.util.List;
 
-class EqualsPathPredicate extends IndexPredicate<ChangeData> {
+class EqualsPathPredicate extends ChangeIndexPredicate {
   private final String value;
 
   EqualsPathPredicate(String fieldName, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 6a9d86b..510910e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -14,47 +14,23 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.EXACT_TOPIC;
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.Schema;
 import com.google.gwtorm.server.OrmException;
 
-class ExactTopicPredicate extends IndexPredicate<ChangeData> {
-  @SuppressWarnings("deprecation")
-  static FieldDef<ChangeData, ?> topicField(Schema<ChangeData> schema) {
-    if (schema == null) {
-      return ChangeField.LEGACY_TOPIC2;
-    }
-    if (schema.hasField(EXACT_TOPIC)) {
-      return schema.getFields().get(EXACT_TOPIC.getName());
-    }
-    if (schema.hasField(ChangeField.LEGACY_TOPIC2)) {
-      return schema.getFields().get(ChangeField.LEGACY_TOPIC2.getName());
-    }
-    // Not exact, but we cannot do any better.
-    return schema.getFields().get(ChangeField.LEGACY_TOPIC3.getName());
+class ExactTopicPredicate extends ChangeIndexPredicate {
+  ExactTopicPredicate(String topic) {
+    super(EXACT_TOPIC, topic);
   }
 
-  ExactTopicPredicate(Schema<ChangeData> schema, String topic) {
-    super(topicField(schema), topic);
-  }
-
-  @SuppressWarnings("deprecation")
   @Override
   public boolean match(final ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
     }
-    String t = change.getTopic();
-    if (t == null && getField() == ChangeField.LEGACY_TOPIC2) {
-      t = "";
-    }
-    return getValue().equals(t);
+    return getValue().equals(change.getTopic());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 5b9b94c..5651544 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -14,35 +14,24 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.FUZZY_TOPIC;
-import static com.google.gerrit.server.index.ChangeField.LEGACY_TOPIC2;
-import static com.google.gerrit.server.index.ChangeField.LEGACY_TOPIC3;
+import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class FuzzyTopicPredicate extends IndexPredicate<ChangeData> {
+class FuzzyTopicPredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
-  @SuppressWarnings("deprecation")
-  static FieldDef<ChangeData, ?> topicField(Schema<ChangeData> schema) {
-    return schema.getField(FUZZY_TOPIC, LEGACY_TOPIC3, LEGACY_TOPIC2).get();
-  }
-
-  FuzzyTopicPredicate(Schema<ChangeData> schema, String topic,
-      ChangeIndex index) {
-    super(topicField(schema), topic);
+  FuzzyTopicPredicate(String topic, ChangeIndex index) {
+    super(FUZZY_TOPIC, topic);
     this.index = index;
   }
 
-  @SuppressWarnings("deprecation")
   @Override
   public boolean match(final ChangeData cd) throws OrmException {
     Change change = cd.change();
@@ -53,21 +42,14 @@
     if (t == null) {
       return false;
     }
-    if (getField() == FUZZY_TOPIC || getField() == LEGACY_TOPIC3) {
-      try {
-        Predicate<ChangeData> thisId =
-            new LegacyChangeIdPredicate(index.getSchema(), cd.getId());
-        Iterable<ChangeData> results =
-            index.getSource(and(thisId, this), QueryOptions.oneResult()).read();
-        return !Iterables.isEmpty(results);
-      } catch (QueryParseException e) {
-        throw new OrmException(e);
-      }
+    try {
+      Predicate<ChangeData> thisId = new LegacyChangeIdPredicate(cd.getId());
+      Iterable<ChangeData> results =
+          index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
+      return !Iterables.isEmpty(results);
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
     }
-    if (getField() == LEGACY_TOPIC2) {
-      return t.equals(getValue());
-    }
-    return false;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 235d64e..9e9bc8d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.List;
 
-class GroupPredicate extends IndexPredicate<ChangeData> {
+class GroupPredicate extends ChangeIndexPredicate {
   GroupPredicate(String group) {
     super(ChangeField.GROUP, group);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
new file mode 100644
index 0000000..45a00c6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Deprecated
+class HasDraftByLegacyPredicate extends ChangeOperatorPredicate
+    implements ChangeDataSource {
+  private final Arguments args;
+  private final Account.Id accountId;
+
+  HasDraftByLegacyPredicate(Arguments args,
+      Account.Id accountId) {
+    super(ChangeQueryBuilder.FIELD_DRAFTBY, accountId.toString());
+    this.args = args;
+    this.accountId = accountId;
+  }
+
+  @Override
+  public boolean match(final ChangeData object) throws OrmException {
+    return !args.plcUtil
+        .draftByChangeAuthor(args.db.get(), object.notes(), accountId)
+        .isEmpty();
+  }
+
+  @Override
+  public ResultSet<ChangeData> read() throws OrmException {
+    Set<Change.Id> ids = new HashSet<>();
+    for (PatchLineComment sc :
+        args.plcUtil.draftByAuthor(args.db.get(), accountId)) {
+      ids.add(sc.getKey().getParentKey().getParentKey().getParentKey());
+    }
+
+    List<ChangeData> r = new ArrayList<>(ids.size());
+    // TODO Don't load the changes directly from the database, but provide
+    // project name + change ID to changeDataFactory, or delete this predicate.
+    for (Change c : args.db.get().changes().get(ids)) {
+      r.add(args.changeDataFactory.create(args.db.get(), c));
+    }
+    return new ListResultSet<>(r);
+  }
+
+  @Override
+  public boolean hasChange() {
+    return false;
+  }
+
+  @Override
+  public int getCardinality() {
+    return 20;
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index ebb7389..244589c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -15,65 +15,24 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.query.OperatorPredicate;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.ListResultSet;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-class HasDraftByPredicate extends OperatorPredicate<ChangeData> implements
-    ChangeDataSource {
-  private final Arguments args;
+class HasDraftByPredicate extends ChangeIndexPredicate {
   private final Account.Id accountId;
 
-  HasDraftByPredicate(Arguments args,
-      Account.Id accountId) {
-    super(ChangeQueryBuilder.FIELD_DRAFTBY, accountId.toString());
-    this.args = args;
+  HasDraftByPredicate(Account.Id accountId) {
+    super(ChangeField.DRAFTBY, accountId.toString());
     this.accountId = accountId;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    return !args.plcUtil
-        .draftByChangeAuthor(args.db.get(), object.notes(), accountId)
-        .isEmpty();
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    Set<Change.Id> ids = new HashSet<>();
-    for (PatchLineComment sc :
-        args.plcUtil.draftByAuthor(args.db.get(), accountId)) {
-      ids.add(sc.getKey().getParentKey().getParentKey().getParentKey());
-    }
-
-    List<ChangeData> r = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      r.add(args.changeDataFactory.create(args.db.get(), id));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  @Override
-  public boolean hasChange() {
-    return false;
-  }
-
-  @Override
-  public int getCardinality() {
-    return 20;
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.draftsByUser().contains(accountId);
   }
 
   @Override
   public int getCost() {
-    return 0;
+    return 1;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
new file mode 100644
index 0000000..eb3a137
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+public class HasStarsPredicate extends ChangeIndexPredicate {
+  private final Account.Id accountId;
+
+  HasStarsPredicate(Account.Id accountId) {
+    super(ChangeField.STARBY, accountId.toString());
+    this.accountId = accountId;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.stars().containsKey(accountId);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_STARBY + ":" + accountId;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index ea591ec..185a539 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.change.HashtagsUtil;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HashtagPredicate extends IndexPredicate<ChangeData> {
+class HashtagPredicate extends ChangeIndexPredicate {
   HashtagPredicate(String hashtag) {
     super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
new file mode 100644
index 0000000..a272fbb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.QueryParseException;
+
+public abstract class IntegerRangeChangePredicate
+    extends IntegerRangePredicate<ChangeData> implements Matchable<ChangeData> {
+
+  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type,
+      String value) throws QueryParseException {
+    super(type, value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 34a2bdf..6aa33352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -15,27 +15,28 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.index.ChangeField.SUBMISSIONID;
+import static com.google.gerrit.server.index.change.ChangeField.SUBMISSIONID;
 import static com.google.gerrit.server.query.Predicate.and;
 import static com.google.gerrit.server.query.Predicate.not;
 import static com.google.gerrit.server.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
 import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.InternalQuery;
 import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
@@ -50,15 +51,7 @@
 import java.util.List;
 import java.util.Set;
 
-/**
- * Execute a single query over changes, for use by Gerrit internals.
- * <p>
- * By default, visibility of returned changes is not enforced (unlike in {@link
- * QueryProcessor}). The methods in this class are not typically used by
- * user-facing paths, but rather by internal callers that need to process all
- * matching results.
- */
-public class InternalChangeQuery {
+public class InternalChangeQuery extends InternalQuery<ChangeData> {
   private static Predicate<ChangeData> ref(Branch.NameKey branch) {
     return new RefPredicate(branch.get());
   }
@@ -75,34 +68,45 @@
     return new ChangeStatusPredicate(status);
   }
 
-  private static Predicate<ChangeData> commit(Schema<ChangeData> schema,
-      String id) {
-    return new CommitPredicate(schema, id);
+  private static Predicate<ChangeData> commit(String id) {
+    return new CommitPredicate(id);
   }
 
-  private final IndexConfig indexConfig;
-  private final QueryProcessor qp;
-  private final IndexCollection indexes;
   private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   @Inject
-  InternalChangeQuery(IndexConfig indexConfig,
-      QueryProcessor queryProcessor,
-      IndexCollection indexes,
-      ChangeData.Factory changeDataFactory) {
-    this.indexConfig = indexConfig;
-    qp = queryProcessor.enforceVisibility(false);
-    this.indexes = indexes;
+  InternalChangeQuery(ChangeQueryProcessor queryProcessor,
+      ChangeIndexCollection indexes,
+      IndexConfig indexConfig,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory) {
+    super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
   }
 
+  @Override
   public InternalChangeQuery setLimit(int n) {
-    qp.setLimit(n);
+    super.setLimit(n);
     return this;
   }
 
+  @Override
   public InternalChangeQuery enforceVisibility(boolean enforce) {
-    qp.enforceVisibility(enforce);
+    super.enforceVisibility(enforce);
+    return this;
+  }
+
+  @Override
+  public InternalChangeQuery setRequestedFields(Set<String> fields) {
+    super.setRequestedFields(fields);
+    return this;
+  }
+
+  @Override
+  public InternalChangeQuery noFields() {
+    super.noFields();
     return this;
   }
 
@@ -114,6 +118,19 @@
     return query(new ChangeIdPredicate(prefix));
   }
 
+  public List<ChangeData> byLegacyChangeId(Change.Id id) throws OrmException {
+    return query(new LegacyChangeIdPredicate(id));
+  }
+
+  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids)
+      throws OrmException {
+    List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
+    for (Change.Id id : ids) {
+      preds.add(new LegacyChangeIdPredicate(id));
+    }
+    return query(or(preds));
+  }
+
   public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key)
       throws OrmException {
     return query(and(
@@ -135,6 +152,14 @@
         open()));
   }
 
+  public List<ChangeData> byBranchNew(Branch.NameKey branch)
+      throws OrmException {
+    return query(and(
+        ref(branch),
+        project(branch.getParentKey()),
+        status(Change.Status.NEW)));
+  }
+
   public Iterable<ChangeData> byCommitsOnBranchNotMerged(Repository repo,
       ReviewDb db, Branch.NameKey branch, List<String> hashes)
       throws OrmException, IOException {
@@ -149,14 +174,13 @@
       throws OrmException, IOException {
     if (hashes.size() > indexLimit) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
-    } else {
-      return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
     }
+    return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, ReviewDb db, Branch.NameKey branch, List<String> hashes)
-      throws OrmException, IOException {
+      Repository repo, final ReviewDb db, final Branch.NameKey branch,
+      List<String> hashes) throws OrmException, IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
     for (Ref ref :
@@ -175,13 +199,20 @@
       }
     }
 
-    List<ChangeData> cds = new ArrayList<>(hashes.size());
-    for (Change c : db.changes().get(changeIds)) {
-      if (c.getDest().equals(branch) && c.getStatus() != Change.Status.MERGED) {
-        cds.add(changeDataFactory.create(db, c));
-      }
-    }
-    return cds;
+    return Lists.transform(notesFactory.create(db, branch.getParentKey(),
+        changeIds, new com.google.common.base.Predicate<ChangeNotes>() {
+          @Override
+          public boolean apply(ChangeNotes notes) {
+            Change c = notes.getChange();
+            return c.getDest().equals(branch)
+                && c.getStatus() != Change.Status.MERGED;
+          }
+        }), new Function<ChangeNotes, ChangeData>() {
+          @Override
+          public ChangeData apply(ChangeNotes notes) {
+            return changeDataFactory.create(db, notes);
+          }
+        });
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
@@ -190,14 +221,13 @@
         ref(branch),
         project(branch.getParentKey()),
         not(status(Change.Status.MERGED)),
-        or(commits(schema(indexes), hashes))));
+        or(commits(hashes))));
   }
 
-  private static List<Predicate<ChangeData>> commits(Schema<ChangeData> schema,
-      List<String> hashes) {
+  private static List<Predicate<ChangeData>> commits(List<String> hashes) {
     List<Predicate<ChangeData>> commits = new ArrayList<>(hashes.size());
     for (String s : hashes) {
-      commits.add(commit(schema, s));
+      commits.add(commit(s));
     }
     return commits;
   }
@@ -209,26 +239,47 @@
 
   public List<ChangeData> byTopicOpen(String topic)
       throws OrmException {
-    return query(and(new ExactTopicPredicate(schema(indexes), topic), open()));
+    return query(and(new ExactTopicPredicate(topic), open()));
   }
 
   public List<ChangeData> byCommit(ObjectId id) throws OrmException {
-    return query(commit(schema(indexes), id.name()));
+    return byCommit(id.name());
+  }
+
+  public List<ChangeData> byCommit(String hash) throws OrmException {
+    return query(commit(hash));
+  }
+
+  public List<ChangeData> byProjectCommit(Project.NameKey project,
+      String hash) throws OrmException {
+    return query(and(project(project), commit(hash)));
   }
 
   public List<ChangeData> byProjectCommits(Project.NameKey project,
       List<String> hashes) throws OrmException {
     int n = indexConfig.maxTerms() - 1;
     checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
-    return query(and(project(project), or(commits(schema(indexes), hashes))));
+    return query(and(project(project), or(commits(hashes))));
+  }
+
+  public List<ChangeData> byBranchCommit(String project, String branch,
+      String hash) throws OrmException {
+    return query(and(
+        new ProjectPredicate(project),
+        new RefPredicate(branch),
+        commit(hash)));
+  }
+
+  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash)
+      throws OrmException {
+    return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
   }
 
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
-    if (Strings.isNullOrEmpty(cs) || !schema(indexes).hasField(SUBMISSIONID)) {
+    if (Strings.isNullOrEmpty(cs) || !schema().hasField(SUBMISSIONID)) {
       return Collections.emptyList();
-    } else {
-      return query(new SubmissionIdPredicate(cs));
     }
+    return query(new SubmissionIdPredicate(cs));
   }
 
   public List<ChangeData> byProjectGroups(Project.NameKey project,
@@ -240,16 +291,8 @@
     return query(and(project(project), or(groupPredicates)));
   }
 
-  public List<ChangeData> query(Predicate<ChangeData> p) throws OrmException {
-    try {
-      return qp.queryChanges(p).changes();
-    } catch (QueryParseException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private static Schema<ChangeData> schema(@Nullable IndexCollection indexes) {
-    ChangeIndex index = indexes != null ? indexes.getSearchIndex() : null;
-    return index != null ? index.getSchema() : null;
+  @SuppressWarnings("deprecation")
+  public List<ChangeData> byIsStarred(Account.Id id) throws OrmException {
+    return query(new IsStarredByPredicate(id));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
index 3c02bab..376ad84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
@@ -26,7 +25,7 @@
 
 import java.io.IOException;
 
-public class IsMergePredicate extends OperatorPredicate<ChangeData> {
+public class IsMergePredicate extends ChangeOperatorPredicate {
   private final Arguments args;
 
   public IsMergePredicate(Arguments args, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
index 8e300a9..d998fa3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class IsMergeablePredicate extends IndexPredicate<ChangeData> {
+class IsMergeablePredicate extends ChangeIndexPredicate {
   private final FillArgs args;
 
   IsMergeablePredicate(FillArgs args) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 645fa4c..24fcd6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -14,18 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.index.ChangeField.LEGACY_REVIEWED;
-import static com.google.gerrit.server.index.ChangeField.REVIEWEDBY;
+import static com.google.gerrit.server.index.change.ChangeField.REVIEWEDBY;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -33,24 +26,15 @@
 import java.util.List;
 import java.util.Set;
 
-class IsReviewedPredicate extends IndexPredicate<ChangeData> {
+class IsReviewedPredicate extends ChangeIndexPredicate {
   private static final Account.Id NOT_REVIEWED =
       new Account.Id(ChangeField.NOT_REVIEWED);
 
-  @SuppressWarnings("deprecation")
-  static Predicate<ChangeData> create(Schema<ChangeData> schema) {
-    if (getField(schema) == LEGACY_REVIEWED) {
-      return new LegacyIsReviewedPredicate();
-    }
+  static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
   }
 
-  @SuppressWarnings("deprecation")
-  static Predicate<ChangeData> create(Schema<ChangeData> schema,
-      Collection<Account.Id> ids) throws QueryParseException {
-    if (getField(schema) == LEGACY_REVIEWED) {
-      throw new QueryParseException("Only is:reviewed is supported");
-    }
+  static Predicate<ChangeData> create(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
       predicates.add(new IsReviewedPredicate(id));
@@ -58,15 +42,6 @@
     return Predicate.or(predicates);
   }
 
-  @SuppressWarnings("deprecation")
-  private static FieldDef<ChangeData, ?> getField(Schema<ChangeData> schema) {
-    Optional<FieldDef<ChangeData, ?>> f =
-        schema.getField(REVIEWEDBY, LEGACY_REVIEWED);
-    checkState(f.isPresent(), "Schema %s missing field %s",
-        schema.getVersion(), REVIEWEDBY.getName());
-    return f.get();
-  }
-
   private final Account.Id id;
 
   private IsReviewedPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
new file mode 100644
index 0000000..19cbd23
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+
+import java.util.List;
+import java.util.Set;
+
+@Deprecated
+class IsStarredByLegacyPredicate extends OrPredicate<ChangeData> {
+  private static List<Predicate<ChangeData>> predicates(Set<Change.Id> ids) {
+    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(ids.size());
+    for (Change.Id id : ids) {
+      r.add(new LegacyChangeIdPredicate(id));
+    }
+    return r;
+  }
+
+  private final Account.Id accountId;
+  private final Set<Change.Id> starredChanges;
+
+  IsStarredByLegacyPredicate(Account.Id accountId,
+      Set<Change.Id> starredChanges) {
+    super(predicates(starredChanges));
+    this.accountId = accountId;
+    this.starredChanges = starredChanges;
+  }
+
+  @Override
+  public boolean match(final ChangeData object) {
+    return starredChanges.contains(object.getId());
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
index 1ac2729..929ed18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
@@ -14,85 +14,31 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 
-import java.util.List;
-import java.util.Set;
+@Deprecated
+class IsStarredByPredicate extends ChangeIndexPredicate {
+  private final Account.Id accountId;
 
-class IsStarredByPredicate extends OrPredicate<ChangeData> implements
-    ChangeDataSource {
-  private static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    return user.toString();
-  }
-
-  private static List<Predicate<ChangeData>> predicates(
-      Schema<ChangeData> schema, Set<Change.Id> ids) {
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(ids.size());
-    for (Change.Id id : ids) {
-      r.add(new LegacyChangeIdPredicate(schema, id));
-    }
-    return r;
-  }
-
-  private final Arguments args;
-  private final CurrentUser user;
-
-  IsStarredByPredicate(Arguments args) throws QueryParseException {
-    this(args, args.getIdentifiedUser());
-  }
-
-  private IsStarredByPredicate(Arguments args, IdentifiedUser user) {
-    super(predicates(args.getSchema(), user.getStarredChanges()));
-    this.args = args;
-    this.user = user;
+  IsStarredByPredicate(Account.Id accountId) {
+    super(ChangeField.STARREDBY, accountId.toString());
+    this.accountId = accountId;
   }
 
   @Override
-  public boolean match(final ChangeData object) {
-    return user.getStarredChanges().contains(object.getId());
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    return ChangeDataResultSet.change(args.changeDataFactory, args.db,
-        args.db.get().changes().get(user.getStarredChanges()));
-  }
-
-  @Override
-  public boolean hasChange() {
-    return true;
-  }
-
-  @Override
-  public int getCardinality() {
-    return 10;
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.starredBy().contains(accountId);
   }
 
   @Override
   public int getCost() {
-    return 0;
+    return 1;
   }
 
   @Override
   public String toString() {
-    String val = describe(user);
-    if (val.indexOf(' ') < 0) {
-      return ChangeQueryBuilder.FIELD_STARREDBY + ":" + val;
-    } else {
-      return ChangeQueryBuilder.FIELD_STARREDBY + ":\"" + val + "\"";
-    }
+    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java
deleted file mode 100644
index 856a559..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.OperatorPredicate;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-
-class IsVisibleToPredicate extends OperatorPredicate<ChangeData> {
-  private static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    if (user instanceof SingleGroupUser) {
-      return "group:" + user.getEffectiveGroups().getKnownGroups() //
-          .iterator().next().toString();
-    }
-    return user.toString();
-  }
-
-  private final Provider<ReviewDb> db;
-  private final ChangeControl.GenericFactory changeControl;
-  private final CurrentUser user;
-
-  IsVisibleToPredicate(Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeControlFactory, CurrentUser user) {
-    super(ChangeQueryBuilder.FIELD_VISIBLETO, describe(user));
-    this.db = db;
-    this.changeControl = changeControlFactory;
-    this.user = user;
-  }
-
-  @Override
-  public boolean match(final ChangeData cd) throws OrmException {
-    if (cd.fastIsVisibleTo(user)) {
-      return true;
-    }
-    try {
-      Change c = cd.change();
-      if (c == null) {
-        return false;
-      }
-
-      ChangeControl cc = changeControl.controlFor(c, user);
-      if (cc.isVisible(db.get())) {
-        cd.cacheVisibleTo(cc);
-        return true;
-      }
-    } catch (NoSuchChangeException e) {
-      // Ignored
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index a027869..0b7a2f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -15,14 +15,16 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 
 class IsWatchedByPredicate extends AndPredicate<ChangeData> {
@@ -44,14 +46,13 @@
   private static List<Predicate<ChangeData>> filters(
       ChangeQueryBuilder.Arguments args,
       boolean checkIsVisible) throws QueryParseException {
-    CurrentUser user = args.getIdentifiedUser();
-    List<Predicate<ChangeData>> r = Lists.newArrayList();
+    List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
-    for (AccountProjectWatch w : user.getNotificationFilters()) {
+    for (ProjectWatchKey w : getWatches(args)) {
       Predicate<ChangeData> f = null;
-      if (w.getFilter() != null) {
+      if (w.filter() != null) {
         try {
-          f = builder.parse(w.getFilter());
+          f = builder.parse(w.filter());
           if (QueryBuilder.find(f, IsWatchedByPredicate.class) != null) {
             // If the query is going to infinite loop, assume it
             // will never match and return null. Yes this test
@@ -65,10 +66,10 @@
       }
 
       Predicate<ChangeData> p;
-      if (w.getProjectNameKey().equals(args.allProjectsName)) {
+      if (w.project().equals(args.allProjectsName)) {
         p = null;
       } else {
-        p = builder.project(w.getProjectNameKey().get());
+        p = builder.project(w.project().get());
       }
 
       if (p != null && f != null) {
@@ -90,6 +91,16 @@
     }
   }
 
+  private static Collection<ProjectWatchKey> getWatches(
+      ChangeQueryBuilder.Arguments args) throws QueryParseException {
+    CurrentUser user = args.getUser();
+    if (user.isIdentifiedUser()) {
+      return args.accountCache.get(args.getUser().getAccountId())
+          .getProjectWatches().keySet();
+    }
+    return Collections.<ProjectWatchKey> emptySet();
+  }
+
   private static List<Predicate<ChangeData>> none() {
     Predicate<ChangeData> any = any();
     return ImmutableList.of(not(any));
@@ -105,8 +116,7 @@
     String val = describe(user);
     if (val.indexOf(' ') < 0) {
       return ChangeQueryBuilder.FIELD_WATCHEDBY + ":" + val;
-    } else {
-      return ChangeQueryBuilder.FIELD_WATCHEDBY + ":\"" + val + "\"";
     }
+    return ChangeQueryBuilder.FIELD_WATCHEDBY + ":\"" + val + "\"";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2e2454d..2f815b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.util.RangeUtil.Range;
 import com.google.inject.Provider;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
@@ -132,9 +133,8 @@
       int expVal) {
     if (expVal != 0) {
       return equalsLabelPredicate(args, label, expVal);
-    } else {
-      return noLabelQuery(args, label);
     }
+    return noLabelQuery(args, label);
   }
 
   private static Predicate<ChangeData> noLabelQuery(Args args, String label) {
@@ -151,13 +151,12 @@
       String label, int expVal) {
     if (args.accounts == null || args.accounts.isEmpty()) {
       return new EqualsLabelPredicate(args, label, expVal, null);
-    } else {
-      List<Predicate<ChangeData>> r = Lists.newArrayList();
-      for (Account.Id a : args.accounts) {
-        r.add(new EqualsLabelPredicate(args, label, expVal, a));
-      }
-      return or(r);
     }
+    List<Predicate<ChangeData>> r = new ArrayList<>();
+    for (Account.Id a : args.accounts) {
+      r.add(new EqualsLabelPredicate(args, label, expVal, a));
+    }
+    return or(r);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index bf59553..425eb00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -14,32 +14,16 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.ChangeField.LEGACY_ID2;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.Schema;
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdPredicate extends IndexPredicate<ChangeData> {
+public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
   private final Change.Id id;
 
-  @SuppressWarnings("deprecation")
-  public static FieldDef<ChangeData, ?> idField(Schema<ChangeData> schema) {
-    if (schema == null) {
-      return ChangeField.LEGACY_ID2;
-    } else if (schema.hasField(LEGACY_ID2)) {
-      return schema.getFields().get(LEGACY_ID2.getName());
-    } else {
-      return schema.getFields().get(LEGACY_ID.getName());
-    }
-  }
-
-  LegacyChangeIdPredicate(Schema<ChangeData> schema, Change.Id id) {
-    super(idField(schema), ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+  LegacyChangeIdPredicate(Change.Id id) {
+    super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
     this.id = id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyIsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyIsReviewedPredicate.java
deleted file mode 100644
index e12e6e0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyIsReviewedPredicate.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gwtorm.server.OrmException;
-
-@Deprecated
-class LegacyIsReviewedPredicate extends IndexPredicate<ChangeData> {
-  @Deprecated
-  LegacyIsReviewedPredicate() {
-    super(ChangeField.LEGACY_REVIEWED, "1");
-  }
-
-  @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    Change c = object.change();
-    if (c == null) {
-      return false;
-    }
-
-    PatchSet.Id current = c.currentPatchSetId();
-    for (PatchSetApproval p : object.approvals().get(current)) {
-      if (p.getValue() != 0) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 2;
-  }
-
-  @Override
-  public String toString() {
-    return "is:reviewed";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
new file mode 100644
index 0000000..cd93ed3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+@Deprecated
+class LegacyReviewerPredicate extends ChangeIndexPredicate {
+  private final Account.Id id;
+
+  LegacyReviewerPredicate(Account.Id id) {
+    super(ChangeField.LEGACY_REVIEWER, id.toString());
+    this.id = id;
+  }
+
+  Account.Id getAccountId() {
+    return id;
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    return object.reviewers().all().contains(id);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java
deleted file mode 100644
index 0e90ddf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
-
-import com.google.gerrit.server.query.IntPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
-
-public class LimitPredicate extends IntPredicate<ChangeData> {
-  @SuppressWarnings("unchecked")
-  public static Integer getLimit(Predicate<ChangeData> p) {
-    IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, FIELD_LIMIT);
-    return ip != null ? ip.intValue() : null;
-  }
-
-  public LimitPredicate(int limit) throws QueryParseException {
-    super(ChangeQueryBuilder.FIELD_LIMIT, limit);
-    if (limit <= 0) {
-      throw new QueryParseException("limit must be positive: " + limit);
-    }
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return true;
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index cf3140a..722a8ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.ChangeIndex;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
@@ -25,7 +25,7 @@
  * Predicate to match changes that contains specified text in commit messages
  * body.
  */
-class MessagePredicate extends IndexPredicate<ChangeData> {
+class MessagePredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
   MessagePredicate(ChangeIndex index, String value) {
@@ -37,10 +37,9 @@
   public boolean match(ChangeData object) throws OrmException {
     try {
       Predicate<ChangeData> p = Predicate.and(
-          new LegacyChangeIdPredicate(index.getSchema(), object.getId()),
-          this);
+          new LegacyChangeIdPredicate(object.getId()), this);
       for (ChangeData cData
-          : index.getSource(p, QueryOptions.oneResult()).read()) {
+          : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
         if (cData.getId().equals(object.getId())) {
           return true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 280be50..496eff6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -32,10 +32,10 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryResult;
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -69,14 +69,14 @@
   private static final DateTimeFormatter dtf =
       DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
 
-  public static enum OutputFormat {
+  public enum OutputFormat {
     TEXT, JSON
   }
 
-  private final Provider<ReviewDb> db;
+  private final ReviewDb db;
   private final GitRepositoryManager repoManager;
   private final ChangeQueryBuilder queryBuilder;
-  private final QueryProcessor queryProcessor;
+  private final ChangeQueryProcessor queryProcessor;
   private final EventFactory eventFactory;
   private final TrackingFooters trackingFooters;
   private final CurrentUser user;
@@ -97,10 +97,10 @@
 
   @Inject
   OutputStreamQuery(
-      Provider<ReviewDb> db,
+      ReviewDb db,
       GitRepositoryManager repoManager,
       ChangeQueryBuilder queryBuilder,
-      QueryProcessor queryProcessor,
+      ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
       CurrentUser user) {
@@ -196,18 +196,18 @@
 
         Map<Project.NameKey, Repository> repos = new HashMap<>();
         Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
-        QueryResult results =
-            queryProcessor.queryChanges(queryBuilder.parse(queryString));
+        QueryResult<ChangeData> results =
+            queryProcessor.query(queryBuilder.parse(queryString));
         try {
-          for (ChangeData d : results.changes()) {
+          for (ChangeData d : results.entities()) {
             show(buildChangeAttribute(d, repos, revWalks));
           }
         } finally {
           closeAll(revWalks.values(), repos.values());
         }
 
-        stats.rowCount = results.changes().size();
-        stats.moreChanges = results.moreChanges();
+        stats.rowCount = results.entities().size();
+        stats.moreChanges = results.more();
         stats.runTimeMilliseconds =
             TimeUtil.nowMs() - stats.runTimeMilliseconds;
         show(stats);
@@ -239,7 +239,7 @@
     ChangeControl cc = d.changeControl().forUser(user);
 
     LabelTypes labelTypes = cc.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(db.get(), d.change());
+    ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
@@ -248,7 +248,7 @@
     }
 
     if (includeAllReviewers) {
-      eventFactory.addAllReviewers(db.get(), c, d.notes());
+      eventFactory.addAllReviewers(db, c, d.notes());
     }
 
     if (includeSubmitRecords) {
@@ -276,7 +276,7 @@
     }
 
     if (includePatchSets) {
-      eventFactory.addPatchSets(db.get(), rw, c, d.visiblePatchSets(),
+      eventFactory.addPatchSets(db, rw, c, d.visiblePatchSets(),
           includeApprovals ? d.approvals().asMap() : null,
           includeFiles, d.change(), labelTypes);
     }
@@ -285,7 +285,7 @@
       PatchSet current = d.currentPatchSet();
       if (current != null && cc.isPatchVisible(current, d.db())) {
         c.currentPatchSet =
-            eventFactory.asPatchSetAttribute(db.get(), rw, current);
+            eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
         eventFactory.addApprovals(c.currentPatchSet,
             d.currentApprovals(), labelTypes);
 
@@ -303,7 +303,7 @@
     if (includeComments) {
       eventFactory.addComments(c, d.messages());
       if (includePatchSets) {
-        eventFactory.addPatchSets(db.get(), rw, c, d.visiblePatchSets(),
+        eventFactory.addPatchSets(db, rw, c, d.visiblePatchSets(),
             includeApprovals ? d.approvals().asMap() : null,
             includeFiles, d.change(), labelTypes);
         for (PatchSetAttribute attribute : c.patchSets) {
@@ -377,9 +377,8 @@
   private String indent(int spaces) {
     if (spaces == 0) {
       return "";
-    } else {
-      return String.format("%" + spaces + "s", " ");
     }
+    return String.format("%" + spaces + "s", " ");
   }
 
   private void showField(String field, Object value, int depth) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index 87ebffb..dfaac08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerPredicate extends IndexPredicate<ChangeData> {
+class OwnerPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   OwnerPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index a0c1235..72327ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -16,21 +16,16 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 
-class OwnerinPredicate extends OperatorPredicate<ChangeData> {
-  private final Provider<ReviewDb> dbProvider;
+class OwnerinPredicate extends ChangeOperatorPredicate {
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(Provider<ReviewDb> dbProvider,
-    IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory,
+    AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
-    this.dbProvider = dbProvider;
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
@@ -45,8 +40,7 @@
     if (change == null) {
       return false;
     }
-    final IdentifiedUser owner = userFactory.create(dbProvider,
-      change.getOwner());
+    final IdentifiedUser owner = userFactory.create(change.getOwner());
     return owner.getEffectiveGroups().contains(uuid);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java
deleted file mode 100644
index 3278b7f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-public interface Paginated {
-  QueryOptions getOptions();
-
-  ResultSet<ChangeData> restart(int start) throws OrmException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 2cabfc5..0cd6978 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.inject.Provider;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
@@ -48,7 +48,7 @@
       return Collections.emptyList();
     }
 
-    List<Predicate<ChangeData>> r = Lists.newArrayList();
+    List<Predicate<ChangeData>> r = new ArrayList<>();
     r.add(new ProjectPredicate(projectState.getProject().getName()));
     ListChildProjects children = listChildProjects.get();
     children.setRecursive(true);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
index 23350d2..2fd0177 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.gerrit.server.query.QueryParseException;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -44,8 +44,8 @@
    * @throws QueryParseException
    */
   PredicateArgs(String args) throws QueryParseException {
-    positional = Lists.newArrayList();
-    keyValue = Maps.newHashMap();
+    positional = new ArrayList<>();
+    keyValue = new HashMap<>();
 
     String[] splitArgs = args.split(",");
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 872b854..644870d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPredicate extends IndexPredicate<ChangeData> {
+class ProjectPredicate extends ChangeIndexPredicate {
   ProjectPredicate(String id) {
     super(ChangeField.PROJECT, id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index d0faf0f..4c06d1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPrefixPredicate extends IndexPredicate<ChangeData> {
+class ProjectPrefixPredicate extends ChangeIndexPredicate {
   ProjectPrefixPredicate(String prefix) {
     super(ChangeField.PROJECTS, prefix);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index dfc0f75e9..69a392b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -14,23 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryResult;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.kohsuke.args4j.Option;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
@@ -40,8 +38,7 @@
 public class QueryChanges implements RestReadView<TopLevelResource> {
   private final ChangeJson.Factory json;
   private final ChangeQueryBuilder qb;
-  private final QueryProcessor imp;
-  private final Provider<CurrentUser> user;
+  private final ChangeQueryProcessor imp;
   private EnumSet<ListChangesOption> options;
 
   @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "Query string")
@@ -70,19 +67,17 @@
   @Inject
   QueryChanges(ChangeJson.Factory json,
       ChangeQueryBuilder qb,
-      QueryProcessor qp,
-      Provider<CurrentUser> user) {
+      ChangeQueryProcessor qp) {
     this.json = json;
     this.qb = qb;
     this.imp = qp;
-    this.user = user;
 
     options = EnumSet.noneOf(ListChangesOption.class);
   }
 
   public void addQuery(String query) {
     if (queries == null) {
-      queries = Lists.newArrayList();
+      queries = new ArrayList<>();
     }
     queries.add(query);
   }
@@ -124,29 +119,13 @@
       throw new QueryParseException("limit of 10 queries");
     }
 
-    IdentifiedUser self = null;
-    try {
-      if (user.get().isIdentifiedUser()) {
-        self = user.get().asIdentifiedUser();
-        self.asyncStarredChanges();
-      }
-      return query0();
-    } finally {
-      if (self != null) {
-        self.abortStarredChanges();
-      }
-    }
-  }
-
-  private List<List<ChangeInfo>> query0() throws OrmException,
-      QueryParseException {
     int cnt = queries.size();
-    List<QueryResult> results = imp.queryChanges(qb.parse(queries));
+    List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
     List<List<ChangeInfo>> res = json.create(options)
         .formatQueryResults(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
-      if (results.get(n).moreChanges()) {
+      if (results.get(n).more()) {
         info.get(info.size() - 1)._moreChanges = true;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java
deleted file mode 100644
index 1964fa5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.server.index.IndexConfig;
-
-@AutoValue
-public abstract class QueryOptions {
-  public static QueryOptions create(IndexConfig config, int start, int limit) {
-    checkArgument(start >= 0, "start must be nonnegative: %s", start);
-    checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit);
-  }
-
-  public static QueryOptions oneResult() {
-    return create(IndexConfig.createDefault(), 0, 1);
-  }
-
-  public abstract IndexConfig config();
-  public abstract int start();
-  public abstract int limit();
-
-  public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit);
-  }
-
-  public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
deleted file mode 100644
index 870ca040..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.IndexRewriter;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class QueryProcessor {
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final IndexRewriter rewriter;
-  private final IndexConfig indexConfig;
-
-  private int limitFromCaller;
-  private int start;
-  private boolean enforceVisibility = true;
-
-  @Inject
-  QueryProcessor(Provider<ReviewDb> db,
-      Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory,
-      IndexRewriter rewriter,
-      IndexConfig indexConfig) {
-    this.db = db;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
-    this.rewriter = rewriter;
-    this.indexConfig = indexConfig;
-  }
-
-  public QueryProcessor enforceVisibility(boolean enforce) {
-    enforceVisibility = enforce;
-    return this;
-  }
-
-  public QueryProcessor setLimit(int n) {
-    limitFromCaller = n;
-    return this;
-  }
-
-  public QueryProcessor setStart(int n) {
-    start = n;
-    return this;
-  }
-
-  /**
-   * Query for changes that match a structured query.
-   *
-   * @see #queryChanges(List)
-   * @param query the query.
-   * @return results of the query.
-   */
-  public QueryResult queryChanges(Predicate<ChangeData> query)
-      throws OrmException, QueryParseException {
-    return queryChanges(ImmutableList.of(query)).get(0);
-  }
-
-  /*
-   * Perform multiple queries over a list of query strings.
-   * <p>
-   * If a limit was specified using {@link #setLimit(int)} this method may
-   * return up to {@code limit + 1} results, allowing the caller to determine if
-   * there are more than {@code limit} matches and suggest to its own caller
-   * that the query could be retried with {@link #setStart(int)}.
-   *
-   * @param queries the queries.
-   * @return results of the queries, one list per input query.
-   */
-  public List<QueryResult> queryChanges(List<Predicate<ChangeData>> queries)
-      throws OrmException, QueryParseException {
-    try {
-      return queryChanges(null, queries);
-    } catch (OrmRuntimeException e) {
-      throw new OrmException(e.getMessage(), e);
-    }
-
-  }
-
-  static {
-    // In addition to this assumption, this queryChanges assumes the basic
-    // rewrites do not touch visibleto predicates either.
-    checkState(
-        !IsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
-        "QueryProcessor assumes visibleto is not used by the index rewriter.");
-  }
-
-  private List<QueryResult> queryChanges(List<String> queryStrings,
-      List<Predicate<ChangeData>> queries)
-      throws OrmException, QueryParseException {
-    Predicate<ChangeData> visibleToMe = enforceVisibility
-        ? new IsVisibleToPredicate(db, changeControlFactory, userProvider.get())
-        : null;
-    int cnt = queries.size();
-
-    // Parse and rewrite all queries.
-    List<Integer> limits = new ArrayList<>(cnt);
-    List<Predicate<ChangeData>> predicates = new ArrayList<>(cnt);
-    List<ChangeDataSource> sources = new ArrayList<>(cnt);
-    for (Predicate<ChangeData> q : queries) {
-      int limit = getEffectiveLimit(q);
-      limits.add(limit);
-
-      // Always bump limit by 1, even if this results in exceeding the permitted
-      // max for this user. The only way to see if there are more changes is to
-      // ask for one more result from the query.
-      if (limit == getBackendSupportedLimit()) {
-        limit--;
-      }
-
-      int page = (start / limit) + 1;
-      if (page > indexConfig.maxPages()) {
-        throw new QueryParseException(
-            "Cannot go beyond page " + indexConfig.maxPages() + "of results");
-      }
-
-      QueryOptions opts = QueryOptions.create(indexConfig, start, limit + 1);
-      Predicate<ChangeData> s = rewriter.rewrite(q, opts);
-      if (!(s instanceof ChangeDataSource)) {
-        q = Predicate.and(open(), q);
-        s = rewriter.rewrite(q, opts);
-      }
-      if (!(s instanceof ChangeDataSource)) {
-        throw new QueryParseException("invalid query: " + s);
-      }
-      if (enforceVisibility) {
-        s = new AndSource(ImmutableList.of(s, visibleToMe), start);
-      }
-      predicates.add(s);
-      sources.add((ChangeDataSource) s);
-    }
-
-    // Run each query asynchronously, if supported.
-    List<ResultSet<ChangeData>> matches = new ArrayList<>(cnt);
-    for (ChangeDataSource s : sources) {
-      matches.add(s.read());
-    }
-
-    List<QueryResult> out = new ArrayList<>(cnt);
-    for (int i = 0; i < cnt; i++) {
-      out.add(QueryResult.create(
-          queryStrings != null ? queryStrings.get(i) : null,
-          predicates.get(i),
-          limits.get(i),
-          matches.get(i).toList()));
-    }
-    return out;
-  }
-
-  boolean isDisabled() {
-    return getPermittedLimit() <= 0;
-  }
-
-  private int getPermittedLimit() {
-    if (enforceVisibility) {
-      return userProvider.get().getCapabilities()
-        .getRange(GlobalCapability.QUERY_LIMIT)
-        .getMax();
-    }
-    return Integer.MAX_VALUE;
-  }
-
-  private int getBackendSupportedLimit() {
-    return indexConfig.maxLimit();
-  }
-
-  private int getEffectiveLimit(Predicate<ChangeData> p) {
-    List<Integer> possibleLimits = new ArrayList<>(4);
-    possibleLimits.add(getBackendSupportedLimit());
-    possibleLimits.add(getPermittedLimit());
-    if (limitFromCaller > 0) {
-      possibleLimits.add(limitFromCaller);
-    }
-    Integer limitFromPredicate = LimitPredicate.getLimit(p);
-    if (limitFromPredicate != null) {
-      possibleLimits.add(limitFromPredicate);
-    }
-    return Ordering.natural().min(possibleLimits);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryResult.java
deleted file mode 100644
index a93f7ac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryResult.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.query.Predicate;
-
-import java.util.List;
-
-/** Results of a query over changes. */
-@AutoValue
-public abstract class QueryResult {
-  static QueryResult create(@Nullable String query,
-      Predicate<ChangeData> predicate, int limit, List<ChangeData> changes) {
-    boolean moreChanges;
-    if (changes.size() > limit) {
-      moreChanges = true;
-      changes = changes.subList(0, limit);
-    } else {
-      moreChanges = false;
-    }
-    return new AutoValue_QueryResult(query, predicate, changes, moreChanges);
-  }
-
-  /**
-   * @return the original query string, or null if the query was created
-   *     programmatically.
-   */
-  @Nullable public abstract String query();
-
-  /**
-   * @return the predicate after all rewriting and other modification by the
-   *     query subsystem.
-   */
-  public abstract Predicate<ChangeData> predicate();
-
-  /** @return the query results. */
-  public abstract List<ChangeData> changes();
-
-  /**
-   * @return whether the query could be retried with
-   *     {@link QueryProcessor#setStart(int)} to produce more results. Never
-   *     true if {@link #changes()} is empty.
-   */
-  public abstract boolean moreChanges();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
index 8a43fb1..491aed9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class RefPredicate extends IndexPredicate<ChangeData> {
+class RefPredicate extends ChangeIndexPredicate {
   RefPredicate(String ref) {
     super(ChangeField.REF, ref);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 049aa40..67efd69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.List;
 
-class RegexPathPredicate extends RegexPredicate<ChangeData> {
+class RegexPathPredicate extends ChangeRegexPredicate {
   RegexPathPredicate(String re) {
     super(ChangeField.PATH, re);
   }
@@ -31,13 +30,12 @@
     List<String> files = object.currentFilePaths();
     if (files != null) {
       return RegexListSearcher.ofStrings(getValue()).hasMatch(files);
-    } else {
-      // The ChangeData can't do expensive lookups right now. Bypass
-      // them and include the result anyway. We might be able to do
-      // a narrow later on to a smaller set.
-      //
-      return true;
     }
+    // The ChangeData can't do expensive lookups right now. Bypass
+    // them and include the result anyway. We might be able to do
+    // a narrow later on to a smaller set.
+    //
+    return true;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index df4fff9..007566e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexProjectPredicate extends RegexPredicate<ChangeData> {
+class RegexProjectPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
   RegexProjectPredicate(String re) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 31625df..c6d1577 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexRefPredicate extends RegexPredicate<ChangeData> {
+class RegexRefPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
   RegexRefPredicate(String re) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index fecc72e..a02edd1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -14,19 +14,19 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
+
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.RegexPredicate;
-import com.google.gerrit.server.index.Schema;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexTopicPredicate extends RegexPredicate<ChangeData> {
+class RegexTopicPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
-  RegexTopicPredicate(Schema<ChangeData> schema, String re) {
-    super(ExactTopicPredicate.topicField(schema), re);
+  RegexTopicPredicate(String re) {
+    super(EXACT_TOPIC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 004de72..1c4fbbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -16,18 +16,46 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class ReviewerPredicate extends IndexPredicate<ChangeData> {
-  private final Account.Id id;
-  private boolean allowDrafts;
+import java.util.ArrayList;
+import java.util.List;
 
-  ReviewerPredicate(Account.Id id, boolean allowDrafts) {
-    super(ChangeField.REVIEWER, id.toString());
+class ReviewerPredicate extends ChangeIndexPredicate {
+  @SuppressWarnings("deprecation")
+  static Predicate<ChangeData> create(Arguments args, Account.Id id) {
+    List<Predicate<ChangeData>> and = new ArrayList<>(2);
+    if (args.getSchema().hasField(ChangeField.REVIEWER)) {
+      ReviewerStateInternal[] states = ReviewerStateInternal.values();
+      List<Predicate<ChangeData>> or = new ArrayList<>(states.length - 1);
+      for (ReviewerStateInternal state : states) {
+        if (state != ReviewerStateInternal.REMOVED) {
+          or.add(new ReviewerPredicate(state, id));
+        }
+      }
+      and.add(Predicate.or(or));
+    } else {
+      and.add(new LegacyReviewerPredicate(id));
+    }
+
+    // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor.
+    if (!args.allowsDrafts) {
+      and.add(Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
+    }
+    return Predicate.and(and);
+  }
+
+  private final ReviewerStateInternal state;
+  private final Account.Id id;
+
+  ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
+    super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
+    this.state = state;
     this.id = id;
-    this.allowDrafts = allowDrafts;
   }
 
   Account.Id getAccountId() {
@@ -35,21 +63,12 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    if (!allowDrafts &&
-        object.change().getStatus() == Change.Status.DRAFT) {
-      return false;
-    }
-    for (Account.Id accountId : object.reviewers().values()) {
-      if (id.equals(accountId)) {
-        return true;
-      }
-    }
-    return false;
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.reviewers().asTable().get(state, id) != null;
   }
 
   @Override
   public int getCost() {
-    return 2;
+    return 1;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index a29ac62..34c10e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -16,21 +16,16 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 
-class ReviewerinPredicate extends OperatorPredicate<ChangeData> {
-  private final Provider<ReviewDb> dbProvider;
+class ReviewerinPredicate extends ChangeOperatorPredicate {
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(Provider<ReviewDb> dbProvider,
-    IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory,
+    AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
-    this.dbProvider = dbProvider;
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
@@ -41,8 +36,8 @@
 
   @Override
   public boolean match(final ChangeData object) throws OrmException {
-    for (Account.Id accountId : object.reviewers().values()) {
-      IdentifiedUser reviewer = userFactory.create(dbProvider, accountId);
+    for (Account.Id accountId : object.reviewers().all()) {
+      IdentifiedUser reviewer = userFactory.create(accountId);
       if (reviewer.getEffectiveGroups().contains(uuid)) {
         return true;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
index 4b71719..33b338c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 
-import java.util.Collection;
 import java.util.Collections;
 import java.util.Set;
 
@@ -44,14 +41,4 @@
   public GroupMembership getEffectiveGroups() {
     return groups;
   }
-
-  @Override
-  public Set<Change.Id> getStarredChanges() {
-    return Collections.emptySet();
-  }
-
-  @Override
-  public Collection<AccountProjectWatch> getNotificationFilters() {
-    return Collections.emptySet();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
new file mode 100644
index 0000000..a31254f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+public class StarPredicate extends ChangeIndexPredicate {
+  private final Account.Id accountId;
+  private final String label;
+
+  StarPredicate(Account.Id accountId, String label) {
+    super(ChangeField.STAR,
+        StarredChangesUtil.StarField.create(accountId, label).toString());
+    this.accountId = accountId;
+    this.label = label;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.stars().get(accountId).contains(label);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  @Override
+  public String toString() {
+    return ChangeQueryBuilder.FIELD_STAR + ":" + label;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index 3b2dd94..d8d5258 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmissionIdPredicate extends IndexPredicate<ChangeData> {
+class SubmissionIdPredicate extends ChangeIndexPredicate {
 
   SubmissionIdPredicate(String changeSet) {
     super(ChangeField.SUBMISSIONID, changeSet);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
new file mode 100644
index 0000000..9242d9d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.query.Matchable;
+
+import java.sql.Timestamp;
+
+public abstract class TimestampRangeChangePredicate extends
+    TimestampRangePredicate<ChangeData> implements Matchable<ChangeData> {
+  protected TimestampRangeChangePredicate(FieldDef<ChangeData, Timestamp> def,
+      String name, String value) {
+    super(def, name, value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index 4f2a8d7..e9be4cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -16,8 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.index.ChangeField;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.revwalk.FooterLine;
@@ -27,7 +26,7 @@
 import java.io.IOException;
 import java.util.List;
 
-class TrackingIdPredicate extends IndexPredicate<ChangeData> {
+class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
   private final TrackingFooters trackingFooters;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
index ced8cd0..2f49f9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
@@ -29,7 +29,16 @@
 
   public static void grant(ProjectConfig config, AccessSection section,
       String permission, boolean force, GroupReference... groupList) {
+    grant(config, section, permission, force, null, groupList);
+  }
+
+  public static void grant(ProjectConfig config, AccessSection section,
+      String permission, boolean force, Boolean exclusive,
+      GroupReference... groupList) {
     Permission p = section.getPermission(permission, true);
+    if (exclusive != null) {
+      p.setExclusiveGroup(exclusive);
+    }
     for (GroupReference group : groupList) {
       if (group != null) {
         PermissionRule r = rule(config, group);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9b6e36a..7c7417a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -114,68 +114,71 @@
 
   private void initAllProjects(Repository git)
       throws IOException, ConfigInvalidException {
-    MetaDataUpdate md = new MetaDataUpdate(
-        GitReferenceUpdated.DISABLED,
-        allProjectsName,
-        git);
-    md.getCommitBuilder().setAuthor(serverUser);
-    md.getCommitBuilder().setCommitter(serverUser);
-    md.setMessage(MoreObjects.firstNonNull(
-        Strings.emptyToNull(message),
-        "Initialized Gerrit Code Review " + Version.getVersion()));
+    try (MetaDataUpdate md = new MetaDataUpdate(
+          GitReferenceUpdated.DISABLED,
+          allProjectsName,
+          git)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(MoreObjects.firstNonNull(
+          Strings.emptyToNull(message),
+          "Initialized Gerrit Code Review " + Version.getVersion()));
 
-    ProjectConfig config = ProjectConfig.read(md);
-    Project p = config.getProject();
-    p.setDescription("Access inherited by all other projects.");
-    p.setRequireChangeID(InheritableBoolean.TRUE);
-    p.setUseContentMerge(InheritableBoolean.TRUE);
-    p.setUseContributorAgreements(InheritableBoolean.FALSE);
-    p.setUseSignedOffBy(InheritableBoolean.FALSE);
-    p.setEnableSignedPush(InheritableBoolean.FALSE);
+      ProjectConfig config = ProjectConfig.read(md);
+      Project p = config.getProject();
+      p.setDescription("Access inherited by all other projects.");
+      p.setRequireChangeID(InheritableBoolean.TRUE);
+      p.setUseContentMerge(InheritableBoolean.TRUE);
+      p.setUseContributorAgreements(InheritableBoolean.FALSE);
+      p.setUseSignedOffBy(InheritableBoolean.FALSE);
+      p.setEnableSignedPush(InheritableBoolean.FALSE);
 
-    AccessSection cap = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-    AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-    AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-    AccessSection tags = config.getAccessSection("refs/tags/*", true);
-    AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
-    AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+      AccessSection cap = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
+      AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+      AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
+      AccessSection tags = config.getAccessSection("refs/tags/*", true);
+      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
+      AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
 
-    grant(config, cap, GlobalCapability.ADMINISTRATE_SERVER, admin);
-    grant(config, all, Permission.READ, admin, anonymous);
+      grant(config, cap, GlobalCapability.ADMINISTRATE_SERVER, admin);
+      grant(config, all, Permission.READ, admin, anonymous);
+      grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
 
-    if (batch != null) {
-      Permission priority = cap.getPermission(GlobalCapability.PRIORITY, true);
-      PermissionRule r = rule(config, batch);
-      r.setAction(Action.BATCH);
-      priority.add(r);
+      if (batch != null) {
+        Permission priority = cap.getPermission(GlobalCapability.PRIORITY, true);
+        PermissionRule r = rule(config, batch);
+        r.setAction(Action.BATCH);
+        priority.add(r);
 
-      Permission stream = cap.getPermission(GlobalCapability.STREAM_EVENTS, true);
-      stream.add(rule(config, batch));
+        Permission stream = cap.getPermission(GlobalCapability.STREAM_EVENTS, true);
+        stream.add(rule(config, batch));
+      }
+
+      LabelType cr = initCodeReviewLabel(config);
+      grant(config, heads, cr, -1, 1, registered);
+      grant(config, heads, cr, -2, 2, admin, owners);
+      grant(config, heads, Permission.CREATE, admin, owners);
+      grant(config, heads, Permission.PUSH, admin, owners);
+      grant(config, heads, Permission.SUBMIT, admin, owners);
+      grant(config, heads, Permission.FORGE_AUTHOR, registered);
+      grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
+      grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
+
+      grant(config, tags, Permission.PUSH_TAG, admin, owners);
+      grant(config, tags, Permission.PUSH_SIGNED_TAG, admin, owners);
+
+      grant(config, magic, Permission.PUSH, registered);
+      grant(config, magic, Permission.PUSH_MERGE, registered);
+
+      meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
+      grant(config, meta, Permission.READ, admin, owners);
+      grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, Permission.PUSH, admin, owners);
+      grant(config, meta, Permission.SUBMIT, admin, owners);
+
+      config.commitToNewRef(md, RefNames.REFS_CONFIG);
     }
-
-    LabelType cr = initCodeReviewLabel(config);
-    grant(config, heads, cr, -1, 1, registered);
-    grant(config, heads, cr, -2, 2, admin, owners);
-    grant(config, heads, Permission.CREATE, admin, owners);
-    grant(config, heads, Permission.PUSH, admin, owners);
-    grant(config, heads, Permission.SUBMIT, admin, owners);
-    grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
-    grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
-
-    grant(config, tags, Permission.PUSH_TAG, admin, owners);
-    grant(config, tags, Permission.PUSH_SIGNED_TAG, admin, owners);
-
-    grant(config, magic, Permission.PUSH, registered);
-    grant(config, magic, Permission.PUSH_MERGE, registered);
-
-    meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
-    grant(config, meta, Permission.READ, admin, owners);
-    grant(config, meta, cr, -2, 2, admin, owners);
-    grant(config, meta, Permission.PUSH, admin, owners);
-    grant(config, meta, Permission.SUBMIT, admin, owners);
-
-    config.commitToNewRef(md, RefNames.REFS_CONFIG);
   }
 
   public static LabelType initCodeReviewLabel(ProjectConfig c) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 22a345a..b697519 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -28,6 +30,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -42,6 +46,7 @@
   private final GitRepositoryManager mgr;
   private final AllUsersName allUsersName;
   private final PersonIdent serverUser;
+  private final GroupReference registered;
 
   private GroupReference admin;
 
@@ -53,6 +58,7 @@
     this.mgr = mgr;
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
+    this.registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
   }
 
   public AllUsersCreator setAdministrators(GroupReference admin) {
@@ -75,29 +81,35 @@
 
   private void initAllUsers(Repository git)
       throws IOException, ConfigInvalidException {
-    MetaDataUpdate md = new MetaDataUpdate(
-        GitReferenceUpdated.DISABLED,
-        allUsersName,
-        git);
-    md.getCommitBuilder().setAuthor(serverUser);
-    md.getCommitBuilder().setCommitter(serverUser);
-    md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
+    try (MetaDataUpdate md = new MetaDataUpdate(
+          GitReferenceUpdated.DISABLED,
+          allUsersName,
+          git)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
 
-    ProjectConfig config = ProjectConfig.read(md);
-    Project project = config.getProject();
-    project.setDescription("Individual user settings and preferences.");
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
+      project.setDescription("Individual user settings and preferences.");
 
-    AccessSection all = config.getAccessSection(RefNames.REFS_USERS + "*", true);
-    all.getPermission(Permission.READ, true).setExclusiveGroup(true);
+      AccessSection users = config.getAccessSection(
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
+      grant(config, users, Permission.READ, false, true, registered);
+      grant(config, users, Permission.PUSH, false, true, registered);
+      grant(config, users, Permission.SUBMIT, false, true, registered);
+      grant(config, users, cr, -2, 2, registered);
 
-    AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
-    defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
-    grant(config, defaults, Permission.READ, admin);
-    defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
-    grant(config, defaults, Permission.PUSH, admin);
-    defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
-    grant(config, defaults, Permission.CREATE, admin);
+      AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
+      defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
+      grant(config, defaults, Permission.READ, admin);
+      defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
+      grant(config, defaults, Permission.PUSH, admin);
+      defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
+      grant(config, defaults, Permission.CREATE, admin);
 
-    config.commit(md);
+      config.commit(md);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
index 777b5b9..65843d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
@@ -35,5 +35,6 @@
      */
     bind(DataSourceType.class).annotatedWith(Names.named("maxdb")).to(MaxDb.class);
     bind(DataSourceType.class).annotatedWith(Names.named("sap db")).to(MaxDb.class);
+    bind(DataSourceType.class).annotatedWith(Names.named("hana")).to(HANA.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index ad6744f..69f4ba5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -20,9 +20,14 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.persistence.DataSourceInterceptor;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.jdbc.SimpleDataSource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -43,18 +48,22 @@
 @Singleton
 public class DataSourceProvider implements Provider<DataSource>,
     LifecycleListener {
-  public static final int DEFAULT_POOL_LIMIT = 8;
-
   private final Config cfg;
+  private final MetricMaker metrics;
   private final Context ctx;
   private final DataSourceType dst;
+  private final ThreadSettingsConfig threadSettingsConfig;
   private DataSource ds;
 
   @Inject
   protected DataSourceProvider(@GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      ThreadSettingsConfig threadSettingsConfig,
       Context ctx,
       DataSourceType dst) {
     this.cfg = cfg;
+    this.metrics = metrics;
+    this.threadSettingsConfig = threadSettingsConfig;
     this.ctx = ctx;
     this.dst = dst;
   }
@@ -82,7 +91,7 @@
     }
   }
 
-  public static enum Context {
+  public enum Context {
     SINGLE_USER, MULTI_USER
   }
 
@@ -120,35 +129,55 @@
       if (password != null && !password.isEmpty()) {
         ds.setPassword(password);
       }
-      ds.setMaxActive(cfg.getInt("database", "poollimit", DEFAULT_POOL_LIMIT));
+      int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
+      ds.setMaxActive(poolLimit);
       ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", 4));
+      ds.setMaxIdle(
+          cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
       ds.setMaxWait(ConfigUtil.getTimeUnit(cfg, "database", null,
           "poolmaxwait", MILLISECONDS.convert(30, SECONDS), MILLISECONDS));
       ds.setInitialSize(ds.getMinIdle());
       ds.setValidationQuery(dst.getValidationQuery());
       ds.setValidationQueryTimeout(5);
-
+      exportPoolMetrics(ds);
       return intercept(interceptor, ds);
 
-    } else {
-      // Don't use the connection pool.
-      //
-      try {
-        final Properties p = new Properties();
-        p.setProperty("driver", driver);
-        p.setProperty("url", url);
-        if (username != null) {
-          p.setProperty("user", username);
-        }
-        if (password != null) {
-          p.setProperty("password", password);
-        }
-        return intercept(interceptor, new SimpleDataSource(p));
-      } catch (SQLException se) {
-        throw new ProvisionException("Database unavailable", se);
-      }
     }
+    // Don't use the connection pool.
+    //
+    try {
+      final Properties p = new Properties();
+      p.setProperty("driver", driver);
+      p.setProperty("url", url);
+      if (username != null) {
+        p.setProperty("user", username);
+      }
+      if (password != null) {
+        p.setProperty("password", password);
+      }
+      return intercept(interceptor, new SimpleDataSource(p));
+    } catch (SQLException se) {
+      throw new ProvisionException("Database unavailable", se);
+    }
+  }
+
+  private void exportPoolMetrics(final BasicDataSource pool) {
+    final CallbackMetric1<Boolean, Integer> cnt = metrics.newCallbackMetric(
+        "sql/connection_pool/connections",
+        Integer.class,
+        new Description("SQL database connections")
+          .setGauge()
+          .setUnit("connections"),
+        Field.ofBoolean("active"));
+    metrics.newTrigger(cnt, new Runnable() {
+      @Override
+      public void run() {
+        synchronized (pool) {
+          cnt.set(true, pool.getNumActive());
+          cnt.set(false, pool.getNumIdle());
+        }
+      }
+    });
   }
 
   private DataSource intercept(String interceptor, DataSource ds) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
index 2651ee2..ee8ce81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
@@ -20,13 +20,13 @@
 /** Abstraction of a supported database platform */
 public interface DataSourceType {
 
-  public String getDriver();
+  String getDriver();
 
-  public String getUrl();
+  String getUrl();
 
-  public String getValidationQuery();
+  String getValidationQuery();
 
-  public boolean usePool();
+  boolean usePool();
 
   /**
    * Return a ScriptRunner that runs the index script. Must not return
@@ -34,5 +34,5 @@
    *
    * @throws IOException
    */
-  public ScriptRunner getIndexScript() throws IOException;
+  ScriptRunner getIndexScript() throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
index 199b3bc..9dee9f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
@@ -20,15 +20,22 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.jdbc.Database;
 import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Key;
 import com.google.inject.TypeLiteral;
 
 /** Loads the database with standard dependencies. */
 public class DatabaseModule extends FactoryModule {
   @Override
   protected void configure() {
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).to(
-        new TypeLiteral<Database<ReviewDb>>() {}).in(SINGLETON);
-    bind(new TypeLiteral<Database<ReviewDb>>() {}).toProvider(
-        ReviewDbDatabaseProvider.class).in(SINGLETON);
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    TypeLiteral<Database<ReviewDb>> database =
+        new TypeLiteral<Database<ReviewDb>>() {};
+
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class))
+        .to(database)
+        .in(SINGLETON);
+    bind(database).toProvider(ReviewDbDatabaseProvider.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
index 66f2f1d..7d64437 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
@@ -20,6 +20,8 @@
 
 import org.eclipse.jgit.lib.Config;
 
+import java.nio.file.Path;
+
 class H2 extends BaseDataSourceType {
 
   protected final Config cfg;
@@ -38,6 +40,30 @@
     if (database == null || database.isEmpty()) {
       database = "db/ReviewDB";
     }
-    return "jdbc:h2:" + site.resolve(database).toUri().toString();
+    return appendUrlOptions(cfg, createUrl(site.resolve(database)));
+  }
+
+  public static String createUrl(Path path) {
+    return new StringBuilder()
+        .append("jdbc:h2:")
+        .append(path.toUri().toString())
+        .toString();
+  }
+
+  public static String appendUrlOptions(Config cfg, String url) {
+    long h2CacheSize = cfg.getLong("database", "h2", "cacheSize", -1);
+    boolean h2AutoServer = cfg.getBoolean("database", "h2", "autoServer", false);
+
+    StringBuilder urlBuilder = new StringBuilder().append(url);
+
+    if (h2CacheSize >= 0) {
+      // H2 CACHE_SIZE is always given in KB
+      urlBuilder.append(";CACHE_SIZE=")
+          .append(h2CacheSize / 1024);
+    }
+    if (h2AutoServer) {
+      urlBuilder.append(";AUTO_SERVER=TRUE");
+    }
+    return urlBuilder.toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
new file mode 100644
index 0000000..d07115c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.sql.SQLException;
+
+@Singleton
+public class H2AccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+
+  @VisibleForTesting
+  public static class InMemoryModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      H2AccountPatchReviewStore inMemoryStore = new H2AccountPatchReviewStore();
+      DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+          .toInstance(inMemoryStore);
+      listener().toInstance(inMemoryStore);
+    }
+  }
+
+  @Inject
+  H2AccountPatchReviewStore(@GerritServerConfig Config cfg,
+      SitePaths sitePaths) {
+    super(cfg, sitePaths);
+  }
+
+  /**
+   * Creates an in-memory H2 database to store the reviewed flags.
+   * This should be used for tests only.
+   */
+  @VisibleForTesting
+  private H2AccountPatchReviewStore() {
+    // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is
+    // lost at the moment the last connection is closed. This option keeps the
+    // content as long as the vm lives.
+    super(createDataSource("jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1"));
+  }
+
+  @Override
+  public OrmException convertError(String op, SQLException err) {
+    switch (getSQLStateInt(err)) {
+      case 23001: // UNIQUE CONSTRAINT VIOLATION
+      case 23505: // DUPLICATE_KEY_1
+        return new OrmDuplicateKeyException("account_patch_reviews", err);
+
+      default:
+        if (err.getCause() == null && err.getNextException() != null) {
+          err.initCause(err.getNextException());
+        }
+        return new OrmException(op + " failure on account_patch_reviews", err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
new file mode 100644
index 0000000..44f1f0c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+
+class HANA extends BaseDataSourceType {
+
+  private Config cfg;
+
+  @Inject
+  HANA(@GerritServerConfig final Config cfg) {
+    super("com.sap.db.jdbc.Driver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    final StringBuilder b = new StringBuilder();
+    final ConfigSection dbs = new ConfigSection(cfg, "database");
+    b.append("jdbc:sap://");
+    b.append(hostname(dbs.required("hostname")));
+    int instance = Integer.parseInt(dbs.required("instance"));
+    String port = "3" + String.format("%02d", instance) + "15";
+    b.append(port(port));
+    return b.toString();
+  }
+
+  @Override
+  public ScriptRunner getIndexScript() throws IOException {
+    // HANA uses column tables and should not require additional indices
+    return ScriptRunner.NOOP;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
index 2c2051d..7cdf93e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
@@ -34,4 +34,4 @@
   public String getUrl() {
     return ConfigUtil.getRequired(cfg, "database", "url");
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
new file mode 100644
index 0000000..8809819
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -0,0 +1,325 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+
+import org.apache.commons.dbcp.BasicDataSource;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Collection;
+
+import javax.sql.DataSource;
+
+public abstract class JdbcAccountPatchReviewStore
+    implements AccountPatchReviewStore, LifecycleListener {
+  private static final Logger log =
+      LoggerFactory.getLogger(JdbcAccountPatchReviewStore.class);
+
+  public static class Module extends LifecycleModule {
+    private final Config cfg;
+
+    public Module(Config cfg) {
+      this.cfg = cfg;
+    }
+
+    @Override
+    protected void configure() {
+      String url = cfg.getString("accountPatchReviewDb", null, "url");
+      if (url == null || url.contains("h2")) {
+        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+            .to(H2AccountPatchReviewStore.class);
+        listener().to(H2AccountPatchReviewStore.class);
+      } else if (url.contains("postgresql")) {
+        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+            .to(PostgresqlAccountPatchReviewStore.class);
+        listener().to(PostgresqlAccountPatchReviewStore.class);
+      } else if (url.contains("mysql")) {
+        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+            .to(MysqlAccountPatchReviewStore.class);
+        listener().to(MysqlAccountPatchReviewStore.class);
+      } else {
+        throw new IllegalArgumentException(
+            "unsupported driver type for account patch reviews db: " + url);
+      }
+    }
+  }
+
+  private final DataSource ds;
+
+  public static JdbcAccountPatchReviewStore createAccountPatchReviewStore(
+      Config cfg, SitePaths sitePaths) {
+    String url = cfg.getString("accountPatchReviewDb", null, "url");
+    if (url == null || url.contains("h2")) {
+      return new H2AccountPatchReviewStore(cfg, sitePaths);
+    } else if (url.contains("postgresql")) {
+      return new PostgresqlAccountPatchReviewStore(cfg, sitePaths);
+    } else if (url.contains("mysql")) {
+      return new MysqlAccountPatchReviewStore(cfg, sitePaths);
+    } else {
+      throw new IllegalArgumentException(
+          "unsupported driver type for account patch reviews db: " + url);
+    }
+  }
+
+  protected JdbcAccountPatchReviewStore(Config cfg,
+      SitePaths sitePaths) {
+    this.ds = createDataSource(getUrl(cfg, sitePaths));
+  }
+
+  protected JdbcAccountPatchReviewStore(DataSource ds) {
+    this.ds = ds;
+  }
+
+  private static String getUrl(@GerritServerConfig Config cfg,
+      SitePaths sitePaths) {
+    String url = cfg.getString("accountPatchReviewDb", null, "url");
+    if (url == null) {
+      return H2.createUrl(sitePaths.db_dir.resolve("account_patch_reviews"));
+    }
+    return url;
+  }
+
+  protected static DataSource createDataSource(String url) {
+    BasicDataSource datasource = new BasicDataSource();
+    if (url.contains("postgresql")) {
+      datasource.setDriverClassName("org.postgresql.Driver");
+    } else if (url.contains("h2")) {
+      datasource.setDriverClassName("org.h2.Driver");
+    } else if (url.contains("mysql")) {
+      datasource.setDriverClassName("com.mysql.jdbc.Driver");
+    }
+    datasource.setUrl(url);
+    datasource.setMaxActive(50);
+    datasource.setMinIdle(4);
+    datasource.setMaxIdle(16);
+    long evictIdleTimeMs = 1000 * 60;
+    datasource.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+    datasource.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
+    return datasource;
+  }
+
+  @Override
+  public void start() {
+    try {
+      createTableIfNotExists();
+    } catch (OrmException e) {
+      log.error("Failed to create table to store account patch reviews", e);
+    }
+  }
+
+  public Connection getConnection() throws SQLException {
+    return ds.getConnection();
+  }
+
+  public void createTableIfNotExists() throws OrmException {
+    try (Connection con = ds.getConnection();
+        Statement stmt = con.createStatement()) {
+      doCreateTable(stmt);
+    } catch (SQLException e) {
+      throw convertError("create", e);
+    }
+  }
+
+  private static void doCreateTable(Statement stmt) throws SQLException {
+    stmt.executeUpdate(
+        "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
+            + "account_id INTEGER DEFAULT 0 NOT NULL, "
+            + "change_id INTEGER DEFAULT 0 NOT NULL, "
+            + "patch_set_id INTEGER DEFAULT 0 NOT NULL, "
+            + "file_name VARCHAR(4096) DEFAULT '' NOT NULL, "
+            + "CONSTRAINT primary_key_account_patch_reviews "
+            + "PRIMARY KEY (account_id, change_id, patch_set_id, file_name)"
+            + ")");
+  }
+
+  public void dropTableIfExists() throws OrmException {
+    try (Connection con = ds.getConnection();
+        Statement stmt = con.createStatement()) {
+      stmt.executeUpdate("DROP TABLE IF EXISTS account_patch_reviews");
+    } catch (SQLException e) {
+      throw convertError("create", e);
+    }
+  }
+
+  @Override
+  public void stop() {
+  }
+
+  @Override
+  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId,
+      String path) throws OrmException {
+    try (Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("INSERT INTO account_patch_reviews "
+                + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                + "(?, ?, ?, ?)")) {
+      stmt.setInt(1, accountId.get());
+      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(3, psId.get());
+      stmt.setString(4, path);
+      stmt.executeUpdate();
+      return true;
+    } catch (SQLException e) {
+      OrmException ormException = convertError("insert", e);
+      if (ormException instanceof OrmDuplicateKeyException) {
+        return false;
+      }
+      throw ormException;
+    }
+  }
+
+  @Override
+  public void markReviewed(PatchSet.Id psId, Account.Id accountId,
+      Collection<String> paths) throws OrmException {
+    if (paths == null || paths.isEmpty()) {
+      return;
+    }
+
+    try (Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("INSERT INTO account_patch_reviews "
+                + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                + "(?, ?, ?, ?)")) {
+      for (String path : paths) {
+        stmt.setInt(1, accountId.get());
+        stmt.setInt(2, psId.getParentKey().get());
+        stmt.setInt(3, psId.get());
+        stmt.setString(4, path);
+        stmt.addBatch();
+      }
+      stmt.executeBatch();
+    } catch (SQLException e) {
+      OrmException ormException = convertError("insert", e);
+      if (ormException instanceof OrmDuplicateKeyException) {
+        return;
+      }
+      throw ormException;
+    }
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException {
+    try (Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("DELETE FROM account_patch_reviews "
+                + "WHERE account_id = ? AND change_id = ? AND "
+                + "patch_set_id = ? AND file_name = ?")) {
+      stmt.setInt(1, accountId.get());
+      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(3, psId.get());
+      stmt.setString(4, path);
+      stmt.executeUpdate();
+    } catch (SQLException e) {
+      throw convertError("delete", e);
+    }
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId) throws OrmException {
+    try (Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("DELETE FROM account_patch_reviews "
+                + "WHERE change_id = ? AND patch_set_id = ?")) {
+      stmt.setInt(1, psId.getParentKey().get());
+      stmt.setInt(2, psId.get());
+      stmt.executeUpdate();
+    } catch (SQLException e) {
+      throw convertError("delete", e);
+    }
+  }
+
+  @Override
+  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId,
+      Account.Id accountId) throws OrmException {
+    try (Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement(
+                "SELECT patch_set_id, file_name FROM account_patch_reviews APR1 "
+                    + "WHERE account_id = ? AND change_id = ? AND patch_set_id = "
+                    + "(SELECT MAX(patch_set_id) FROM account_patch_reviews APR2 WHERE "
+                    + "APR1.account_id = APR2.account_id "
+                    + "AND APR1.change_id = APR2.change_id "
+                    + "AND patch_set_id <= ?)")) {
+      stmt.setInt(1, accountId.get());
+      stmt.setInt(2, psId.getParentKey().get());
+      stmt.setInt(3, psId.get());
+      try (ResultSet rs = stmt.executeQuery()) {
+        if (rs.next()) {
+          PatchSet.Id id = new PatchSet.Id(psId.getParentKey(),
+              rs.getInt("patch_set_id"));
+          ImmutableSet.Builder<String> builder = ImmutableSet.builder();
+          do {
+            builder.add(rs.getString("file_name"));
+          } while (rs.next());
+
+          return Optional.of(
+              AccountPatchReviewStore.PatchSetWithReviewedFiles.create(
+                  id, builder.build()));
+        }
+
+        return Optional.absent();
+      }
+    } catch (SQLException e) {
+      throw convertError("select", e);
+    }
+  }
+
+  public OrmException convertError(String op, SQLException err) {
+    if (err.getCause() == null && err.getNextException() != null) {
+      err.initCause(err.getNextException());
+    }
+    return new OrmException(op + " failure on account_patch_reviews", err);
+  }
+
+  private static String getSQLState(SQLException err) {
+    String ec;
+    SQLException next = err;
+    do {
+      ec = next.getSQLState();
+      next = next.getNextException();
+    } while (ec == null && next != null);
+    return ec;
+  }
+
+  protected static int getSQLStateInt(SQLException err) {
+    String s = getSQLState(err);
+    if (s != null) {
+      Integer i = Ints.tryParse(s);
+      return i != null ? i : -1;
+    }
+    return 0;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
index 7ef88f0..9a09746 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
@@ -29,7 +29,7 @@
   private Config cfg;
 
   @Inject
-  public MaxDb(@GerritServerConfig final Config cfg) {
+  MaxDb(@GerritServerConfig final Config cfg) {
     super("com.sap.dbtech.jdbc.DriverSapDB");
     this.cfg = cfg;
   }
@@ -49,4 +49,4 @@
   public ScriptRunner getIndexScript() throws IOException {
     return getScriptRunner("index_maxdb.sql");
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
index 308cec8..0b345e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
@@ -28,7 +28,7 @@
   private Config cfg;
 
   @Inject
-  public MySql(@GerritServerConfig final Config cfg) {
+  MySql(@GerritServerConfig final Config cfg) {
     super("com.mysql.jdbc.Driver");
     this.cfg = cfg;
   }
@@ -53,4 +53,4 @@
     // a new MySQL connection is usually very fast.
     return false;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
new file mode 100644
index 0000000..7cf28ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.sql.SQLException;
+
+@Singleton
+public class MysqlAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+
+  @Inject
+  MysqlAccountPatchReviewStore(@GerritServerConfig Config cfg,
+      SitePaths sitePaths) {
+    super(cfg, sitePaths);
+  }
+
+  @Override
+  public OrmException convertError(String op, SQLException err) {
+    switch (getSQLStateInt(err)) {
+      case 1022: // ER_DUP_KEY
+      case 1062: // ER_DUP_ENTRY
+      case 1169: // ER_DUP_UNIQUE;
+        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+
+
+      default:
+        if (err.getCause() == null && err.getNextException() != null) {
+          err.initCause(err.getNextException());
+        }
+        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
new file mode 100644
index 0000000..f38ddfa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.DisabledChangesReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
+  private final SchemaFactory<ReviewDb> delegate;
+  private final NotesMigration migration;
+
+  @Inject
+  NotesMigrationSchemaFactory(
+      @ReviewDbFactory SchemaFactory<ReviewDb> delegate,
+      NotesMigration migration) {
+    this.delegate = delegate;
+    this.migration = migration;
+  }
+
+  @Override
+  public ReviewDb open() throws OrmException {
+    ReviewDb db = delegate.open();
+    if (!migration.readChanges()) {
+      return db;
+    }
+    return new DisabledChangesReviewDbWrapper(db);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
index c58d0c2..3e3509e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
@@ -30,7 +30,7 @@
   private Config cfg;
 
   @Inject
-  public PostgreSQL(@GerritServerConfig final Config cfg) {
+  PostgreSQL(@GerritServerConfig final Config cfg) {
     super("org.postgresql.Driver");
     this.cfg = cfg;
   }
@@ -51,4 +51,4 @@
   public ScriptRunner getIndexScript() throws IOException {
     return getScriptRunner("index_postgres.sql");
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
new file mode 100644
index 0000000..c264c68
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.sql.SQLException;
+
+@Singleton
+public class PostgresqlAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+
+  @Inject
+  PostgresqlAccountPatchReviewStore(@GerritServerConfig Config cfg,
+      SitePaths sitePaths) {
+    super(cfg, sitePaths);
+  }
+
+  @Override
+  public OrmException convertError(String op, SQLException err) {
+    switch (getSQLStateInt(err)) {
+      case 23505: // DUPLICATE_KEY_1
+        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+
+      case 23514: // CHECK CONSTRAINT VIOLATION
+      case 23503: // FOREIGN KEY CONSTRAINT VIOLATION
+      case 23502: // NOT NULL CONSTRAINT VIOLATION
+      case 23001: // RESTRICT VIOLATION
+      default:
+        if (err.getCause() == null && err.getNextException() != null) {
+          err.initCause(err.getNextException());
+        }
+        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
new file mode 100644
index 0000000..3a63360
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on {@link com.google.gwtorm.server.SchemaFactory} implementation
+ * that talks to the underlying traditional {@link
+ * com.google.gerrit.reviewdb.server.ReviewDb} database.
+ * <p>
+ * During the migration to NoteDb, the actual {@code ReviewDb} will be a wrapper
+ * with certain tables enabled/disabled; this marker goes on the low-level
+ * implementation that has all tables.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface ReviewDbFactory {
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
index 3770b82..f23dabf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.config.GerritServerIdProvider;
 
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -45,5 +47,9 @@
 
     bind(String.class).annotatedWith(AnonymousCowardName.class).toProvider(
         AnonymousCowardNameProvider.class);
+
+    bind(String.class).annotatedWith(GerritServerId.class)
+      .toProvider(GerritServerIdProvider.class)
+      .in(SINGLETON);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index 148d1df..24022e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -17,10 +17,12 @@
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
@@ -34,6 +36,7 @@
 import com.google.inject.Stage;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
 import java.io.IOException;
@@ -70,6 +73,7 @@
         for (Key<?> k : new Key<?>[]{
             Key.get(PersonIdent.class, GerritPersonIdent.class),
             Key.get(String.class, AnonymousCowardName.class),
+            Key.get(Config.class, GerritServerConfig.class),
             }) {
           rebind(parent, k);
         }
@@ -92,7 +96,8 @@
   }
 
   public void update(final UpdateUI ui) throws OrmException {
-    try (ReviewDb db = schema.open()) {
+    try (ReviewDb db = ReviewDbUtil.unwrapDb(schema.open())) {
+
       final SchemaVersion u = updater.get();
       final CurrentSchemaVersion version = getSchemaVersion(db);
       if (version == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 27df9a5c..7217fd0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -26,13 +26,14 @@
 import java.sql.PreparedStatement;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_115> C = Schema_115.class;
+  public static final Class<Schema_129> C = Schema_129.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -80,7 +81,7 @@
     migrateData(pending, ui, curr, db);
 
     JdbcSchema s = (JdbcSchema) db;
-    final List<String> pruneList = Lists.newArrayList();
+    final List<String> pruneList = new ArrayList<>();
     s.pruneSchema(new StatementExecutor() {
       @Override
       public void execute(String sql) {
@@ -176,7 +177,7 @@
       throws OrmException {
     JdbcSchema s = (JdbcSchema) db;
     try (JdbcExecutor e = new JdbcExecutor(s)) {
-      s.renameField(e, table, from, to);
+      s.renameColumn(e, table, from, to);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
index ecdf28f..838706e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
@@ -33,9 +33,19 @@
 import java.io.File;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.SortedSet;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 
 public class Schema_106 extends SchemaVersion {
+  // we can use multiple threads per CPU as we can expect that threads will be
+  // waiting for IO
+  private static final int THREADS_PER_CPU = 4;
   private final GitRepositoryManager repoManager;
   private final PersonIdent serverUser;
 
@@ -60,19 +70,71 @@
 
     ui.message(String.format("creating reflog files for %s branches ...",
         RefNames.REFS_CONFIG));
+
+    ExecutorService executorPool = createExecutor(ui, repoList.size());
+    List<Future<Void>> futures = new ArrayList<>();
+
     for (Project.NameKey project : repoList) {
+      Callable<Void> callable = new ReflogCreator(project);
+      futures.add(executorPool.submit(callable));
+    }
+
+    executorPool.shutdown();
+    try {
+      for (Future<Void> future : futures) {
+        try {
+          future.get();
+        } catch (ExecutionException e) {
+          ui.message(e.getCause().getMessage());
+        }
+      }
+      ui.message("done");
+    } catch (InterruptedException ex) {
+      String msg = String.format(
+              "Migration step 106 was interrupted. "
+              + "Reflog created in %d of %d repositories only.",
+              countDone(futures), repoList.size());
+      ui.message(msg);
+    }
+  }
+
+  private static int countDone(List<Future<Void>> futures) {
+    int count = 0;
+    for (Future<Void> future : futures) {
+      if (future.isDone()) {
+        count++;
+      }
+    }
+
+    return count;
+  }
+
+  private ExecutorService createExecutor(UpdateUI ui, int repoCount) {
+    int procs = Runtime.getRuntime().availableProcessors();
+    int threads = Math.min(procs * THREADS_PER_CPU, repoCount);
+    ui.message(String.format("... using %d threads ...", threads));
+    return Executors.newFixedThreadPool(threads);
+  }
+
+  private class ReflogCreator implements Callable<Void> {
+    private final Project.NameKey project;
+
+    ReflogCreator(Project.NameKey project) {
+      this.project = project;
+    }
+
+    @Override
+    public Void call() throws IOException {
       try (Repository repo = repoManager.openRepository(project)) {
         File metaConfigLog =
             new File(repo.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
         if (metaConfigLog.exists()) {
-          continue;
+          return null;
         }
 
         if (!metaConfigLog.getParentFile().mkdirs()
             || !metaConfigLog.createNewFile()) {
-          throw new IOException(String.format(
-              "Failed to create reflog for %s in repository %s",
-              RefNames.REFS_CONFIG, project));
+          throw new IOException();
         }
 
         ObjectId metaConfigId = repo.resolve(RefNames.REFS_CONFIG);
@@ -89,11 +151,13 @@
             writer.println();
           }
         }
+        return null;
       } catch (IOException e) {
-        ui.message(String.format("ERROR: Failed to create reflog file for the"
-            + " %s branch in repository %s", RefNames.REFS_CONFIG, project.get()));
+        throw new IOException(String.format(
+            "ERROR: Failed to create reflog file for the"
+                + " %s branch in repository %s", RefNames.REFS_CONFIG,
+            project.get()));
       }
     }
-    ui.message("done");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
index 12a70eb..946ddcf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
@@ -29,6 +30,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -74,7 +76,7 @@
       try (Repository repo = repoManager.openRepository(e.getKey());
           RevWalk rw = new RevWalk(repo)) {
         updateProjectGroups(db, repo, rw, (Set<Change.Id>) e.getValue(), ui);
-      } catch (IOException err) {
+      } catch (IOException | NoSuchChangeException err) {
         throw new OrmException(err);
       }
       if (++i % 100 == 0) {
@@ -84,9 +86,9 @@
     ui.message("done");
   }
 
-  private static void updateProjectGroups(ReviewDb db, Repository repo,
-      RevWalk rw, Set<Change.Id> changes, UpdateUI ui)
-          throws OrmException, IOException {
+  private void updateProjectGroups(ReviewDb db, Repository repo, RevWalk rw,
+      Set<Change.Id> changes, UpdateUI ui)
+          throws OrmException, IOException, NoSuchChangeException {
     // Match sorting in ReceiveCommits.
     rw.reset();
     rw.sort(RevSort.TOPO);
@@ -119,7 +121,8 @@
       }
     }
 
-    GroupCollector collector = new GroupCollector(changeRefsBySha, db);
+    GroupCollector collector =
+        GroupCollector.createForSchemaUpgradeOnly(changeRefsBySha, db);
     RevCommit c;
     while ((c = rw.next()) != null) {
       collector.visit(c);
@@ -129,7 +132,8 @@
   }
 
   private static void updateGroups(ReviewDb db, GroupCollector collector,
-      Multimap<ObjectId, PatchSet.Id> patchSetsBySha) throws OrmException {
+      Multimap<ObjectId, PatchSet.Id> patchSetsBySha)
+          throws OrmException, NoSuchChangeException {
     Map<PatchSet.Id, PatchSet> patchSets =
         db.patchSets().toMap(db.patchSets().get(patchSetsBySha.values()));
     for (Map.Entry<ObjectId, Collection<String>> e
@@ -137,7 +141,7 @@
       for (PatchSet.Id psId : patchSetsBySha.get(e.getKey())) {
         PatchSet ps = patchSets.get(psId);
         if (ps != null) {
-          ps.setGroups(e.getValue());
+          ps.setGroups(ImmutableList.copyOf(e.getValue()));
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java
index 5e77c12..c5a6015 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_109.java
@@ -29,7 +29,7 @@
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (StatementExecutor e = newExecutor(db)) {
-      e.execute("UPDATE changes SET status = 'n' WHERE status = 's'");
+      e.execute("UPDATE changes SET status = 'n', created_on = created_on WHERE status = 's'");
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
index 3401a86..26cd3e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
@@ -153,22 +153,17 @@
     try (Repository git = mgr.openRepository(allUsersName);
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-
       for (Map.Entry<Account.Id, DiffPreferencesInfo> e : imports.entrySet()) {
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allUsersName, git, bru);
-        try {
+        try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allUsersName, git, bru)) {
           md.getCommitBuilder().setAuthor(serverUser);
           md.getCommitBuilder().setCommitter(serverUser);
-
           VersionedAccountPreferences p =
               VersionedAccountPreferences.forUser(e.getKey());
           p.load(md);
           storeSection(p.getConfig(), UserConfigSections.DIFF, null,
               e.getValue(), DiffPreferencesInfo.defaults());
           p.commit(md);
-        } finally {
-          md.close();
         }
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java
new file mode 100644
index 0000000..5b018a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_116 extends SchemaVersion {
+  @Inject
+  Schema_116(Provider<Schema_115> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
new file mode 100644
index 0000000..6176aeb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Set;
+
+public class Schema_117 extends SchemaVersion {
+  @Inject
+  Schema_117(Provider<Schema_116> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void preUpdateSchema(ReviewDb db)
+      throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    Connection connection = schema.getConnection();
+    String tableName = "patch_sets";
+    String oldColumnName = "push_certficate";
+    String newColumnName = "push_certificate";
+    Set<String> columns =
+        schema.getDialect().listColumns(connection, tableName);
+    if (columns.contains(oldColumnName)) {
+      renameColumn(db, tableName, oldColumnName, newColumnName);
+    }
+    try (Statement stmt = schema.getConnection().createStatement()) {
+      stmt.execute(
+          "ALTER TABLE " + tableName + " MODIFY " + newColumnName + " clob");
+    } catch (SQLException e) {
+      // Ignore.  Type may have already been modified manually.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_118.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_118.java
new file mode 100644
index 0000000..8c2c740
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_118.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_118 extends SchemaVersion {
+  @Inject
+  Schema_118(Provider<Schema_117> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
new file mode 100644
index 0000000..9fdec25
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
@@ -0,0 +1,236 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.ANON_GIT;
+import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.ANON_HTTP;
+import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.HTTP;
+import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.REPO_DOWNLOAD;
+import static com.google.gerrit.reviewdb.client.CoreDownloadSchemes.SSH;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class Schema_119 extends SchemaVersion {
+  private static final Map<String, String> LEGACY_DISPLAYNAME_MAP =
+      ImmutableMap.<String, String> of(
+          "ANON_GIT", ANON_GIT,
+          "ANON_HTTP", ANON_HTTP,
+          "HTTP", HTTP,
+          "SSH", SSH,
+          "REPO_DOWNLOAD", REPO_DOWNLOAD);
+
+  private final GitRepositoryManager mgr;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_119(Provider<Schema_118> prior,
+      GitRepositoryManager mgr,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.mgr = mgr;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui)
+      throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    Connection connection = schema.getConnection();
+    String tableName = "accounts";
+    String emailStrategy = "email_strategy";
+    Set<String> columns =
+        schema.getDialect().listColumns(connection, tableName);
+    Map<Account.Id, GeneralPreferencesInfo> imports = new HashMap<>();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs = stmt.executeQuery(
+          "select "
+          + "account_id, "
+          + "maximum_page_size, "
+          + "show_site_header, "
+          + "use_flash_clipboard, "
+          + "download_url, "
+          + "download_command, "
+          + (columns.contains(emailStrategy)
+              ? emailStrategy + ", "
+              : "copy_self_on_email, ")
+          + "date_format, "
+          + "time_format, "
+          + "relative_date_in_change_table, "
+          + "diff_view, "
+          + "size_bar_in_change_table, "
+          + "legacycid_in_change_table, "
+          + "review_category_strategy, "
+          + "mute_common_path_prefixes "
+          + "from " + tableName)) {
+        while (rs.next()) {
+          GeneralPreferencesInfo p =
+              new GeneralPreferencesInfo();
+          Account.Id accountId = new Account.Id(rs.getInt(1));
+          p.changesPerPage = (int)rs.getShort(2);
+          p.showSiteHeader = toBoolean(rs.getString(3));
+          p.useFlashClipboard = toBoolean(rs.getString(4));
+          p.downloadScheme = convertToModernNames(rs.getString(5));
+          p.downloadCommand = toDownloadCommand(rs.getString(6));
+          p.emailStrategy = toEmailStrategy(rs.getString(7),
+              columns.contains(emailStrategy));
+          p.dateFormat = toDateFormat(rs.getString(8));
+          p.timeFormat = toTimeFormat(rs.getString(9));
+          p.relativeDateInChangeTable = toBoolean(rs.getString(10));
+          p.diffView = toDiffView(rs.getString(11));
+          p.sizeBarInChangeTable = toBoolean(rs.getString(12));
+          p.legacycidInChangeTable = toBoolean(rs.getString(13));
+          p.reviewCategoryStrategy =
+              toReviewCategoryStrategy(rs.getString(14));
+          p.muteCommonPathPrefixes = toBoolean(rs.getString(15));
+          imports.put(accountId, p);
+        }
+    }
+
+    if (imports.isEmpty()) {
+      return;
+    }
+
+    try (Repository git = mgr.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      for (Map.Entry<Account.Id, GeneralPreferencesInfo> e
+          : imports.entrySet()) {
+        try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allUsersName, git, bru)) {
+          md.getCommitBuilder().setAuthor(serverUser);
+          md.getCommitBuilder().setCommitter(serverUser);
+          VersionedAccountPreferences p =
+              VersionedAccountPreferences.forUser(e.getKey());
+          p.load(md);
+          storeSection(p.getConfig(), UserConfigSections.GENERAL, null,
+              e.getValue(), GeneralPreferencesInfo.defaults());
+          p.commit(md);
+        }
+      }
+
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+
+  private String convertToModernNames(String s) {
+    return !Strings.isNullOrEmpty(s) && LEGACY_DISPLAYNAME_MAP.containsKey(s)
+        ? LEGACY_DISPLAYNAME_MAP.get(s)
+        : s;
+  }
+
+  private static DownloadCommand toDownloadCommand(String v) {
+    if (v == null) {
+      return DownloadCommand.CHECKOUT;
+    }
+    return DownloadCommand.valueOf(v);
+  }
+
+  private static DateFormat toDateFormat(String v) {
+    if (v == null) {
+      return DateFormat.STD;
+    }
+    return DateFormat.valueOf(v);
+  }
+
+  private static TimeFormat toTimeFormat(String v) {
+    if (v == null) {
+      return TimeFormat.HHMM_12;
+    }
+    return TimeFormat.valueOf(v);
+  }
+
+  private static DiffView toDiffView(String v) {
+    if (v == null) {
+      return DiffView.SIDE_BY_SIDE;
+    }
+    return DiffView.valueOf(v);
+  }
+
+  private static EmailStrategy toEmailStrategy(String v,
+      boolean emailStrategyColumnExists) throws OrmException {
+    if (v == null) {
+      return EmailStrategy.ENABLED;
+    }
+    if (emailStrategyColumnExists) {
+      return EmailStrategy.valueOf(v);
+    }
+    if (v.equals("N")) {
+      // EMAIL_STRATEGY='ENABLED' WHERE (COPY_SELF_ON_EMAIL='N')
+      return EmailStrategy.ENABLED;
+    } else if (v.equals("Y")) {
+      // EMAIL_STRATEGY='CC_ON_OWN_COMMENTS' WHERE (COPY_SELF_ON_EMAIL='Y')
+      return EmailStrategy.CC_ON_OWN_COMMENTS;
+    } else {
+      throw new OrmException(
+          "invalid value in accounts.copy_self_on_email: " + v);
+    }
+  }
+
+  private static ReviewCategoryStrategy toReviewCategoryStrategy(String v) {
+    if (v == null) {
+      return ReviewCategoryStrategy.NONE;
+    }
+    return ReviewCategoryStrategy.valueOf(v);
+  }
+
+  private static boolean toBoolean(String v) {
+    Preconditions.checkState(!Strings.isNullOrEmpty(v));
+    return v.equals("Y");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
new file mode 100644
index 0000000..fc1b0cd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
+
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+public class Schema_120 extends SchemaVersion {
+
+  private final GitRepositoryManager mgr;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_120(Provider<Schema_119> prior,
+      GitRepositoryManager mgr,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.mgr = mgr;
+    this.serverUser = serverUser;
+  }
+
+  private void allowSubmoduleSubscription(Branch.NameKey subbranch,
+      Branch.NameKey superBranch) throws OrmException {
+    try (Repository git = mgr.openRepository(subbranch.getParentKey());
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+          subbranch.getParentKey(), git, bru)) {
+        md.getCommitBuilder().setAuthor(serverUser);
+        md.getCommitBuilder().setCommitter(serverUser);
+        md.setMessage("Added superproject subscription during upgrade");
+        ProjectConfig pc = ProjectConfig.read(md);
+
+        SubscribeSection s = null;
+        for (SubscribeSection s1 : pc.getSubscribeSections(subbranch)) {
+          if (s1.getProject().equals(superBranch.getParentKey())) {
+            s = s1;
+          }
+        }
+        if (s == null) {
+          s = new SubscribeSection(superBranch.getParentKey());
+          pc.addSubscribeSection(s);
+        }
+        RefSpec newRefSpec = new RefSpec(subbranch.get() + ":" + superBranch.get());
+
+        if (!s.getMatchingRefSpecs().contains(newRefSpec)) {
+          // For the migration we use only exact RefSpecs, we're not trying to
+          // generalize it.
+          s.addMatchingRefSpec(newRefSpec);
+        }
+
+        pc.commit(md);
+      }
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    } catch (ConfigInvalidException | IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui)
+      throws OrmException, SQLException {
+    ui.message("Generating Superproject subscriptions table to submodule ACLs");
+
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs = stmt.executeQuery("SELECT "
+            + "super_project_project_name, "
+            + "super_project_branch_name, "
+            + "submodule_project_name, "
+            + "submodule_branch_name "
+            + "FROM submodule_subscriptions")) {
+      while (rs.next()) {
+        Project.NameKey superproject = new Project.NameKey(rs.getString(1));
+        Branch.NameKey superbranch = new Branch.NameKey(superproject,
+            rs.getString(2));
+
+        Project.NameKey submodule = new Project.NameKey(rs.getString(3));
+        Branch.NameKey subbranch = new Branch.NameKey(submodule,
+            rs.getString(4));
+
+        allowSubmoduleSubscription(subbranch, superbranch);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_121.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_121.java
new file mode 100644
index 0000000..31b42fb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_121.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_121 extends SchemaVersion {
+  @Inject
+  Schema_121(Provider<Schema_120> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_122.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_122.java
new file mode 100644
index 0000000..b5b799d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_122.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_122 extends SchemaVersion {
+  @Inject
+  Schema_122(Provider<Schema_121> prior) {
+    super(prior);
+  }
+
+  // Adds tag column
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
new file mode 100644
index 0000000..d698974
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Map;
+
+public class Schema_123 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_123(Provider<Schema_122> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui)
+      throws OrmException, SQLException {
+    Multimap<Account.Id, Change.Id> imports = ArrayListMultimap.create();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+      ResultSet rs = stmt.executeQuery(
+          "SELECT "
+          + "account_id, "
+          + "change_id "
+          + "FROM starred_changes")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        Change.Id changeId = new Change.Id(rs.getInt(2));
+        imports.put(accountId, changeId);
+      }
+    }
+
+    if (imports.isEmpty()) {
+      return;
+    }
+
+    try (Repository git = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      ObjectId id = StarredChangesUtil.writeLabels(git,
+          StarredChangesUtil.DEFAULT_LABELS);
+      for (Map.Entry<Account.Id, Change.Id> e : imports.entries()) {
+        bru.addCommand(new ReceiveCommand(ObjectId.zeroId(), id,
+            RefNames.refsStarredChanges(e.getValue(), e.getKey())));
+      }
+      bru.execute(rw, new TextProgressMonitor());
+    } catch (IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
new file mode 100644
index 0000000..16f0bcf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.base.Function;
+import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys.SimpleSshKeyCreator;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class Schema_124 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_124(Provider<Schema_123> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui)
+      throws OrmException, SQLException {
+    Multimap<Account.Id, AccountSshKey> imports = ArrayListMultimap.create();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+      ResultSet rs = stmt.executeQuery(
+          "SELECT "
+          + "account_id, "
+          + "seq, "
+          + "ssh_public_key, "
+          + "valid "
+          + "FROM account_ssh_keys")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        int seq = rs.getInt(2);
+        String sshPublicKey = rs.getString(3);
+        AccountSshKey key = new AccountSshKey(
+            new AccountSshKey.Id(accountId, seq), sshPublicKey);
+        boolean valid = toBoolean(rs.getString(4));
+        if (!valid) {
+          key.setInvalid();
+        }
+        imports.put(accountId, key);
+      }
+    }
+
+    if (imports.isEmpty()) {
+      return;
+    }
+
+    try (Repository git = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+
+      for (Map.Entry<Account.Id, Collection<AccountSshKey>> e : imports.asMap()
+          .entrySet()) {
+        try (MetaDataUpdate md = new MetaDataUpdate(
+                 GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
+          md.getCommitBuilder().setAuthor(serverUser);
+          md.getCommitBuilder().setCommitter(serverUser);
+
+          VersionedAuthorizedKeys authorizedKeys = new VersionedAuthorizedKeys(
+              new SimpleSshKeyCreator(), e.getKey());
+          authorizedKeys.load(md);
+          authorizedKeys.setKeys(fixInvalidSequenceNumbers(e.getValue()));
+          authorizedKeys.commit(md);
+        }
+      }
+
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+
+  private Collection<AccountSshKey> fixInvalidSequenceNumbers(
+      Collection<AccountSshKey> keys) {
+    Ordering<AccountSshKey> o =
+        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
+          @Override
+          public Integer apply(AccountSshKey sshKey) {
+            return sshKey.getKey().get();
+          }
+        });
+    List<AccountSshKey> fixedKeys = new ArrayList<>(keys);
+    AccountSshKey minKey = o.min(keys);
+    while (minKey.getKey().get() <= 0) {
+      AccountSshKey fixedKey = new AccountSshKey(
+          new AccountSshKey.Id(minKey.getKey().getParentKey(),
+              Math.max(o.max(keys).getKey().get() + 1, 1)),
+          minKey.getSshPublicKey());
+      Collections.replaceAll(fixedKeys, minKey, fixedKey);
+      minKey = o.min(fixedKeys);
+    }
+    return fixedKeys;
+  }
+
+  private static boolean toBoolean(String v) {
+    return !Strings.isNullOrEmpty(v) && v.equalsIgnoreCase("Y");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
new file mode 100644
index 0000000..714eb69d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Schema_125 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Assign default permissions on user branches\n" +
+      "\n" +
+      "By default each user should be able to read and update the own user\n" +
+      "branch. Also the user should be able to approve and submit changes for\n" +
+      "the own user branch. Assign default permissions for this and remove the\n" +
+      "old exclusive read protection from the user branches.\n";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final AllProjectsName allProjectsName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_125(Provider<Schema_124> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      AllProjectsName allProjectsName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.allProjectsName = allProjectsName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allUsersName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      config.getAccessSection(RefNames.REFS_USERS + "*", true)
+          .remove(new Permission(Permission.READ));
+      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      AccessSection users = config.getAccessSection(
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      grant(config, users, Permission.READ, true, registered);
+      grant(config, users, Permission.PUSH, true, registered);
+      grant(config, users, Permission.SUBMIT, true, registered);
+
+      for (LabelType lt : getLabelTypes(config)) {
+        if ("Code-Review".equals(lt.getName())
+            || "Verified".equals(lt.getName())) {
+          grant(config, users, lt, lt.getMin().getValue(),
+              lt.getMax().getValue(), registered);
+        }
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+
+  private Collection<LabelType> getLabelTypes(ProjectConfig config)
+      throws IOException, ConfigInvalidException {
+    Map<String, LabelType> labelTypes =
+        new HashMap<>(config.getLabelSections());
+    Project.NameKey parent = config.getProject().getParent(allProjectsName);
+    while (parent != null) {
+      try (Repository git = repoManager.openRepository(parent);
+          MetaDataUpdate md =
+              new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
+        ProjectConfig parentConfig = ProjectConfig.read(md);
+        for (LabelType lt : parentConfig.getLabelSections().values()) {
+          if (!labelTypes.containsKey(lt.getName())) {
+            labelTypes.put(lt.getName(), lt);
+          }
+        }
+        parent = parentConfig.getProject().getParent(allProjectsName);
+      }
+    }
+    return labelTypes.values();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
new file mode 100644
index 0000000..50c518b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+public class Schema_126 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Fix default permissions on user branches";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_126(Provider<Schema_125> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allUsersName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      String refsUsersShardedId =
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
+      config.remove(config.getAccessSection(refsUsersShardedId));
+
+      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      AccessSection users = config.getAccessSection(refsUsersShardedId, true);
+      grant(config, users, Permission.READ, false, true, registered);
+      grant(config, users, Permission.PUSH, false, true, registered);
+      grant(config, users, Permission.SUBMIT, false, true, registered);
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
new file mode 100644
index 0000000..cc2b0b2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+public class Schema_127 extends SchemaVersion {
+  private static final int MAX_BATCH_SIZE = 1000;
+
+  private final SitePaths sitePaths;
+  private final Config cfg;
+
+  @Inject
+  Schema_127(Provider<Schema_126> prior,
+      SitePaths sitePaths,
+      @GerritServerConfig Config cfg) {
+    super(prior);
+    this.sitePaths = sitePaths;
+    this.cfg = cfg;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    JdbcAccountPatchReviewStore jdbcAccountPatchReviewStore =
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(cfg, sitePaths);
+    jdbcAccountPatchReviewStore.dropTableIfExists();
+    jdbcAccountPatchReviewStore.createTableIfNotExists();
+    try (Connection con = jdbcAccountPatchReviewStore.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("INSERT INTO account_patch_reviews "
+                + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                + "(?, ?, ?, ?)")) {
+      int batchCount = 0;
+
+      try (Statement s = newStatement(db);
+        ResultSet rs = s.executeQuery("SELECT * from account_patch_reviews")) {
+        while (rs.next()) {
+          stmt.setInt(1, rs.getInt("account_id"));
+          stmt.setInt(2, rs.getInt("change_id"));
+          stmt.setInt(3, rs.getInt("patch_set_id"));
+          stmt.setString(4, rs.getString("file_name"));
+          stmt.addBatch();
+          batchCount++;
+          if (batchCount >= MAX_BATCH_SIZE) {
+            stmt.executeBatch();
+            batchCount = 0;
+          }
+        }
+      }
+      if (batchCount > 0) {
+        stmt.executeBatch();
+      }
+    } catch (SQLException e) {
+      throw jdbcAccountPatchReviewStore.convertError("insert", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
new file mode 100644
index 0000000..a7f57b6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+public class Schema_128 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Add addPatchSet permission to all projects";
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_128(Provider<Schema_127> prior,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allProjectsName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allProjectsName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      AccessSection refsFor = config.getAccessSection("refs/for/*", true);
+      grant(config, refsFor, Permission.ADD_PATCH_SET,
+          false, false, registered);
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
new file mode 100644
index 0000000..de02ec7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.sql.SQLException;
+import java.sql.Statement;
+
+public class Schema_129 extends SchemaVersion {
+
+  @Inject
+  Schema_129(Provider<Schema_128> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void preUpdateSchema(ReviewDb db) throws OrmException {
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement()) {
+      stmt.execute("ALTER TABLE patch_sets MODIFY groups clob");
+    } catch (SQLException e) {
+      // Ignore.  Type may have already been modified manually.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index a2693e0..c8190a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -19,6 +19,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.LockFile;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -27,20 +28,26 @@
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Singleton
 public class DefaultSecureStore extends SecureStore {
   private final FileBasedConfig sec;
+  private final Map<String, FileBasedConfig> pluginSec;
+  private final SitePaths site;
 
   @Inject
   DefaultSecureStore(SitePaths site) {
+    this.site = site;
     sec = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED);
     try {
       sec.load();
-    } catch (Exception e) {
+    } catch (IOException | ConfigInvalidException e) {
       throw new RuntimeException("Cannot load secure.config", e);
     }
+    this.pluginSec = new HashMap<>();
   }
 
   @Override
@@ -49,6 +56,28 @@
   }
 
   @Override
+  public synchronized String[] getListForPlugin(String pluginName, String section,
+    String subsection, String name) {
+    FileBasedConfig cfg = null;
+    if (pluginSec.containsKey(pluginName)) {
+      cfg = pluginSec.get(pluginName);
+    } else {
+      String filename = pluginName + ".secure.config";
+      File pluginConfigFile = site.etc_dir.resolve(filename).toFile();
+      if (pluginConfigFile.exists()) {
+        cfg = new FileBasedConfig(pluginConfigFile, FS.DETECTED);
+        try {
+          cfg.load();
+          pluginSec.put(pluginName, cfg);
+        } catch (IOException | ConfigInvalidException e) {
+          throw new RuntimeException("Cannot load " + filename, e);
+        }
+      }
+    }
+    return cfg != null ? cfg.getStringList(section, subsection, name) : null;
+  }
+
+  @Override
   public void setList(String section, String subsection, String name,
       List<String> values) {
     if (values != null) {
@@ -93,7 +122,7 @@
     if (FileUtil.modified(sec)) {
       final byte[] out = Constants.encode(sec.toText());
       final File path = sec.getFile();
-      final LockFile lf = new LockFile(path, FS.DETECTED);
+      final LockFile lf = new LockFile(path);
       if (!lf.lock()) {
         throw new IOException("Cannot lock " + path);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
index 2a0086e..122e26b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -75,6 +75,38 @@
   }
 
   /**
+   * Extract decrypted value of stored plugin config property from SecureStore
+   * or {@code null} when property was not found.
+   *
+   * @param pluginName
+   * @param section
+   * @param subsection
+   * @param name
+   * @return decrypted String value or {@code null} if not found
+   */
+  public final String getForPlugin(String pluginName, String section,
+      String subsection, String name) {
+    String[] values = getListForPlugin(pluginName, section, subsection, name);
+    if (values != null && values.length > 0) {
+      return values[0];
+    }
+    return null;
+  }
+
+  /**
+   * Extract list of plugin config values from SecureStore and decrypt every
+   * value in that list, or {@code null} when property was not found.
+   *
+   * @param pluginName
+   * @param section
+   * @param subsection
+   * @param name
+   * @return decrypted list of string values or {@code null}
+   */
+  public abstract String[] getListForPlugin(String pluginName, String section,
+      String subsection, String name);
+
+  /**
    * Extract list of values from SecureStore and decrypt every value in that
    * list or {@code null} when property was not found.
    *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
index 0957594..f3250d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
@@ -18,15 +18,17 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 
-
-public class NoSshKeyCache implements SshKeyCache {
+@Singleton
+public class NoSshKeyCache implements SshKeyCache, SshKeyCreator {
 
   public static Module module() {
     return new AbstractModule() {
       @Override
       protected void configure() {
         bind(SshKeyCache.class).to(NoSshKeyCache.class);
+        bind(SshKeyCreator.class).to(NoSshKeyCache.class);
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 36e7e8c..f768c5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -27,6 +27,7 @@
 
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
@@ -81,8 +82,8 @@
     if (want.length > 0) {
       return Arrays.asList(want);
     }
-    List<InetSocketAddress> pub = Lists.newArrayList();
-    List<InetSocketAddress> local = Lists.newArrayList();
+    List<InetSocketAddress> pub = new ArrayList<>();
+    List<InetSocketAddress> local = new ArrayList<>();
 
     for (SocketAddress addr : listen) {
       if (addr instanceof InetSocketAddress) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
index 13c5703..fd8e1b9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
@@ -14,13 +14,7 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.google.gerrit.common.errors.InvalidSshKeyException;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-
 /** Permits controlling the contents of the SSH key cache area. */
 public interface SshKeyCache {
-  public void evict(String username);
-
-  public AccountSshKey create(AccountSshKey.Id id, String encoded)
-      throws InvalidSshKeyException;
+  void evict(String username);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
new file mode 100644
index 0000000..fd0c69c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.ssh;
+
+import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+
+public interface SshKeyCreator {
+  AccountSshKey create(AccountSshKey.Id id, String encoded)
+      throws InvalidSshKeyException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java b/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
index 4810b3d..9e48aad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -143,7 +143,7 @@
 
   /** A file served out of the tools root directory. */
   public static class Entry {
-    public static enum Type {
+    public enum Type {
       DIR, FILE
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
new file mode 100644
index 0000000..2d1e1fa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+public class GitUtil {
+
+  /**
+   * @param git
+   * @param commitId
+   * @param parentNum
+   * @return the {@code paretNo} parent of given commit or {@code null}
+   *             when {@code parentNo} exceed number of {@code commitId} parents.
+   * @throws IncorrectObjectTypeException
+   *             the supplied id is not a commit or an annotated tag.
+   * @throws IOException
+   *             a pack file or loose object could not be read.
+   */
+  public static RevCommit getParent(Repository git,
+      ObjectId commitId, int parentNum) throws IOException {
+    try (RevWalk walk = new RevWalk(git)) {
+      RevCommit commit = walk.parseCommit(commitId);
+      if (commit.getParentCount() > parentNum) {
+        return commit.getParent(parentNum);
+      }
+    }
+    return null;
+  }
+
+  private GitUtil() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
index 0b2efd8..1568228 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -29,6 +28,7 @@
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.net.SocketAddress;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Callable;
 
@@ -52,9 +52,13 @@
   /**
    * @see RequestScopePropagator#wrap(Callable)
    */
+  // ServletScopes#continueRequest is deprecated, but it's not obvious their
+  // recommended replacement is an appropriate drop-in solution; see
+  // https://gerrit-review.googlesource.com/83971
+  @SuppressWarnings("deprecation")
   @Override
   protected <T> Callable<T> wrapImpl(Callable<T> callable) {
-    Map<Key<?>, Object> seedMap = Maps.newHashMap();
+    Map<Key<?>, Object> seedMap = new HashMap<>();
 
     // Request scopes appear to use specific keys in their map, instead of only
     // providers. Add bindings for both the key to the instance directly and the
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
index c585270..86b3b7364 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
@@ -21,11 +21,11 @@
   private static final boolean win32 = computeWin32();
 
   /** @return true if this JVM is running on a Windows platform. */
-  public static final boolean isWin32() {
+  public static boolean isWin32() {
     return win32;
   }
 
-  private static final boolean computeWin32() {
+  private static boolean computeWin32() {
     final String osDotName =
         AccessController.doPrivileged(new PrivilegedAction<String>() {
           @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
index 2e01613..fab0b34 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
@@ -82,9 +82,8 @@
   public String formatWithEquals() {
     if (value() <= (short) 0) {
       return label() + '=' + value();
-    } else {
-      return label() + "=+" + value();
     }
+    return label() + "=+" + value();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
index 1cb180b..159763c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.util;
 
 import com.google.gerrit.common.data.RefConfigSection;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.RefPattern;
 
 import org.apache.commons.lang.StringUtils;
 
@@ -67,7 +67,7 @@
         cmp = -1;
       } else if (!p1_finite && p2_finite) {
         cmp = 1;
-      } else /* if (f1 == f2) */{
+      } else /* if (f1 == f2) */ {
         cmp = 0;
       }
     }
@@ -82,8 +82,8 @@
 
   private int distance(String pattern) {
     String example;
-    if (RefControl.isRE(pattern)) {
-      example = RefControl.shortestExample(pattern);
+    if (RefPattern.isRE(pattern)) {
+      example = RefPattern.shortestExample(pattern);
 
     } else if (pattern.endsWith("/*")) {
       example = pattern;
@@ -98,8 +98,8 @@
   }
 
   private boolean finite(String pattern) {
-    if (RefControl.isRE(pattern)) {
-      return RefControl.toRegExp(pattern).toAutomaton().isFinite();
+    if (RefPattern.isRE(pattern)) {
+      return RefPattern.toRegExp(pattern).toAutomaton().isFinite();
 
     } else if (pattern.endsWith("/*")) {
       return false;
@@ -110,8 +110,8 @@
   }
 
   private int transitions(String pattern) {
-    if (RefControl.isRE(pattern)) {
-      return RefControl.toRegExp(pattern).toAutomaton()
+    if (RefPattern.isRE(pattern)) {
+      return RefPattern.toRegExp(pattern).toAutomaton()
           .getNumberOfTransitions();
 
     } else if (pattern.endsWith("/*")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
index 6feb182..f4719aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.util;
 
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -27,25 +29,34 @@
  * Each call to {@link #open()} opens a new {@link ReviewDb}, so this class
  * should only be used in a bounded try/finally block.
  * <p>
- * The user in the request context is {@link InternalUser}.
+ * The user in the request context is {@link InternalUser} or the
+ * {@link IdentifiedUser} associated to the userId passed as parameter.
  */
 @Singleton
 public class OneOffRequestContext {
   private final InternalUser.Factory userFactory;
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
   OneOffRequestContext(InternalUser.Factory userFactory,
       SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext requestContext) {
+      ThreadLocalRequestContext requestContext,
+      IdentifiedUser.GenericFactory identifiedUserFactory) {
     this.userFactory = userFactory;
     this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
+    this.identifiedUserFactory = identifiedUserFactory;
   }
 
   public ManualRequestContext open() throws OrmException {
     return new ManualRequestContext(userFactory.create(),
         schemaFactory, requestContext);
   }
+
+  public ManualRequestContext openAs(Account.Id userId) throws OrmException {
+    return new ManualRequestContext(identifiedUserFactory.create(userId),
+        schemaFactory, requestContext);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
new file mode 100644
index 0000000..4f43c2a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/** Unique identifier for an end-user request, used in logs and similar. */
+public class RequestId {
+  private static final String MACHINE_ID;
+  static {
+    String id;
+    try {
+      id = InetAddress.getLocalHost().getHostAddress();
+    } catch (UnknownHostException e) {
+      id = "unknown";
+    }
+    MACHINE_ID = id;
+  }
+
+  public static RequestId forChange(Change c) {
+    return new RequestId(c.getId().toString());
+  }
+
+  public static RequestId forProject(Project.NameKey p) {
+    return new RequestId(p.toString());
+  }
+
+  private final String str;
+
+  private RequestId(String resourceId) {
+    Hasher h = Hashing.sha1().newHasher();
+    h.putLong(Thread.currentThread().getId())
+        .putUnencodedChars(MACHINE_ID);
+    str = "[" + resourceId + "-" + TimeUtil.nowTs().getTime() +
+        "-" + h.hash().toString().substring(0, 8) + "]";
+  }
+
+  @Override
+  public String toString() {
+    return str;
+  }
+
+  public String toStringForStorage() {
+    return str.substring(1, str.length() - 1);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index d1cf47c..13142fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -85,6 +85,7 @@
    * @param callable the Callable to wrap.
    * @return a new Callable which will execute in the current request scope.
    */
+  @SuppressWarnings("javadoc") // See GuiceRequestScopePropagator#wrapImpl
   public final <T> Callable<T> wrap(final Callable<T> callable) {
     final RequestContext callerContext = checkNotNull(local.getContext());
     final Callable<T> wrapped =
@@ -94,9 +95,8 @@
       public T call() throws Exception {
         if (callerContext == local.getContext()) {
           return callable.call();
-        } else {
-          return wrapped.call();
         }
+        return wrapped.call();
       }
 
       @Override
@@ -155,25 +155,24 @@
           return runnable.toString();
         }
       };
-    } else {
-      return new Runnable() {
-        @Override
-        public void run() {
-          try {
-            wrapped.call();
-          } catch (RuntimeException e) {
-            throw e;
-          } catch (Exception e) {
-            throw new RuntimeException(e); // Not possible.
-          }
-        }
-
-        @Override
-        public String toString() {
-          return runnable.toString();
-        }
-      };
     }
+    return new Runnable() {
+      @Override
+      public void run() {
+        try {
+          wrapped.call();
+        } catch (RuntimeException e) {
+          throw e;
+        } catch (Exception e) {
+          throw new RuntimeException(e); // Not possible.
+        }
+      }
+
+      @Override
+      public String toString() {
+        return runnable.toString();
+      }
+    };
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
index 3d58393..4991c58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
@@ -105,9 +105,8 @@
 
     if ("*".equals(hostStr)) {
       return new InetSocketAddress(port);
-    } else {
-      return InetSocketAddress.createUnresolved(hostStr, port);
     }
+    return InetSocketAddress.createUnresolved(hostStr, port);
   }
 
   /** Parse and resolve an address string, looking up the IP address. */
@@ -117,13 +116,12 @@
     if (addr.getAddress() != null && addr.getAddress().isAnyLocalAddress()) {
       return addr;
 
-    } else {
-      try {
-        final InetAddress host = InetAddress.getByName(addr.getHostName());
-        return new InetSocketAddress(host, addr.getPort());
-      } catch (UnknownHostException e) {
-        throw new IllegalArgumentException("unknown host: " + desc, e);
-      }
+    }
+    try {
+      final InetAddress host = InetAddress.getByName(addr.getHostName());
+      return new InetSocketAddress(host, addr.getPort());
+    } catch (UnknownHostException e) {
+      throw new IllegalArgumentException("unknown host: " + desc, e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
index ad2ab90..6b5c991 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
@@ -14,19 +14,16 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 
-import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.HashSet;
 import java.util.Set;
 
 /**
@@ -48,29 +45,20 @@
  */
 public class SubmoduleSectionParser {
 
-  public interface Factory {
-    SubmoduleSectionParser create(BlobBasedConfig bbc, String thisServer,
-        Branch.NameKey superProjectBranch);
-  }
-
-  private final ProjectCache projectCache;
-  private final BlobBasedConfig bbc;
-  private final String thisServer;
+  private final Config bbc;
+  private final String canonicalWebUrl;
   private final Branch.NameKey superProjectBranch;
 
-  @Inject
-  public SubmoduleSectionParser(ProjectCache projectCache,
-      @Assisted BlobBasedConfig bbc,
-      @Assisted String thisServer,
-      @Assisted Branch.NameKey superProjectBranch) {
-    this.projectCache = projectCache;
+  public SubmoduleSectionParser(Config bbc,
+      String canonicalWebUrl,
+      Branch.NameKey superProjectBranch) {
     this.bbc = bbc;
-    this.thisServer = thisServer;
+    this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
   }
 
   public Set<SubmoduleSubscription> parseAllSections() {
-    Set<SubmoduleSubscription> parsedSubscriptions = Sets.newHashSet();
+    Set<SubmoduleSubscription> parsedSubscriptions = new HashSet<>();
     for (final String id : bbc.getSubsections("submodule")) {
       final SubmoduleSubscription subscription = parse(id);
       if (subscription != null) {
@@ -84,51 +72,74 @@
     final String url = bbc.getString("submodule", id, "url");
     final String path = bbc.getString("submodule", id, "path");
     String branch = bbc.getString("submodule", id, "branch");
-    SubmoduleSubscription ss = null;
 
     try {
       if (url != null && url.length() > 0 && path != null && path.length() > 0
           && branch != null && branch.length() > 0) {
         // All required fields filled.
+        String project;
 
-        boolean urlIsRelative = url.startsWith("../");
-        String server = null;
-        if (!urlIsRelative) {
+        if (branch.equals(".")) {
+          branch = superProjectBranch.get();
+        }
+
+        // relative URL
+        if (url.startsWith("../")) {
+          // prefix with a slash for easier relative path walks
+          project = '/' + superProjectBranch.getParentKey().get();
+          String hostPart = url;
+          while (hostPart.startsWith("../")) {
+            int lastSlash = project.lastIndexOf('/');
+            if (lastSlash < 0) {
+              // too many levels up, ignore for now
+              return null;
+            }
+            project = project.substring(0, lastSlash);
+            hostPart = hostPart.substring(3);
+          }
+          project = project + "/" + hostPart;
+
+          // remove leading '/'
+          project = project.substring(1);
+        } else {
           // It is actually an URI. It could be ssh://localhost/project-a.
-          server = new URI(url).getHost();
-        }
-        if ((urlIsRelative)
-            || (server != null && server.equalsIgnoreCase(thisServer))) {
-          // Subscription really related to this running server.
-          if (branch.equals(".")) {
-            branch = superProjectBranch.get();
+          URI targetServerURI = new URI(url);
+          URI thisServerURI = new URI(canonicalWebUrl);
+          String thisHost = thisServerURI.getHost();
+          String targetHost = targetServerURI.getHost();
+          if (thisHost == null || targetHost == null ||
+              !targetHost.equalsIgnoreCase(thisHost)) {
+            return null;
           }
-
-          final String urlExtractedPath = new URI(url).getPath();
-          String projectName;
-          int fromIndex = urlExtractedPath.length() - 1;
-          while (fromIndex > 0) {
-            fromIndex = urlExtractedPath.lastIndexOf('/', fromIndex - 1);
-            projectName = urlExtractedPath.substring(fromIndex + 1);
-
-            if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
-              projectName = projectName.substring(0, //
-                  projectName.length() - Constants.DOT_GIT_EXT.length());
-            }
-            Project.NameKey projectKey = new Project.NameKey(projectName);
-            if (projectCache.get(projectKey) != null) {
-              ss = new SubmoduleSubscription(
-                  superProjectBranch,
-                  new Branch.NameKey(new Project.NameKey(projectName), branch),
-                  path);
-            }
+          String p1 = targetServerURI.getPath();
+          String p2 = thisServerURI.getPath();
+          if (!p1.startsWith(p2)) {
+            // When we are running the server at
+            // http://server/my-gerrit/ but the subscription is for
+            // http://server/other-teams-gerrit/
+            return null;
           }
+          // skip common part
+          project = p1.substring(p2.length());
         }
+
+        while (project.startsWith("/")) {
+          project = project.substring(1);
+        }
+
+        if (project.endsWith(Constants.DOT_GIT_EXT)) {
+          project = project.substring(0, //
+              project.length() - Constants.DOT_GIT_EXT.length());
+        }
+        Project.NameKey projectKey = new Project.NameKey(project);
+        return new SubmoduleSubscription(
+            superProjectBranch,
+            new Branch.NameKey(projectKey, branch),
+            path);
       }
     } catch (URISyntaxException e) {
       // Error in url syntax (in fact it is uri syntax)
     }
-
-    return ss;
+    return null;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
index 1c68a75..883f972 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -19,10 +19,10 @@
 
 public class TreeFormatter {
 
-  public static interface TreeNode {
-    public String getDisplayName();
-    public boolean isVisible();
-    public SortedSet<? extends TreeNode> getChildren();
+  public interface TreeNode {
+    String getDisplayName();
+    boolean isVisible();
+    SortedSet<? extends TreeNode> getChildren();
   }
 
   public static final String NOT_VISIBLE_NODE = "(x)";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
index 11db3ee..03bdf37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
@@ -30,6 +30,6 @@
    * @param args arguments for the group creation
    * @throws ValidationException if validation fails
    */
-  public void validateNewGroup(CreateGroupArgs args)
+  void validateNewGroup(CreateGroupArgs args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
index c1d509e..1baab7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
@@ -32,6 +32,6 @@
    * @param toRemove the hashtags to be removed
    * @throws ValidationException if validation fails
    */
-  public void validateHashtags(Change change, Set<String> toAdd,
+  void validateHashtags(Change change, Set<String> toAdd,
       Set<String> toRemove) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index 5b3f158..b2899c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -29,7 +29,7 @@
   /**
    * Arguments supplied to validateOutgoingEmail.
    */
-  public static class Args {
+  class Args {
     // in arguments
     public String messageClass;
 
@@ -55,6 +55,6 @@
    * @param args E-mail properties. Some are mutable.
    * @throws ValidationException if validation fails.
    */
-  public void validateOutgoingEmail(OutgoingEmailValidationListener.Args args)
+  void validateOutgoingEmail(OutgoingEmailValidationListener.Args args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
index d3c69c1..6012328 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
@@ -30,6 +30,6 @@
    * @param args arguments for the project creation
    * @throws ValidationException if validation fails
    */
-  public void validateNewProject(CreateProjectArgs args)
+  void validateNewProject(CreateProjectArgs args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java b/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
index 32713d1..a91bead 100644
--- a/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -75,4 +75,4 @@
     }
     return cont;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
index 59c4c18..9ef68f5 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
@@ -34,4 +34,4 @@
     UserIdentity author = psInfo.getAuthor();
     return exec(engine, author);
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
index 77a668b..d73ed9b 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
@@ -34,4 +34,4 @@
     UserIdentity committer = psInfo.getCommitter();
     return exec(engine, committer);
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java b/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
index 893c5bc..8fcb98c 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
@@ -175,4 +175,4 @@
     }
     throw new IllegalArgumentException("ChangeType not recognized");
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java b/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
index 6e1dc91..6fc1c2f 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
@@ -50,4 +50,4 @@
     }
     return cont;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
index 4f665ee..1dbdb68 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
@@ -48,13 +48,13 @@
     Term a3 = arg3.dereference();
 
     PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    if(!a1.unify(new IntegerTerm(pl.getPatches().size() -1),engine.trail)) { //Account for /COMMIT_MSG.
+    if (!a1.unify(new IntegerTerm(pl.getPatches().size() - 1),engine.trail)) { //Account for /COMMIT_MSG.
       return engine.fail();
     }
-    if(!a2.unify(new IntegerTerm(pl.getInsertions()),engine.trail)) {
+    if (!a2.unify(new IntegerTerm(pl.getInsertions()),engine.trail)) {
       return engine.fail();
     }
-    if(!a3.unify(new IntegerTerm(pl.getDeletions()),engine.trail)) {
+    if (!a3.unify(new IntegerTerm(pl.getDeletions()),engine.trail)) {
       return engine.fail();
     }
     return cont;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java b/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
index 3ee8d82..87c7138 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
@@ -17,12 +17,10 @@
 import static com.googlecode.prolog_cafe.lang.SymbolTerm.intern;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.rules.PrologEnvironment;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.util.Providers;
 
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
@@ -90,14 +88,8 @@
       Account.Id accountId = new Account.Id(((IntegerTerm) idTerm).intValue());
       user = cache.get(accountId);
       if (user == null) {
-        ReviewDb db = StoredValues.REVIEW_DB.getOrNull(engine);
         IdentifiedUser.GenericFactory userFactory = userFactory(engine);
-        IdentifiedUser who;
-        if (db != null) {
-          who = userFactory.create(Providers.of(db), accountId);
-        } else {
-          who = userFactory.create(accountId);
-        }
+        IdentifiedUser who = userFactory.create(accountId);
         cache.put(accountId, who);
         user = who;
       }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
new file mode 100644
index 0000000..bea7c8b
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.rules.StoredValues;
+
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_uploader_1 extends Predicate.P1 {
+  private static final SymbolTerm user = SymbolTerm.intern("user", 1);
+
+  public PRED_uploader_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Account.Id uploaderId = StoredValues.getPatchSet(engine).getUploader();
+
+    if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())),
+        engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/gerrit-server/src/main/prolog/BUILD b/gerrit-server/src/main/prolog/BUILD
new file mode 100644
index 0000000..555cd90
--- /dev/null
+++ b/gerrit-server/src/main/prolog/BUILD
@@ -0,0 +1,8 @@
+load('//lib/prolog:prolog.bzl', 'prolog_cafe_library')
+
+prolog_cafe_library(
+  name = 'common',
+  srcs = ['gerrit_common.pl'],
+  deps = ['//gerrit-server:server'],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 9a4e77c..59c926f 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -243,6 +243,7 @@
 legacy_submit_rule('MaxNoBlock', Label, Min, Max, T) :- !, max_no_block(Label, Max, T).
 legacy_submit_rule('NoBlock', Label, Min, Max, T) :- !, T = may(_).
 legacy_submit_rule('NoOp', Label, Min, Max, T) :- !, T = may(_).
+legacy_submit_rule('PatchSetLock', Label, Min, Max, T) :- !, T = may(_).
 legacy_submit_rule(Fun, Label, Min, Max, T) :- T = impossible(unsupported(Fun)).
 
 %% max_with_block:
@@ -294,6 +295,10 @@
 %%
 %% - At least one maximum is used.
 %%
+max_no_block(Max, Label, label(Label, S)) :-
+  number(Max), atom(Label),
+  !,
+  max_no_block(Label, Max, S).
 max_no_block(Label, Max, ok(Who)) :-
   check_label_range_permission(Label, Max, ok(Who)),
   !
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
index 57f4f63..accd3b8 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
@@ -32,7 +32,9 @@
 ## to a change being abandoned.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has abandoned this change.
+$fromName has abandoned this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
index e64677d..a442311 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
@@ -33,7 +33,9 @@
 ## ChangeSubject.vm, ChangeFooter.vm and CommentFooter.vm.
 ##
 #if ($email.coverLetter || $email.hasInlineComments())
-$fromName has posted comments on this change.
+$fromName has posted comments on this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
new file mode 100644
index 0000000..635b716
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
@@ -0,0 +1,47 @@
+## Copyright (C) 2016 The Android Open Source Project
+##
+## Licensed under the Apache License, Version 2.0 (the "License");
+## you may not use this file except in compliance with the License.
+## You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+##
+##
+## Template Type:
+## -------------
+## This is a velocity mail template, see: http://velocity.apache.org and the
+## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
+##
+## Template File Names and extensions:
+## ----------------------------------
+## Gerrit will use templates ending in ".vm" but will ignore templates ending
+## in ".vm.example".  If a .vm template does not exist, the default internal
+## gerrit template which is the same as the .vm.example will be used.  If you
+## want to override the default template, copy the .vm.example file to a .vm
+## file and edit it appropriately.
+##
+## This Template:
+## --------------
+## The DeleteReviewer.vm template will determine the contents of the email
+## related to removal of a reviewer (and the reviewer's votes) from reviews.
+## It is a ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
+##
+$fromName has removed $email.joinStrings($email.reviewerNames, ', ') from #**
+*#this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
+
+Change subject: $change.subject
+......................................................................
+
+
+#if ($email.coverLetter)
+$email.coverLetter
+
+#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
new file mode 100644
index 0000000..294063e
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
@@ -0,0 +1,44 @@
+## Copyright (C) 2016 The Android Open Source Project
+##
+## Licensed under the Apache License, Version 2.0 (the "License");
+## you may not use this file except in compliance with the License.
+## You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+##
+##
+## Template Type:
+## -------------
+## This is a velocity mail template, see: http://velocity.apache.org and the
+## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
+##
+## Template File Names and extensions:
+## ----------------------------------
+## Gerrit will use templates ending in ".vm" but will ignore templates ending
+## in ".vm.example".  If a .vm template does not exist, the default internal
+## gerrit template which is the same as the .vm.example will be used.  If you
+## want to override the default template, copy the .vm.example file to a .vm
+## file and edit it appropriately.
+##
+## This Template:
+## --------------
+## The DeleteVote.vm template will determine the contents of the email related
+## to removing votes on changes.  It is a ChangeEmail: see ChangeSubject.vm
+## and ChangeFooter.vm.
+##
+$fromName has removed a vote on this change.
+
+Change subject: $change.subject
+......................................................................
+
+
+#if ($coverLetter)
+$coverLetter
+
+#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
deleted file mode 100644
index 6ded252..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
+++ /dev/null
@@ -1,44 +0,0 @@
-## Copyright (C) 2010 The Android Open Source Project
-##
-## Licensed under the Apache License, Version 2.0 (the "License");
-## you may not use this file except in compliance with the License.
-## You may obtain a copy of the License at
-##
-## http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The MergeFail.vm template will determine the contents of the email related
-## to a failure upon attempting to merge a change to the head.  It is a
-## ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has submitted this change and it FAILED to merge.
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
index 22e29e8..3e49e92 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
@@ -32,7 +32,9 @@
 ## a change successfully merged to the head.  It is a ChangeEmail: see
 ## ChangeSubject.vm and ChangeFooter.vm.
 ##
-$fromName has submitted this change and it was merged.
+$fromName has submitted this change and it was merged.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
index d524b48..8b66e81 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
@@ -42,12 +42,10 @@
 to review the following change.
 #end
 #else
-$fromName has uploaded a new change for review.
-#if($email.changeUrl)
+$fromName has uploaded a new change for review.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+#end
 
-  $email.changeUrl
-#end
-#end
 
 Change subject: $change.subject
 ......................................................................
@@ -59,4 +57,4 @@
 #if($email.includeDiff)
 
 $email.UnifiedDiff
-#end
\ No newline at end of file
+#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
index 392df9d..e45bf30 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
@@ -42,7 +42,9 @@
 to look at the new patch set (#$patchSet.patchSetId).
 #end
 #else
-$fromName has uploaded a new patch set (#$patchSet.patchSetId).
+$fromName has uploaded a new patch set (#$patchSet.patchSetId).#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 #end
 
 Change subject: $change.subject
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
index afcbcc5..31e1c69 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
@@ -32,7 +32,9 @@
 ## to a change being restored.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has restored this change.
+$fromName has restored this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
index a0aedd6..1e9e251 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
@@ -32,7 +32,9 @@
 ## to a change being reverted.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has reverted this change.
+$fromName has reverted this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index ea99846..d51547c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -1,54 +1,254 @@
+apl = text/apl
 as = text/x-gas
+asn = text/x-ttcn-asn
+asn1 = text/x-ttcn-asn
+asp = application/x-aspx
+aspx = application/x-aspx
+asterisk = text/x-asterisk
+b = text/x-brainfuck
+bash = text/x-sh
+bf = text/x-brainfuck
+bnf = text/x-ebnf
 bucklet = text/x-python
+bzl = text/x-python
 BUCK = text/x-python
-clj = text/x-clojure
+BUILD = text/x-python
+c = text/x-csrc
+cfg = text/x-ttcn-cfg
 cl = text/x-common-lisp
+clj = text/x-clojure
+cljs = text/x-clojurescript
+cmake = text/x-cmake
+cmake.in = text/x-cmake
+contributing.md = text/x-gfm
+CMakeLists.txt = text/x-cmake
+CONTRIBUTING.md = text/x-gfm
+cob = text/x-cobol
 coffee = text/x-coffeescript
+conf = text/plain
+cpy = text/x-cobol
+cr = text/x-crystal
 cs = text/x-csharp
+csharp = text/x-csharp
+css = text/css
 cpp = text/x-c++src
+cql = text/x-cassandra
 cxx = text/x-c++src
+cyp = application/x-cypher-query
+cypher = application/x-cypher-query
+c++ = text/x-c++src
 d = text/x-d
 dart = application/dart
+def = text/plain
 defs = text/x-python
 diff = text/x-diff
+django = text/x-django
+dtd = application/xml-dtd
+dyalog = text/apl
+dyl = text/x-dylan
+dylan = text/x-dylan
 Dockerfile = text/x-dockerfile
 dtd = application/xml-dtd
+e = text/x-eiffel
+ebnf = text/x-ebnf
+ecl = text/x-ecl
 el = text/x-common-lisp
+elm = text/x-elm
+ejs = application/x-ejs
+erb = application/x-erb
 erl = text/x-erlang
+es6 = text/jsx
+excel = text/x-spreadsheet
+extensions.conf = text/x-asterisk
+f = text/x-fortran
+factor = text/x-factor
+feathre = text/x-feature
+fcl = text/x-fcl
+for = text/x-fortran
+formula = text/x-spreadsheet
+forth = text/x-forth
+fth = text/x-forth
 frag = x-shader/x-fragment
+fs = text/x-fsharp
+fsharp = text/x-fsharp
+f77 = text/x-fortran
+f90 = text/x-fortran
 gitmodules = text/x-ini
 glsl = x-shader/x-vertex
 go = text/x-go
+gradle = text/x-groovy
+gradlew = text/x-sh
 groovy = text/x-groovy
-hs = text/x-haskell
+gss = text/x-gss
+h = text/x-csrc
+haml = text/x-haml
+hh = text/x-c++src
+history.md = text/x-gfm
 hpp = text/x-c++src
+hs = text/x-haskell
+htm = text/html
+html = text/html
+http = message/http
+hx = text/x-haxe
+hxml = text/x-hxml
 hxx = text/x-c++src
+h++ = text/x-c++src
+HISTORY.md = text/x-gfm
+in = text/x-properties
+ini = text/x-properties
+intr = text/x-dylan
+jade = text/x-jade
+java = text/x-java
+jl = text/x-julia
+jruby = text/x-ruby
+js = text/javascript
+json = application/json
+jsonld = application/ld+json
+jsx = text/jsx
+jsp = application/x-jsp
+kt = text/x-kotlin
+less = text/x-less
+lhs = text/x-literate-haskell
 lisp = text/x-common-lisp
+list = text/plain
+log = text/plain
+ls = text/x-livescript
 lsp = text/x-common-lisp
 lua = text/x-lua
 m = text/x-objectivec
+macruby = text/x-ruby
+map = application/json
+markdown = text/x-markdown
+mbox = application/mbox
 md = text/x-markdown
+mirc = text/mirc
+mkd = text/x-markdown
+ml = text/x-ocaml
+mli = text/x-ocaml
+mll = text/x-ocaml
+mly = text/x-ocaml
+mm = text/x-objectivec
+mo = text/x-modelica
+mps = text/x-mumps
+msc = text/x-mscgen
+mscgen = text/x-mscgen
+mscin = text/x-mscgen
+msgenny = text/x-msgenny
+nb = text/x-mathematica
+nginx.conf = text/x-nginx-conf
+nsh = text/x-nsis
+nsi = text/x-nsis
+nt = text/n-triples
+nut = text/x-squirrel
+oz = text/x-oz
+p = text/x-pascal
+pas = text/x-pascal
 patch = text/x-diff
+pgp = application/pgp
 php = text/x-php
+php3 = text/x-php
+php4 = text/x-php
+php5 = text/x-php
+phtml = text/x-php
 pig = text/x-pig
 pl = text/x-perl
+pls = text/x-plsql
 pm = text/x-perl
 pp = text/x-puppet
+pro = text/x-idl
 project.config = text/x-ini
 properties = text/x-ini
+proto = text/x-protobuf
+protobuf = text/x-protobuf
+ps1 = application/x-powershell
+psd1 = application/x-powershell
+psm1 = application/x-powershell
 py = text/x-python
+pyw = text/x-python
+pyx = text/x-cython
+pxd = text/x-cython
+pxi = text/x-cython
+PKGBUILD = text/x-sh
+q = text/x-q
 r = text/r-src
+rake = text/x-ruby
 rb = text/x-ruby
+rbx = text/x-ruby
+readme.md = text/x-gfm
 rng = application/xml
+rpm = text/x-rpm-changes
+rq = application/sparql-query
+rs = text/x-rustsrc
+rss = application/xml
 rst = text/x-rst
+README.md = text/x-gfm
+s = text/x-gas
+sas = text/x-sas
+sass = text/x-sass
 scala = text/x-scala
+scm = text/x-scheme
+scss = text/x-scss
+sh = text/x-sh
+sieve = application/sieve
+siv = application/sieve
+slim = text/x-slim
+solr = text/x-solr
 soy = text/x-soy
+sparql = application/sparql-query
+sparul = applicatoin/sparql-query
+spec = text/x-rpm-spec
+spreadsheet = text/x-spreadsheet
+sql = text/x-sql
+ss = text/x-scheme
 st = text/x-stsrc
 stex = text/x-stex
+swift = text/x-swift
 tcl = text/x-tcl
+tex = text/x-latex
+text = text/plain
+textile = text/x-textile
+tiddly = text/x-tiddlywiki
+tiddlywiki = text/x-tiddlywiki
+tiki = text/tiki
+toml = text/x-toml
+tpl = text/x-smarty
+ts = application/typescript
+ttcn = text/x-ttcn
+ttcnpp = text/x-ttcn
+ttcn3 = text/x-ttcn
+ttl = text/turtle
+txt = text/plain
+twig = text/x-twig
 v = text/x-verilog
+vb = text/x-vb
+vbs = text/vbscript
 vert = x-shader/x-vertex
 vh = text/x-verilog
+vhd = text/x-vhdl
 vhdl = text/x-vhdl
 vm = text/velocity
+vtl = text/velocity
+webidl = text/x-webidl
+wsdl = application/xml
+xhtml = text/html
+xml = application/xml
+xsd = application/xml
+xsl = application/xml
+xquery = application/xquery
+xu = text/x-xu
+xy = application/xquery
 yaml = text/x-yaml
+yml = text/x-yaml
+ys = text/x-yacas
+zsh = text/x-sh
+z80 = text/x-z80
+1 = text/troff
+2 = text/troff
+3 = text/troff
+4 = text/troff
+4th = text/x-forth
+5 = text/troff
+6 = text/troff
+7 = text/troff
+8 = text/troff
+9 = text/troff
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index cca2ac1..03f5375 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -19,7 +19,7 @@
 
 unset GREP_OPTIONS
 
-CHANGE_ID_AFTER="Bug|Depends-On|Issue|Test"
+CHANGE_ID_AFTER="Bug|Depends-On|Issue|Test|Feature|Fixes|Fixed"
 MSG="$1"
 
 # Check for, and add if missing, a unique Change-Id
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
index 70b1776..b835e6b 100755
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/reposize.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 #
 # Copyright (C) 2014 The Android Open Source Project
 #
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
new file mode 100644
index 0000000..aa4c4eb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.FieldOrdering;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class ProcMetricModuleTest {
+  @Rule
+  public ExpectedException exception = ExpectedException.none();
+
+  @Inject
+  MetricMaker metrics;
+
+  @Inject
+  MetricRegistry registry;
+
+  @Test
+  public void testConstantBuildLabel() {
+    Gauge<String> buildLabel = gauge("build/label");
+    assertThat(buildLabel.getValue()).isEqualTo(Version.getVersion());
+  }
+
+  @Test
+  public void testProcUptime() {
+    Gauge<Long> birth = gauge("proc/birth_timestamp");
+    assertThat(birth.getValue()).isAtMost(
+        TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()));
+
+    Gauge<Long> uptime = gauge("proc/uptime");
+    assertThat(uptime.getValue()).isAtLeast(1L);
+  }
+
+  @Test
+  public void testCounter0() {
+    Counter0 cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative());
+
+    Counter raw = get("test/count", Counter.class);
+    assertThat(raw.getCount()).isEqualTo(0);
+
+    cntr.increment();
+    assertThat(raw.getCount()).isEqualTo(1);
+
+    cntr.incrementBy(5);
+    assertThat(raw.getCount()).isEqualTo(6);
+  }
+
+  @Test
+  public void testCounter1() {
+    Counter1<String> cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative(),
+        Field.ofString("action"));
+
+    Counter total = get("test/count_total", Counter.class);
+    assertThat(total.getCount()).isEqualTo(0);
+
+    cntr.increment("passed");
+    Counter passed = get("test/count/passed", Counter.class);
+    assertThat(total.getCount()).isEqualTo(1);
+    assertThat(passed.getCount()).isEqualTo(1);
+
+    cntr.incrementBy("failed", 5);
+    Counter failed = get("test/count/failed", Counter.class);
+    assertThat(total.getCount()).isEqualTo(6);
+    assertThat(passed.getCount()).isEqualTo(1);
+    assertThat(failed.getCount()).isEqualTo(5);
+  }
+
+  @Test
+  public void testCounterPrefixFields() {
+    Counter1<String> cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative()
+          .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
+        Field.ofString("action"));
+
+    Counter total = get("test/count_total", Counter.class);
+    assertThat(total.getCount()).isEqualTo(0);
+
+    cntr.increment("passed");
+    Counter passed = get("test/passed/count", Counter.class);
+    assertThat(total.getCount()).isEqualTo(1);
+    assertThat(passed.getCount()).isEqualTo(1);
+
+    cntr.incrementBy("failed", 5);
+    Counter failed = get("test/failed/count", Counter.class);
+    assertThat(total.getCount()).isEqualTo(6);
+    assertThat(passed.getCount()).isEqualTo(1);
+    assertThat(failed.getCount()).isEqualTo(5);
+  }
+
+  @Test
+  public void testCallbackMetric0() {
+    final CallbackMetric0<Long> cntr = metrics.newCallbackMetric(
+        "test/count",
+        Long.class,
+        new Description("simple test")
+          .setCumulative());
+
+    final AtomicInteger invocations = new AtomicInteger(0);
+    metrics.newTrigger(cntr, new Runnable() {
+      @Override
+      public void run() {
+        invocations.getAndIncrement();
+        cntr.set(42L);
+      }
+    });
+
+    // Triggers run immediately with DropWizard binding.
+    assertThat(invocations.get()).isEqualTo(1);
+
+    Gauge<Long> raw = gauge("test/count");
+    assertThat(raw.getValue()).isEqualTo(42);
+
+    // Triggers are debounced to avoid being fired too frequently.
+    assertThat(invocations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void testInvalidName1() {
+    exception.expect(IllegalArgumentException.class);
+    metrics.newCounter("invalid name", new Description("fail"));
+  }
+
+  @Test
+  public void testInvalidName2() {
+    exception.expect(IllegalArgumentException.class);
+    metrics.newCounter("invalid/ name", new Description("fail"));
+  }
+
+  @SuppressWarnings({"unchecked", "cast"})
+  private <V> Gauge<V> gauge(String name) {
+    return (Gauge<V>) get(name, Gauge.class);
+  }
+
+  private <M extends Metric> M get(String name, Class<M> type) {
+    Metric m = registry.getMetrics().get(name);
+    assertThat(m).named(name).isNotNull();
+    assertThat(m).named(name).isInstanceOf(type);
+
+    @SuppressWarnings("unchecked")
+    M result = (M) m;
+    return result;
+  }
+
+  @Before
+  public void setup() {
+    Injector injector =
+        Guice.createInjector(new DropWizardMetricMaker.ApiModule());
+
+    LifecycleManager mgr = new LifecycleManager();
+    mgr.add(injector);
+    mgr.start();
+
+    injector.injectMembers(this);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index d6a5c67..e23867f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -14,21 +14,11 @@
 
 package com.google.gerrit.rules;
 
-import static com.google.gerrit.common.data.Permission.LABEL;
-import static com.google.gerrit.server.project.Util.allow;
-import static com.google.gerrit.server.project.Util.category;
-import static com.google.gerrit.server.project.Util.value;
+import static org.easymock.EasyMock.expect;
 
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.inject.AbstractModule;
 
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -38,36 +28,18 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 
+import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.io.PushbackReader;
 import java.io.StringReader;
 import java.util.Arrays;
 
 public class GerritCommonTest extends PrologTestCase {
-  private final LabelType V = category("Verified",
-      value(1, "Verified"),
-      value(0, "No score"),
-      value(-1, "Fails"));
-  private final LabelType Q = category("Qualified",
-      value(1, "Qualified"),
-      value(0, "No score"),
-      value(-1, "Fails"));
-
-  private final Project.NameKey localKey = new Project.NameKey("local");
-  private ProjectConfig local;
-  private Util util;
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
   @Before
   public void setUp() throws Exception {
-    util = new Util();
     load("gerrit", "gerrit_common_test.pl", new AbstractModule() {
       @Override
       protected void configure() {
@@ -85,26 +57,16 @@
                 cfg));
       }
     });
-
-    local = new ProjectConfig(localKey);
-    local.load(InMemoryRepositoryManager.newRepository(localKey));
-    Q.setRefPatterns(Arrays.asList("refs/heads/develop"));
-
-    local.getLabelSections().put(V.getName(), V);
-    local.getLabelSections().put(Q.getName(), Q);
-    util.add(local);
-    allow(local, LABEL + V.getName(), -1, +1, SystemGroupBackend.REGISTERED_USERS, "refs/heads/*");
-    allow(local, LABEL + Q.getName(), -1, +1, SystemGroupBackend.REGISTERED_USERS, "refs/heads/master");
   }
 
   @Override
   protected void setUpEnvironment(PrologEnvironment env) {
-    Change change =
-        new Change(new Change.Key("Ibeef"), new Change.Id(1),
-            new Account.Id(2),
-            new Branch.NameKey(localKey, "refs/heads/master"),
-            TimeUtil.nowTs());
-    env.set(StoredValues.CHANGE_CONTROL, util.user(local).controlFor(change));
+    LabelTypes labelTypes =
+        new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
+    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
+    expect(ctl.getLabelTypes()).andStubReturn(labelTypes);
+    EasyMock.replay(ctl);
+    env.set(StoredValues.CHANGE_CONTROL, ctl);
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index 8956e8f..dc8004a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Module;
 
@@ -45,7 +46,7 @@
 
 
 /** Base class for any tests written in Prolog. */
-public abstract class PrologTestCase {
+public abstract class PrologTestCase extends GerritBaseTests {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
   private String pkg;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
index 039871e..0d2de399 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
@@ -60,7 +60,7 @@
   private static final String[] TEST_CASES = {
     "",
     "FirstName.LastName@Corporation.com",
-    "!#$%&'+-/=.?^`{|}~@[IPv6:0123:4567:89AB:CDEF:0123:4567:89AB:CDEF]"
+    "!#$%&'+-/=.?^`{|}~@[IPv6:0123:4567:89AB:CDEF:0123:4567:89AB:CDEF]",
   };
 
   @Before
@@ -95,7 +95,6 @@
         bind(CapabilityControl.Factory.class)
           .toProvider(Providers.<CapabilityControl.Factory>of(null));
         bind(Realm.class).toInstance(mockRealm);
-
       }
     };
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
index b4d6e96..0646eef0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
@@ -51,7 +51,7 @@
         "string with 'quotes'", "string with 'quotes'",
         "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram",
         "string\nwith\nnewlines", "string\\nwith\\nnewlines",
-        "string\twith\ttabs", "string\\twith\\ttabs" };
+        "string\twith\ttabs", "string\\twith\\ttabs", };
     for (int i = 0; i < testPairs.length; i += 2) {
       assertEquals(StringUtil.escapeString(testPairs[i]), testPairs[i + 1]);
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
new file mode 100644
index 0000000..f5849c1
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Optional;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AuthorizedKeysTest {
+  private static final String KEY1 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
+      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+      + "w== john.doe@example.com";
+  private static final String KEY2 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDm5yP7FmEoqzQRDyskX+9+N0q9GrvZeh5"
+      + "RG52EUpE4ms/Ujm3ewV1LoGzc/lYKJAIbdcZQNJ9+06EfWZaIRA3oOwAPe1eCnX+aLr8E"
+      + "6Tw2gDMQOGc5e9HfyXpC2pDvzauoZNYqLALOG3y/1xjo7IH8GYRS2B7zO/Mf9DdCcCKSf"
+      + "w== john.doe@example.com";
+  private static final String KEY3 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCaS7RHEcZ/zjl9hkWkqnm29RNr2OQ/TZ5"
+      + "jk2qBVMH3BgzPsTsEs+7ag9tfD8OCj+vOcwm626mQBZoR2e3niHa/9gnHBHFtOrGfzKbp"
+      + "RjTWtiOZbB9HF+rqMVD+Dawo/oicX/dDg7VAgOFSPothe6RMhbgWf84UcK5aQd5eP5y+t"
+      + "Q== john.doe@example.com";
+  private static final String KEY4 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDIJzW9BaAeO+upFletwwEBnGS15lJmS5i"
+      + "08/NiFef0jXtNNKcLtnd13bq8jOi5VA2is0bwof1c8YbwcvUkdFa8RL5aXoyZBpfYZsWs"
+      + "/YBLZGiHy5rjooMZQMnH37A50cBPnXr0AQz0WRBxLDBDyOZho+O/DfYAKv4rzPSQ3yw4+"
+      + "w== john.doe@example.com";
+  private static final String KEY5 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgBRKGhiXvY6D9sM+Vbth5Kate57YF7kD"
+      + "rqIyUiYIMJK93/AXc8qR/J/p3OIFQAxvLz1qozAur3j5HaiwvxVU19IiSA0vafdhaDLRi"
+      + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
+      + "w== john.doe@example.com";
+
+  @Test
+  public void test() throws Exception {
+    List<Optional<AccountSshKey>> keys = new ArrayList<>();
+    StringBuilder expected = new StringBuilder();
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addKey(keys, KEY1));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addKey(keys, KEY2));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addInvalidKey(keys, KEY3));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addKey(keys, KEY4));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addDeletedKey(keys));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+
+    expected.append(addKey(keys, KEY5));
+    assertSerialization(keys, expected);
+    assertParse(expected, keys);
+  }
+
+  @Test
+  public void testParseWindowsLineEndings() throws Exception {
+    List<Optional<AccountSshKey>> keys = new ArrayList<>();
+    StringBuilder authorizedKeys = new StringBuilder();
+    authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY1)));
+    assertParse(authorizedKeys, keys);
+
+    authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY2)));
+    assertParse(authorizedKeys, keys);
+
+    authorizedKeys.append(toWindowsLineEndings(addInvalidKey(keys, KEY3)));
+    assertParse(authorizedKeys, keys);
+
+    authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY4)));
+    assertParse(authorizedKeys, keys);
+
+    authorizedKeys.append(toWindowsLineEndings(addDeletedKey(keys)));
+    assertParse(authorizedKeys, keys);
+
+    authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY5)));
+    assertParse(authorizedKeys, keys);
+
+  }
+
+  private static String toWindowsLineEndings(String s) {
+    return s.replaceAll("\n", "\r\n");
+  }
+
+  private static void assertSerialization(List<Optional<AccountSshKey>> keys,
+      StringBuilder expected) {
+    assertThat(AuthorizedKeys.serialize(keys)).isEqualTo(expected.toString());
+  }
+
+  private static void assertParse(StringBuilder authorizedKeys,
+      List<Optional<AccountSshKey>> expectedKeys) {
+    Account.Id accountId = new Account.Id(1);
+    List<Optional<AccountSshKey>> parsedKeys =
+        AuthorizedKeys.parse(accountId, authorizedKeys.toString());
+    assertThat(parsedKeys).containsExactlyElementsIn(expectedKeys);
+    int seq = 1;
+    for(Optional<AccountSshKey> sshKey : parsedKeys) {
+      if (sshKey.isPresent()) {
+        assertThat(sshKey.get().getAccount()).isEqualTo(accountId);
+        assertThat(sshKey.get().getKey().get()).isEqualTo(seq);
+      }
+      seq++;
+    }
+  }
+
+  /**
+   * Adds the given public key as new SSH key to the given list.
+   *
+   * @return the expected line for this key in the authorized_keys file
+   */
+  private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
+    AccountSshKey.Id keyId =
+        new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
+    AccountSshKey key = new AccountSshKey(keyId, pub);
+    keys.add(Optional.of(key));
+    return key.getSshPublicKey() + "\n";
+  }
+
+  /**
+   * Adds the given public key as invalid SSH key to the given list.
+   *
+   * @return the expected line for this key in the authorized_keys file
+   */
+  private static String addInvalidKey(List<Optional<AccountSshKey>> keys,
+      String pub) {
+    AccountSshKey.Id keyId =
+        new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
+    AccountSshKey key = new AccountSshKey(keyId, pub);
+    key.setInvalid();
+    keys.add(Optional.of(key));
+    return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX
+        + key.getSshPublicKey() + "\n";
+  }
+
+  /**
+   * Adds a deleted SSH key to the given list.
+   *
+   * @return the expected line for this key in the authorized_keys file
+   */
+  private static String addDeletedKey(List<Optional<AccountSshKey>> keys) {
+    keys.add(Optional.<AccountSshKey> absent());
+    return AuthorizedKeys.DELETED_KEY_COMMENT + "\n";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
new file mode 100644
index 0000000..0619a78
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyValue;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.git.ValidationError;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class WatchConfigTest implements ValidationError.Sink {
+  private List<ValidationError> validationErrors = new ArrayList<>();
+
+  @Before
+  public void setup() {
+    validationErrors.clear();
+  }
+
+  @Test
+  public void parseWatchConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[project \"myProject\"]\n"
+        + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
+        + "  notify = branch:master [NEW_CHANGES]\n"
+        + "  notify = branch:master [NEW_PATCHSETS]\n"
+        + "  notify = branch:foo []\n"
+        + "[project \"otherProject\"]\n"
+        + "  notify = [NEW_PATCHSETS]\n"
+        + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
+    Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
+        WatchConfig.parse(new Account.Id(1000000), cfg, this);
+
+    assertThat(validationErrors).isEmpty();
+
+    Project.NameKey myProject = new Project.NameKey("myProject");
+    Project.NameKey otherProject = new Project.NameKey("otherProject");
+    Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches =
+        new HashMap<>();
+    expectedProjectWatches.put(ProjectWatchKey.create(myProject, null),
+        EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
+    expectedProjectWatches.put(
+        ProjectWatchKey.create(myProject, "branch:master"),
+        EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.NEW_PATCHSETS));
+    expectedProjectWatches.put(ProjectWatchKey.create(myProject, "branch:foo"),
+        EnumSet.noneOf(NotifyType.class));
+    expectedProjectWatches.put(ProjectWatchKey.create(otherProject, null),
+        EnumSet.of(NotifyType.NEW_PATCHSETS));
+    expectedProjectWatches.put(ProjectWatchKey.create(otherProject, null),
+        EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
+    assertThat(projectWatches).containsExactlyEntriesIn(expectedProjectWatches);
+  }
+
+  @Test
+  public void parseInvalidWatchConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[project \"myProject\"]\n"
+        + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
+        + "  notify = branch:master [INVALID, NEW_CHANGES]\n"
+        + "[project \"otherProject\"]\n"
+        + "  notify = [NEW_PATCHSETS]\n");
+
+    WatchConfig.parse(new Account.Id(1000000), cfg, this);
+    assertThat(validationErrors).hasSize(1);
+    assertThat(validationErrors.get(0).getMessage()).isEqualTo(
+        "watch.config: Invalid notify type INVALID in project watch of"
+            + " account 1000000 for project myProject: branch:master"
+            + " [INVALID, NEW_CHANGES]");
+  }
+
+  @Test
+  public void parseNotifyValue() throws Exception {
+    assertParseNotifyValue("* []", null, EnumSet.noneOf(NotifyType.class));
+    assertParseNotifyValue("* [ALL_COMMENTS]", null,
+        EnumSet.of(NotifyType.ALL_COMMENTS));
+    assertParseNotifyValue("[]", null, EnumSet.noneOf(NotifyType.class));
+    assertParseNotifyValue("[ALL_COMMENTS, NEW_PATCHSETS]", null,
+        EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
+    assertParseNotifyValue("branch:master []", "branch:master",
+        EnumSet.noneOf(NotifyType.class));
+    assertParseNotifyValue("branch:master || branch:stable []",
+        "branch:master || branch:stable", EnumSet.noneOf(NotifyType.class));
+    assertParseNotifyValue("branch:master [ALL_COMMENTS]", "branch:master",
+        EnumSet.of(NotifyType.ALL_COMMENTS));
+    assertParseNotifyValue("branch:master [ALL_COMMENTS, NEW_PATCHSETS]",
+        "branch:master",
+        EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
+    assertParseNotifyValue("* [ALL]", null, EnumSet.of(NotifyType.ALL));
+
+    assertThat(validationErrors).isEmpty();
+  }
+
+  @Test
+  public void parseInvalidNotifyValue() {
+    assertParseNotifyValueFails("* [] illegal-characters-at-the-end");
+    assertParseNotifyValueFails("* [INVALID]");
+    assertParseNotifyValueFails("* [ALL_COMMENTS, UNKNOWN]");
+    assertParseNotifyValueFails("* [ALL_COMMENTS NEW_CHANGES]");
+    assertParseNotifyValueFails("* [ALL_COMMENTS, NEW_CHANGES");
+    assertParseNotifyValueFails("* ALL_COMMENTS, NEW_CHANGES]");
+  }
+
+  @Test
+  public void toNotifyValue() throws Exception {
+    assertToNotifyValue(null, EnumSet.noneOf(NotifyType.class), "* []");
+    assertToNotifyValue("*", EnumSet.noneOf(NotifyType.class), "* []");
+    assertToNotifyValue(null, EnumSet.of(NotifyType.ALL_COMMENTS),
+        "* [ALL_COMMENTS]");
+    assertToNotifyValue("branch:master", EnumSet.noneOf(NotifyType.class),
+        "branch:master []");
+    assertToNotifyValue("branch:master",
+        EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS),
+        "branch:master [ALL_COMMENTS, NEW_PATCHSETS]");
+    assertToNotifyValue("branch:master",
+        EnumSet.of(NotifyType.ABANDONED_CHANGES, NotifyType.ALL_COMMENTS,
+            NotifyType.NEW_CHANGES, NotifyType.NEW_PATCHSETS,
+            NotifyType.SUBMITTED_CHANGES),
+        "branch:master [ABANDONED_CHANGES, ALL_COMMENTS, NEW_CHANGES,"
+        + " NEW_PATCHSETS, SUBMITTED_CHANGES]");
+    assertToNotifyValue("*", EnumSet.of(NotifyType.ALL), "* [ALL]");
+  }
+
+  private void assertParseNotifyValue(String notifyValue,
+      String expectedFilter, Set<NotifyType> expectedNotifyTypes) {
+    NotifyValue nv = parseNotifyValue(notifyValue);
+    assertThat(nv.filter()).isEqualTo(expectedFilter);
+    assertThat(nv.notifyTypes()).containsExactlyElementsIn(expectedNotifyTypes);
+  }
+
+  private static void assertToNotifyValue(String filter,
+      Set<NotifyType> notifyTypes, String expectedNotifyValue) {
+    NotifyValue nv = NotifyValue.create(filter, notifyTypes);
+    assertThat(nv.toString()).isEqualTo(expectedNotifyValue);
+  }
+
+  private void assertParseNotifyValueFails(String notifyValue) {
+    assertThat(validationErrors).isEmpty();
+    parseNotifyValue(notifyValue);
+    assertThat(validationErrors)
+        .named("expected validation error for notifyValue: " + notifyValue)
+        .isNotEmpty();
+    validationErrors.clear();
+  }
+
+  private NotifyValue parseNotifyValue(String notifyValue) {
+    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue, this);
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    validationErrors.add(error);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java
deleted file mode 100644
index f5e6d74..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java
+++ /dev/null
@@ -1,478 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.inject.Scopes.SINGLETON;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.FakeRealm;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.FakeAccountCache;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.util.Providers;
-
-import org.easymock.IAnswer;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.junit.runner.RunWith;
-
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-
-@RunWith(ConfigSuite.class)
-public class CommentsTest  {
-  private static final TimeZone TZ =
-      TimeZone.getTimeZone("America/Los_Angeles");
-
-  @ConfigSuite.Parameter
-  public Config config;
-
-  @ConfigSuite.Config
-  @GerritServerConfig
-  public static Config noteDbEnabled() {
-    return NotesMigration.allEnabledConfig();
-  }
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
-  private Injector injector;
-  private ReviewDb db;
-  private Project.NameKey project;
-  private Account.Id ownerId;
-  private RevisionResource revRes1;
-  private RevisionResource revRes2;
-  private RevisionResource revRes3;
-  private PatchLineComment plc1;
-  private PatchLineComment plc2;
-  private PatchLineComment plc3;
-  private PatchLineComment plc4;
-  private PatchLineComment plc5;
-  private PatchLineComment plc6;
-  private IdentifiedUser changeOwner;
-
-  @Inject private AllUsersNameProvider allUsers;
-  @Inject private Comments comments;
-  @Inject private DraftComments drafts;
-  @Inject private GetComment getComment;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private InMemoryRepositoryManager repoManager;
-  @Inject private NotesMigration migration;
-  @Inject private PatchLineCommentsUtil plcUtil;
-
-  @Before
-  public void setUp() throws Exception {
-    @SuppressWarnings("unchecked")
-    final DynamicMap<RestView<CommentResource>> commentViews =
-        createMock(DynamicMap.class);
-    final TypeLiteral<DynamicMap<RestView<CommentResource>>> commentViewsType =
-        new TypeLiteral<DynamicMap<RestView<CommentResource>>>() {};
-    @SuppressWarnings("unchecked")
-    final DynamicMap<RestView<DraftCommentResource>> draftViews =
-        createMock(DynamicMap.class);
-    final TypeLiteral<DynamicMap<RestView<DraftCommentResource>>> draftViewsType =
-        new TypeLiteral<DynamicMap<RestView<DraftCommentResource>>>() {};
-
-    final AccountLoader.Factory alf =
-        createMock(AccountLoader.Factory.class);
-    db = createMock(ReviewDb.class);
-    final FakeAccountCache accountCache = new FakeAccountCache();
-    final PersonIdent serverIdent = new PersonIdent(
-        "Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
-    project = new Project.NameKey("test-project");
-
-    Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
-    co.setFullName("Change Owner");
-    co.setPreferredEmail("change@owner.com");
-    accountCache.put(co);
-    ownerId = co.getId();
-
-    Account ou = new Account(new Account.Id(2), TimeUtil.nowTs());
-    ou.setFullName("Other Account");
-    ou.setPreferredEmail("other@account.com");
-    accountCache.put(ou);
-    Account.Id otherUserId = ou.getId();
-
-    AbstractModule mod = new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(commentViewsType).toInstance(commentViews);
-        bind(draftViewsType).toInstance(draftViews);
-        bind(AccountLoader.Factory.class).toInstance(alf);
-        bind(ReviewDb.class).toInstance(db);
-        bind(Realm.class).to(FakeRealm.class);
-        bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
-        bind(ProjectCache.class).toProvider(Providers.<ProjectCache> of(null));
-        install(new GitModule());
-        bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
-        bind(InMemoryRepositoryManager.class)
-            .toInstance(new InMemoryRepositoryManager());
-        bind(CapabilityControl.Factory.class)
-            .toProvider(Providers.<CapabilityControl.Factory> of(null));
-        bind(String.class).annotatedWith(AnonymousCowardName.class)
-            .toProvider(AnonymousCowardNameProvider.class);
-        bind(String.class).annotatedWith(CanonicalWebUrl.class)
-            .toInstance("http://localhost:8080/");
-        bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-            .toInstance(Boolean.FALSE);
-        bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-        bind(AccountCache.class).toInstance(accountCache);
-        bind(GitReferenceUpdated.class)
-            .toInstance(GitReferenceUpdated.DISABLED);
-        bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class)
-          .toInstance(serverIdent);
-      }
-
-      @Provides
-      @Singleton
-      CurrentUser getUser(IdentifiedUser.GenericFactory userFactory) {
-        return userFactory.create(ownerId);
-      }
-    };
-
-    injector = Guice.createInjector(mod);
-    injector.injectMembers(this);
-
-    repoManager.createRepository(project);
-    changeOwner = userFactory.create(ownerId);
-    IdentifiedUser otherUser = userFactory.create(otherUserId);
-
-    AccountLoader accountLoader = createMock(AccountLoader.class);
-    accountLoader.fill();
-    expectLastCall().anyTimes();
-    expect(accountLoader.get(ownerId))
-        .andReturn(new AccountInfo(ownerId.get())).anyTimes();
-    expect(accountLoader.get(otherUserId))
-        .andReturn(new AccountInfo(otherUserId.get())).anyTimes();
-    expect(alf.create(true)).andReturn(accountLoader).anyTimes();
-    replay(accountLoader, alf);
-
-    repoManager.createRepository(allUsers.get());
-
-    PatchLineCommentAccess plca = createMock(PatchLineCommentAccess.class);
-    expect(db.patchComments()).andReturn(plca).anyTimes();
-
-    Change change1 = newChange();
-    PatchSet.Id psId1 = new PatchSet.Id(change1.getId(), 1);
-    PatchSet ps1 = new PatchSet(psId1);
-    PatchSet.Id psId2 = new PatchSet.Id(change1.getId(), 2);
-    PatchSet ps2 = new PatchSet(psId2);
-
-    Change change2 = newChange();
-    PatchSet.Id psId3 = new PatchSet.Id(change2.getId(), 1);
-    PatchSet ps3 = new PatchSet(psId3);
-
-    long timeBase = TimeUtil.roundToSecond(TimeUtil.nowTs()).getTime();
-    plc1 = newPatchLineComment(psId1, "Comment1", null,
-        "FileOne.txt", Side.REVISION, 3, ownerId, timeBase,
-        "First Comment", new CommentRange(1, 2, 3, 4));
-    plc1.setRevId(new RevId("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"));
-    plc2 = newPatchLineComment(psId1, "Comment2", "Comment1",
-        "FileOne.txt", Side.REVISION, 3, otherUserId, timeBase + 1000,
-        "Reply to First Comment",  new CommentRange(1, 2, 3, 4));
-    plc2.setRevId(new RevId("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"));
-    plc3 = newPatchLineComment(psId1, "Comment3", "Comment1",
-        "FileOne.txt", Side.PARENT, 3, ownerId, timeBase + 2000,
-        "First Parent Comment",  new CommentRange(1, 2, 3, 4));
-    plc3.setRevId(new RevId("cdefcdefcdefcdefcdefcdefcdefcdefcdefcdef"));
-    plc4 = newPatchLineComment(psId2, "Comment4", null, "FileOne.txt",
-        Side.REVISION, 3, ownerId, timeBase + 3000, "Second Comment",
-        new CommentRange(1, 2, 3, 4), Status.DRAFT);
-    plc4.setRevId(new RevId("bcdebcdebcdebcdebcdebcdebcdebcdebcdebcde"));
-    plc5 = newPatchLineComment(psId2, "Comment5", null, "FileOne.txt",
-        Side.REVISION, 5, ownerId, timeBase + 4000, "Third Comment",
-        new CommentRange(3, 4, 5, 6), Status.DRAFT);
-    plc5.setRevId(new RevId("bcdebcdebcdebcdebcdebcdebcdebcdebcdebcde"));
-    plc6 = newPatchLineComment(psId3, "Comment6", null, "FileOne.txt",
-        Side.REVISION, 5, ownerId, timeBase + 5000, "Sixth Comment",
-        new CommentRange(3, 4, 5, 6), Status.DRAFT);
-    plc6.setRevId(new RevId("1234123412341234123412341234123412341234"));
-
-    List<PatchLineComment> commentsByOwner = Lists.newArrayList();
-    commentsByOwner.add(plc1);
-    commentsByOwner.add(plc3);
-    List<PatchLineComment> commentsByReviewer = Lists.newArrayList();
-    commentsByReviewer.add(plc2);
-    List<PatchLineComment> drafts1 = Lists.newArrayList();
-    drafts1.add(plc4);
-    drafts1.add(plc5);
-    List<PatchLineComment> drafts2 = Lists.newArrayList();
-    drafts2.add(plc6);
-
-    plca.upsert(commentsByOwner);
-    expectLastCall().anyTimes();
-    plca.upsert(commentsByReviewer);
-    expectLastCall().anyTimes();
-    plca.upsert(drafts1);
-    expectLastCall().anyTimes();
-    plca.upsert(drafts2);
-    expectLastCall().anyTimes();
-
-    expect(plca.publishedByPatchSet(psId1))
-        .andAnswer(results(plc1, plc2, plc3)).anyTimes();
-    expect(plca.publishedByPatchSet(psId2))
-        .andAnswer(results()).anyTimes();
-    expect(plca.draftByPatchSetAuthor(psId1, ownerId))
-        .andAnswer(results()).anyTimes();
-    expect(plca.draftByPatchSetAuthor(psId2, ownerId))
-        .andAnswer(results(plc4, plc5)).anyTimes();
-    expect(plca.byChange(change1.getId()))
-        .andAnswer(results(plc1, plc2, plc3, plc4, plc5)).anyTimes();
-    expect(plca.draftByAuthor(ownerId))
-        .andAnswer(results(plc4, plc5, plc6)).anyTimes();
-    replay(db, plca);
-
-    ChangeUpdate update = newUpdate(change1, changeOwner);
-    update.setPatchSetId(psId1);
-    plcUtil.upsertComments(db, update, commentsByOwner);
-    update.commit();
-
-    update = newUpdate(change1, otherUser);
-    update.setPatchSetId(psId1);
-    plcUtil.upsertComments(db, update, commentsByReviewer);
-    update.commit();
-
-    update = newUpdate(change1, changeOwner);
-    update.setPatchSetId(psId2);
-    plcUtil.upsertComments(db, update, drafts1);
-    update.commit();
-
-    update = newUpdate(change2, changeOwner);
-    update.setPatchSetId(psId3);
-    plcUtil.upsertComments(db, update, drafts2);
-    update.commit();
-
-    ChangeControl ctl = stubChangeControl(change1);
-    revRes1 = new RevisionResource(new ChangeResource(ctl), ps1);
-    revRes2 = new RevisionResource(new ChangeResource(ctl), ps2);
-    revRes3 = new RevisionResource(new ChangeResource(stubChangeControl(change2)), ps3);
-  }
-
-  private ChangeControl stubChangeControl(Change c) throws OrmException {
-    return TestChanges.stubChangeControl(
-        repoManager, migration, c, allUsers, changeOwner);
-  }
-
-  private Change newChange() {
-    return TestChanges.newChange(project, changeOwner.getAccountId());
-  }
-
-  private ChangeUpdate newUpdate(Change c, final IdentifiedUser user) throws Exception {
-    return TestChanges.newUpdate(
-        injector, repoManager, migration, c, allUsers, user);
-  }
-
-  @Test
-  public void testListComments() throws Exception {
-    // test ListComments for patch set 1
-    assertListComments(revRes1, ImmutableMap.of(
-        "FileOne.txt", Lists.newArrayList(plc3, plc1, plc2)));
-
-    // test ListComments for patch set 2
-    assertListComments(revRes2,
-        Collections.<String, List<PatchLineComment>>emptyMap());
-  }
-
-  @Test
-  public void testGetCommentExisting() throws Exception {
-    // test GetComment for existing comment
-    String uuid = plc1.getKey().get();
-    CommentResource commentRes = comments.parse(revRes1, IdString.fromUrl(uuid));
-    CommentInfo actual = getComment.apply(commentRes);
-    assertComment(plc1, actual, true);
-  }
-
-  @Test
-  public void testGetCommentNotExisting() throws Exception {
-    // test GetComment for non-existent comment
-    exception.expect(ResourceNotFoundException.class);
-    comments.parse(revRes1, IdString.fromUrl("BadComment"));
-  }
-
-  @Test
-  public void testListDrafts() throws Exception {
-    // test ListDrafts for patch set 1
-    assertListDrafts(revRes1,
-        Collections.<String, List<PatchLineComment>> emptyMap());
-
-    // test ListDrafts for patch set 2
-    assertListDrafts(revRes2, ImmutableMap.of(
-        "FileOne.txt", Lists.newArrayList(plc4, plc5)));
-  }
-
-  @Test
-  public void testPatchLineCommentsUtilByCommentStatus() throws OrmException {
-    assertThat(plcUtil.publishedByChange(db, revRes2.getNotes()))
-        .containsExactly(plc3, plc1, plc2).inOrder();
-    assertThat(plcUtil.draftByChange(db, revRes2.getNotes()))
-        .containsExactly(plc4, plc5).inOrder();
-  }
-
-  @Test
-  public void testPatchLineCommentsUtilDraftByChangeAuthor() throws Exception {
-    assertThat(plcUtil.draftByChangeAuthor(db, revRes1.getNotes(), ownerId))
-        .containsExactly(plc4, plc5).inOrder();
-    assertThat(plcUtil.draftByChangeAuthor(db, revRes3.getNotes(), ownerId))
-        .containsExactly(plc6);
-  }
-
-  private static IAnswer<ResultSet<PatchLineComment>> results(
-      final PatchLineComment... comments) {
-    return new IAnswer<ResultSet<PatchLineComment>>() {
-      @Override
-      public ResultSet<PatchLineComment> answer() throws Throwable {
-        return new ListResultSet<>(Lists.newArrayList(comments));
-      }
-    };
-  }
-
-  private void assertListComments(RevisionResource res,
-      Map<String, ? extends List<PatchLineComment>> expected) throws Exception {
-    assertCommentMap(comments.list().apply(res), expected, true);
-  }
-
-  private void assertListDrafts(RevisionResource res,
-      Map<String, ? extends List<PatchLineComment>> expected) throws Exception {
-    assertCommentMap(drafts.list().apply(res), expected, false);
-  }
-
-  private void assertCommentMap(Map<String, List<CommentInfo>> actual,
-      Map<String, ? extends List<PatchLineComment>> expected,
-      boolean isPublished) {
-    assertThat(actual.keySet()).containsExactlyElementsIn(expected.keySet());
-    for (Map.Entry<String, List<CommentInfo>> entry : actual.entrySet()) {
-      List<CommentInfo> actualList = entry.getValue();
-      List<PatchLineComment> expectedList = expected.get(entry.getKey());
-      assertThat(actualList).hasSize(expectedList.size());
-      for (int i = 0; i < expectedList.size(); i++) {
-        assertComment(expectedList.get(i), actualList.get(i), isPublished);
-      }
-    }
-  }
-
-  private static void assertComment(PatchLineComment plc, CommentInfo ci,
-      boolean isPublished) {
-    assertThat(ci.id).isEqualTo(plc.getKey().get());
-    assertThat(ci.inReplyTo).isEqualTo(plc.getParentUuid());
-    assertThat(ci.message).isEqualTo(plc.getMessage());
-    if (isPublished) {
-      assertThat(ci.author).isNotNull();
-      assertThat(new Account.Id(ci.author._accountId))
-          .isEqualTo(plc.getAuthor());
-    }
-    assertThat(ci.line).isEqualTo(plc.getLine());
-    assertThat(MoreObjects.firstNonNull(ci.side, Side.REVISION))
-        .isEqualTo(plc.getSide() == 0 ? Side.PARENT : Side.REVISION);
-    assertThat(TimeUtil.roundToSecond(ci.updated))
-        .isEqualTo(TimeUtil.roundToSecond(plc.getWrittenOn()));
-    assertThat(ci.updated).isEqualTo(plc.getWrittenOn());
-    assertThat(ci.range.startLine).isEqualTo(plc.getRange().getStartLine());
-    assertThat(ci.range.startCharacter)
-        .isEqualTo(plc.getRange().getStartCharacter());
-    assertThat(ci.range.endLine).isEqualTo(plc.getRange().getEndLine());
-    assertThat(ci.range.endCharacter)
-        .isEqualTo(plc.getRange().getEndCharacter());
-  }
-
-  private static PatchLineComment newPatchLineComment(PatchSet.Id psId,
-      String uuid, String inReplyToUuid, String filename, Side side, int line,
-      Account.Id authorId, long millis, String message, CommentRange range,
-      PatchLineComment.Status status) {
-    Patch.Key p = new Patch.Key(psId, filename);
-    PatchLineComment.Key id = new PatchLineComment.Key(p, uuid);
-    PatchLineComment plc =
-        new PatchLineComment(id, line, authorId, inReplyToUuid, TimeUtil.nowTs());
-    plc.setMessage(message);
-    plc.setRange(range);
-    plc.setSide(side == Side.PARENT ? (short) 0 : (short) 1);
-    plc.setStatus(status);
-    plc.setWrittenOn(new Timestamp(millis));
-    return plc;
-  }
-
-  private static PatchLineComment newPatchLineComment(PatchSet.Id psId,
-      String uuid, String inReplyToUuid, String filename, Side side, int line,
-      Account.Id authorId, long millis, String message, CommentRange range) {
-    return newPatchLineComment(psId, uuid, inReplyToUuid, filename, side, line,
-        authorId, millis, message, range, Status.PUBLISHED);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
index 4cd31ab..3e19366 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.data.IncludedInDetail;
-
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -138,7 +136,7 @@
   @Test
   public void resolveLatestCommit() throws Exception {
     // Check tip commit
-    IncludedInDetail detail = resolve(commit_v2_5);
+    IncludedInResolver.Result detail = resolve(commit_v2_5);
 
     // Check that only tags and branches which refer the tip are returned
     expTags.add(TAG_2_5);
@@ -152,7 +150,7 @@
   @Test
   public void resolveFirstCommit() throws Exception {
     // Check first commit
-    IncludedInDetail detail = resolve(commit_initial);
+    IncludedInResolver.Result detail = resolve(commit_initial);
 
     // Check whether all tags and branches are returned
     expTags.add(TAG_1_0);
@@ -176,7 +174,7 @@
   @Test
   public void resolveBetwixtCommit() throws Exception {
     // Check a commit somewhere in the middle
-    IncludedInDetail detail = resolve(commit_v1_3);
+    IncludedInResolver.Result detail = resolve(commit_v1_3);
 
     // Check whether all succeeding tags and branches are returned
     expTags.add(TAG_1_3);
@@ -190,7 +188,7 @@
     assertEquals(expBranches, detail.getBranches());
   }
 
-  private IncludedInDetail resolve(RevCommit commit) throws Exception {
+  private IncludedInResolver.Result resolve(RevCommit commit) throws Exception {
     return IncludedInResolver.resolve(db, revWalk, commit);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
index cb2d5a12..4f2166d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
@@ -355,7 +355,7 @@
       throws Exception {
     Project.NameKey project = tr.getRepository().getDescription().getProject();
     Change c = TestChanges.newChange(project, userId);
-    ChangeData cd = ChangeData.createForTest(c.getId(), 1);
+    ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
     cd.setChange(c);
     cd.currentPatchSet().setRevision(new RevId(id.name()));
     cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
index 4f409d1..6282415 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -27,6 +27,8 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
+import java.util.List;
+import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
 public class ConfigUtilTest {
@@ -47,8 +49,11 @@
     public Boolean bd;
     public String s;
     public String sd;
+    public String nd;
     public Theme t;
     public Theme td;
+    public List<String> list;
+    public Map<String, String> map;
     static SectionInfo defaults() {
       SectionInfo i = new SectionInfo();
       i.i = 1;
@@ -62,6 +67,7 @@
       i.bd = true;
       i.s = "foo";
       i.sd = "bar";
+      // i.nd = null; // Don't need to explicitly set it; it's null by default
       i.t = Theme.DEFAULT;
       i.td = Theme.DEFAULT;
       return i;
@@ -96,6 +102,7 @@
     assertThat(cfg.getLong(SECT, SUB, "ll", 0L)).isEqualTo(in.ll);
     assertThat(cfg.getString(SECT, SUB, "s")).isEqualTo(in.s);
     assertThat(cfg.getString(SECT, SUB, "sd")).isNull();
+    assertThat(cfg.getString(SECT, SUB, "nd")).isNull();
 
     SectionInfo out = new SectionInfo();
     ConfigUtil.loadSection(cfg, SECT, SUB, out, d, null);
@@ -110,6 +117,7 @@
     assertThat(out.bd).isNull();
     assertThat(out.s).isEqualTo(in.s);
     assertThat(out.sd).isEqualTo(d.sd);
+    assertThat(out.nd).isNull();
     assertThat(out.t).isEqualTo(in.t);
     assertThat(out.td).isEqualTo(d.td);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
index b03a381..12e563f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -26,14 +26,14 @@
 
   @Test
   public void testValidPathSeparator() {
-    for(char c : VALID_CHARACTERS.toCharArray()) {
+    for (char c : VALID_CHARACTERS.toCharArray()) {
       assertTrue("valid character rejected: " + c, GitwebConfig.isValidPathSeparator(c));
     }
   }
 
   @Test
   public void testInalidPathSeparator() {
-    for(char c : SOME_INVALID_CHARACTERS.toCharArray()) {
+    for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
       assertFalse("invalid character accepted: " + c, GitwebConfig.isValidPathSeparator(c));
     }
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
index 1fb6d81..bf36738 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project.NameKey;
 
@@ -24,6 +24,8 @@
 import org.junit.Before;
 import org.junit.Test;
 
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.List;
 
 public class RepositoryConfigTest {
@@ -95,48 +97,46 @@
 
   @Test
   public void testOwnerGroupsWhenNotConfigured() {
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEqualTo(
-        new String[] {});
+    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEmpty();
   }
 
   @Test
   public void testOwnerGroupsForStarFilter() {
-    String[] ownerGroups = new String[] {"group1", "group2"};
-    configureOwnerGroups("*", Lists.newArrayList(ownerGroups));
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEqualTo(
-        ownerGroups);
+    ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
+    configureOwnerGroups("*", ownerGroups);
+    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+        .containsExactlyElementsIn(ownerGroups);
   }
 
   @Test
   public void testOwnerGroupsForSpecificFilter() {
-    String[] ownerGroups = new String[] {"group1", "group2"};
-    configureOwnerGroups("someProject", Lists.newArrayList(ownerGroups));
+    ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
+    configureOwnerGroups("someProject", ownerGroups);
     assertThat(repoCfg.getOwnerGroups(new NameKey("someOtherProject")))
-        .isEqualTo(new String[] {});
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEqualTo(
-        ownerGroups);
+        .isEmpty();
+    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+        .containsExactlyElementsIn(ownerGroups);
   }
 
   @Test
   public void testOwnerGroupsForStartWithFilter() {
-    String[] ownerGroups1 = new String[] {"group1"};
-    String[] ownerGroups2 = new String[] {"group2"};
-    String[] ownerGroups3 = new String[] {"group3"};
+    ImmutableList<String> ownerGroups1 = ImmutableList.of("group1");
+    ImmutableList<String> ownerGroups2 = ImmutableList.of("group2");
+    ImmutableList<String> ownerGroups3 = ImmutableList.of("group3");
 
-    configureOwnerGroups("*", Lists.newArrayList(ownerGroups1));
-    configureOwnerGroups("somePath/*", Lists.newArrayList(ownerGroups2));
-    configureOwnerGroups("somePath/somePath/*",
-        Lists.newArrayList(ownerGroups3));
+    configureOwnerGroups("*", ownerGroups1);
+    configureOwnerGroups("somePath/*", ownerGroups2);
+    configureOwnerGroups("somePath/somePath/*", ownerGroups3);
 
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEqualTo(
-        ownerGroups1);
+    assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
+        .containsExactlyElementsIn(ownerGroups1);
 
     assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/someProject")))
-        .isEqualTo(ownerGroups2);
+        .containsExactlyElementsIn(ownerGroups2);
 
     assertThat(
         repoCfg.getOwnerGroups(new NameKey("somePath/somePath/someProject")))
-        .isEqualTo(ownerGroups3);
+        .containsExactlyElementsIn(ownerGroups3);
   }
 
   private void configureOwnerGroups(String projectFilter,
@@ -144,4 +144,69 @@
     cfg.setStringList(RepositoryConfig.SECTION_NAME, projectFilter,
         RepositoryConfig.OWNER_GROUP_NAME, ownerGroups);
   }
+
+  @Test
+  public void testBasePathWhenNotConfigured() {
+    assertThat((Object)repoCfg.getBasePath(new NameKey("someProject"))).isNull();
+  }
+
+  @Test
+  public void testBasePathForStarFilter() {
+    String basePath = "/someAbsolutePath/someDirectory";
+    configureBasePath("*", basePath);
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
+        .isEqualTo(basePath);
+  }
+
+  @Test
+  public void testBasePathForSpecificFilter() {
+    String basePath = "/someAbsolutePath/someDirectory";
+    configureBasePath("someProject", basePath);
+    assertThat((Object) repoCfg.getBasePath(new NameKey("someOtherProject")))
+        .isNull();
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
+        .isEqualTo(basePath);
+  }
+
+  @Test
+  public void testBasePathForStartWithFilter() {
+    String basePath1 = "/someAbsolutePath1/someDirectory";
+    String basePath2 = "someRelativeDirectory2";
+    String basePath3 = "/someAbsolutePath3/someDirectory";
+    String basePath4 = "/someAbsolutePath4/someDirectory";
+
+    configureBasePath("pro*", basePath1);
+    configureBasePath("project/project/*", basePath2);
+    configureBasePath("project/*", basePath3);
+    configureBasePath("*", basePath4);
+
+    assertThat(repoCfg.getBasePath(new NameKey("project1")).toString())
+        .isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(new NameKey("project/project/someProject"))
+        .toString()).isEqualTo(basePath2);
+    assertThat(
+        repoCfg.getBasePath(new NameKey("project/someProject")).toString())
+            .isEqualTo(basePath3);
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
+        .isEqualTo(basePath4);
+  }
+
+  @Test
+  public void testAllBasePath() {
+    ImmutableList<Path> allBasePaths = ImmutableList.of(
+        Paths.get("/someBasePath1"),
+        Paths.get("/someBasePath2"),
+        Paths.get("/someBasePath2"));
+
+    configureBasePath("*", allBasePaths.get(0).toString());
+    configureBasePath("project/*", allBasePaths.get(1).toString());
+    configureBasePath("project/project/*", allBasePaths.get(2).toString());
+
+    assertThat(repoCfg.getAllBasePaths()).isEqualTo(allBasePaths);
+  }
+
+  private void configureBasePath(String projectFilter, String basePath) {
+    cfg.setString(RepositoryConfig.SECTION_NAME, projectFilter,
+        RepositoryConfig.BASE_PATH_NAME, basePath);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
index 743e43d..8cdd42b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
@@ -21,10 +21,9 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.testutil.GerritBaseTests;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.io.IOException;
 import java.nio.file.Files;
@@ -32,10 +31,7 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 
-public class SitePathsTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class SitePathsTest extends GerritBaseTests {
   @Test
   public void testCreate_NotExisting() throws IOException {
     final Path root = random();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
new file mode 100644
index 0000000..6a006cd
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import org.junit.Test;
+
+public class EventDeserializerTest {
+
+  @Test
+  public void testRefUpdatedEvent() {
+    RefUpdatedEvent refUpdatedEvent = new RefUpdatedEvent();
+
+    RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
+    refUpdatedAttribute.refName = "refs/heads/master";
+    refUpdatedEvent.refUpdate = createSupplier(refUpdatedAttribute);
+
+    AccountAttribute accountAttribute = new AccountAttribute();
+    accountAttribute.email = "some.user@domain.com";
+    refUpdatedEvent.submitter = createSupplier(accountAttribute);
+
+    Gson gsonSerializer = new GsonBuilder()
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer()).create();
+    String serializedEvent = gsonSerializer.toJson(refUpdatedEvent);
+
+    Gson gsonDeserializer = new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventDeserializer())
+        .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+        .create();
+
+    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer
+        .fromJson(serializedEvent, Event.class);
+
+    assertThat(e).isNotNull();
+    assertThat(e.refUpdate).isInstanceOf(Supplier.class);
+    assertThat(e.refUpdate.get().refName)
+        .isEqualTo(refUpdatedAttribute.refName);
+    assertThat(e.submitter).isInstanceOf(Supplier.class);
+    assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
+  }
+
+  private <T> Supplier<T> createSupplier(final T value) {
+    return Suppliers.memoize(new Supplier<T>() {
+      @Override
+      public T get() {
+        return value;
+      }
+    });
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
index 7eed35f..3cbb59c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
@@ -20,12 +20,14 @@
 
 public class EventTypesTest {
   public static class TestEvent extends Event {
+    private static final String TYPE = "test-event";
     public TestEvent() {
-      super("test-event");
+      super(TYPE);
     }
   }
 
   public static class AnotherTestEvent extends Event {
+    private static final String TYPE = "another-test-event";
     public AnotherTestEvent() {
       super("another-test-event");
     }
@@ -33,10 +35,10 @@
 
   @Test
   public void testEventTypeRegistration() {
-    EventTypes.registerClass(new TestEvent());
-    EventTypes.registerClass(new AnotherTestEvent());
-    assertThat(EventTypes.getClass("test-event")).isEqualTo(TestEvent.class);
-    assertThat(EventTypes.getClass("another-test-event"))
+    EventTypes.register(TestEvent.TYPE, TestEvent.class);
+    EventTypes.register(AnotherTestEvent.TYPE, AnotherTestEvent.class);
+    assertThat(EventTypes.getClass(TestEvent.TYPE)).isEqualTo(TestEvent.class);
+    assertThat(EventTypes.getClass(AnotherTestEvent.TYPE))
       .isEqualTo(AnotherTestEvent.class);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
new file mode 100644
index 0000000..87bfa00
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
@@ -0,0 +1,134 @@
+package com.google.gerrit.server.git;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoOnlyOp;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BatchUpdateTest {
+  @Inject
+  private AccountManager accountManager;
+
+  @Inject
+  private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  private InMemoryDatabase schemaFactory;
+
+  @Inject
+  private InMemoryRepositoryManager repoManager;
+
+  @Inject
+  private SchemaCreator schemaCreator;
+
+  @Inject
+  private ThreadLocalRequestContext requestContext;
+
+  @Inject
+  private BatchUpdate.Factory batchUpdateFactory;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private Project.NameKey project;
+  private IdentifiedUser user;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
+        .getAccountId();
+    user = userFactory.create(userId);
+
+    project = new Project.NameKey("test");
+
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return user;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void addRefUpdateFromFastForwardCommit() throws Exception {
+    final RevCommit masterCommit = repo.branch("master").commit().create();
+    final RevCommit branchCommit =
+        repo.branch("branch").commit().parent(masterCommit).create();
+
+    try (BatchUpdate bu = batchUpdateFactory
+        .create(db, project, user, TimeUtil.nowTs())) {
+      bu.addRepoOnlyOp(new RepoOnlyOp() {
+        @Override
+        public void updateRepo(RepoContext ctx) throws Exception {
+          ctx.addRefUpdate(
+              new ReceiveCommand(masterCommit.getId(), branchCommit.getId(),
+                  "refs/heads/master"));
+        }
+      });
+      bu.execute();
+    }
+
+    assertEquals(
+        repo.getRepository().exactRef("refs/heads/master").getObjectId(),
+        branchCommit.getId());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
index 2304ece..fb046fd 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
@@ -18,7 +18,6 @@
 import static org.easymock.EasyMock.createNiceMock;
 import static org.easymock.EasyMock.replay;
 
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 
@@ -27,6 +26,7 @@
 import org.junit.Test;
 
 import java.io.IOException;
+import java.util.HashSet;
 import java.util.Set;
 
 public class DestinationListTest extends TestCase {
@@ -61,7 +61,7 @@
   public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR);
   public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
 
-  public static final Set<Branch.NameKey> D_SIMPLE = Sets.newHashSet();
+  public static final Set<Branch.NameKey> D_SIMPLE = new HashSet<>();
   static {
     D_SIMPLE.clear();
     D_SIMPLE.add(B_FOO);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
index b3f7894..aa23e50 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -41,11 +42,14 @@
 import com.google.gerrit.server.git.LabelNormalizer.Result;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.lib.Repository;
@@ -66,6 +70,7 @@
   @Inject private MetaDataUpdate.User metaDataUpdateFactory;
   @Inject private ProjectCache projectCache;
   @Inject private SchemaCreator schemaCreator;
+  @Inject protected ThreadLocalRequestContext requestContext;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
@@ -85,7 +90,19 @@
     schemaCreator.create(db);
     userId = accountManager.authenticate(AuthRequest.forUser("user"))
         .getAccountId();
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
+
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return user;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
 
     configureProject();
     setUpChange();
@@ -123,6 +140,7 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
+    requestContext.setContext(null);
     if (db != null) {
       db.close();
     }
@@ -196,10 +214,11 @@
   }
 
   private void save(ProjectConfig pc) throws Exception {
-    MetaDataUpdate md =
-        metaDataUpdateFactory.create(pc.getProject().getNameKey(), user);
-    pc.commit(md);
-    projectCache.evict(pc.getProject().getNameKey());
+    try (MetaDataUpdate md =
+        metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
+      pc.commit(md);
+      projectCache.evict(pc.getProject().getNameKey());
+    }
   }
 
   private PatchSetApproval psa(Account.Id accountId, String label, int value) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index ca154b1..86fa0db 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.StandardKeyEncoder;
@@ -53,15 +52,13 @@
     site.resolve("git").toFile().mkdir();
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
-    repoManager =
-        new LocalDiskRepositoryManager(site, cfg,
-            createNiceMock(NotesMigration.class));
+    repoManager = new LocalDiskRepositoryManager(site, cfg);
+    repoManager.start();
   }
 
   @Test(expected = IllegalStateException.class)
   public void testThatNullBasePathThrowsAnException() {
-    new LocalDiskRepositoryManager(site, new Config(),
-        createNiceMock(NotesMigration.class));
+    new LocalDiskRepositoryManager(site, new Config());
   }
 
   @Test
@@ -169,8 +166,8 @@
 
   @Test
   public void testOpenRepositoryCreatedDirectlyOnDisk() throws Exception {
-    createRepository(repoManager.getBasePath(), "projectA");
     Project.NameKey projectA = new Project.NameKey("projectA");
+    createRepository(repoManager.getBasePath(projectA), projectA.get());
     try (Repository repo = repoManager.openRepository(projectA)) {
       assertThat(repo).isNotNull();
     }
@@ -185,17 +182,17 @@
   @Test
   public void testList() throws Exception {
     Project.NameKey projectA = new Project.NameKey("projectA");
-    createRepository(repoManager.getBasePath(), projectA.get());
+    createRepository(repoManager.getBasePath(projectA), projectA.get());
 
     Project.NameKey projectB = new Project.NameKey("path/projectB");
-    createRepository(repoManager.getBasePath(), projectB.get());
+    createRepository(repoManager.getBasePath(projectB), projectB.get());
 
     Project.NameKey projectC = new Project.NameKey("anotherPath/path/projectC");
-    createRepository(repoManager.getBasePath(), projectC.get());
+    createRepository(repoManager.getBasePath(projectC), projectC.get());
     // create an invalid git repo named only .git
-    repoManager.getBasePath().resolve(".git").toFile().mkdir();
+    repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
     // create an invalid repo name
-    createRepository(repoManager.getBasePath(), "project?A");
+    createRepository(repoManager.getBasePath(null), "project?A");
     assertThat(repoManager.list())
         .containsExactly(projectA, projectB, projectC);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
new file mode 100644
index 0000000..b26a228
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.testutil.TempFileUtil;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.SortedSet;
+
+public class MultiBaseLocalDiskRepositoryManagerTest {
+
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  private Config cfg;
+  private SitePaths site;
+  private MultiBaseLocalDiskRepositoryManager repoManager;
+  private RepositoryConfig configMock;
+
+  @Before
+  public void setUp() throws IOException {
+    site = new SitePaths(TempFileUtil.createTempDirectory().toPath());
+    site.resolve("git").toFile().mkdir();
+    cfg = new Config();
+    cfg.setString("gerrit", null, "basePath", "git");
+    configMock = createNiceMock(RepositoryConfig.class);
+    expect(configMock.getAllBasePaths()).andReturn(new ArrayList<Path>()).anyTimes();
+    replay(configMock);
+    repoManager =
+        new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+  }
+
+  @After
+  public void tearDown() throws IOException {
+    TempFileUtil.cleanup();
+  }
+
+  @Test
+  public void testDefaultRepositoryLocation()
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    Repository repo = repoManager.createRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent()).isEqualTo(
+        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    repo = repoManager.openRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent()).isEqualTo(
+        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    assertThat(
+        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
+        .isEqualTo(
+            repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {someProjectKey});
+  }
+
+  @Test
+  public void testAlternateRepositoryLocation() throws IOException {
+    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
+    Project.NameKey someProjectKey = new Project.NameKey("someProject");
+    reset(configMock);
+    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath)
+        .anyTimes();
+    expect(configMock.getAllBasePaths())
+        .andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    replay(configMock);
+
+    Repository repo = repoManager.createRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(alternateBasePath.toString());
+
+    repo = repoManager.openRepository(someProjectKey);
+    assertThat(repo.getDirectory()).isNotNull();
+    assertThat(repo.getDirectory().exists()).isTrue();
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(alternateBasePath.toString());
+
+    assertThat(
+        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
+            .isEqualTo(alternateBasePath.toString());
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {someProjectKey});
+  }
+
+  @Test
+  public void testListReturnRepoFromProperLocation() throws IOException {
+    Project.NameKey basePathProject = new Project.NameKey("basePathProject");
+    Project.NameKey altPathProject = new Project.NameKey("altPathProject");
+    Project.NameKey misplacedProject1 =
+        new Project.NameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 =
+        new Project.NameKey("misplacedProject2");
+
+    Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
+
+    reset(configMock);
+    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath)
+        .anyTimes();
+    expect(configMock.getBasePath(misplacedProject2))
+        .andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getAllBasePaths())
+        .andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    replay(configMock);
+
+    repoManager.createRepository(basePathProject);
+    repoManager.createRepository(altPathProject);
+    // create the misplaced ones without the repomanager otherwise they would
+    // end up at the proper place.
+    createRepository(repoManager.getBasePath(basePathProject),
+        misplacedProject2);
+    createRepository(alternateBasePath, misplacedProject1);
+
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    assertThat(repoList.size()).isEqualTo(2);
+    assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
+        .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
+  }
+
+  private void createRepository(Path directory, Project.NameKey projectName)
+      throws IOException {
+    String n = projectName.get() + Constants.DOT_GIT_EXT;
+    FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
+    try (Repository db = RepositoryCache.open(loc, false)) {
+      db.create(true /* bare */);
+    }
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testRelativeAlternateLocation() {
+    configMock = createNiceMock(RepositoryConfig.class);
+    expect(configMock.getAllBasePaths())
+        .andReturn(Arrays.asList(Paths.get("repos"))).anyTimes();
+    replay(configMock);
+    repoManager =
+        new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
index 4f32ba8..0757a26 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
@@ -49,6 +49,18 @@
 import java.util.Map;
 
 public class ProjectConfigTest extends LocalDiskRepositoryTestCase {
+  private static final String LABEL_SCORES_CONFIG =
+      "  copyMinScore = " + !LabelType.DEF_COPY_MIN_SCORE + "\n" //
+      + "  copyMaxScore = " + !LabelType.DEF_COPY_MAX_SCORE + "\n" //
+      + "  copyAllScoresOnMergeFirstParentUpdate = "
+      + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE + "\n" //
+      + "  copyAllScoresOnTrivialRebase = "
+      + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE + "\n" //
+      + "  copyAllScoresIfNoCodeChange = "
+      + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE + "\n" //
+      + "  copyAllScoresIfNoChange = "
+      + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE + "\n";
+
   private final GroupReference developers = new GroupReference(
       new AccountGroup.UUID("X"), "Developers");
   private final GroupReference staff = new GroupReference(
@@ -167,6 +179,30 @@
   }
 
   @Test
+  public void testReadConfigLabelScores() throws Exception {
+    RevCommit rev = util.commit(util.tree( //
+        util.file("groups", util.blob(group(developers))), //
+        util.file("project.config", util.blob(""//
+            + "[label \"CustomLabel\"]\n" //
+            + LABEL_SCORES_CONFIG)) //
+        ));
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    LabelType type = labels.entrySet().iterator().next().getValue();
+    assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
+    assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
+    assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
+      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    assertThat(type.isCopyAllScoresOnTrivialRebase())
+      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    assertThat(type.isCopyAllScoresIfNoCodeChange())
+      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    assertThat(type.isCopyAllScoresIfNoChange())
+      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+  }
+
+  @Test
   public void testEditConfig() throws Exception {
     RevCommit rev = util.commit(util.tree( //
         util.file("groups", util.blob(group(developers))), //
@@ -183,7 +219,9 @@
             + "  description = A simple description\n" //
             + "  accepted = group Developers\n" //
             + "  autoVerify = group Developers\n" //
-            + "  agreementUrl = http://www.example.com/agree\n")) //
+            + "  agreementUrl = http://www.example.com/agree\n" //
+            + "[label \"CustomLabel\"]\n" //
+            + LABEL_SCORES_CONFIG)) //
         ));
     update(rev);
 
@@ -210,7 +248,11 @@
         + "[contributor-agreement \"Individual\"]\n" //
         + "  description = A new description\n" //
         + "  accepted = group Staff\n" //
-        + "  agreementUrl = http://www.example.com/agree\n");
+        + "  agreementUrl = http://www.example.com/agree\n"
+        + "[label \"CustomLabel\"]\n" //
+        + LABEL_SCORES_CONFIG
+        + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
+        + "\tdefaultValue = 0\n"); //  label gets this value when it is created
   }
 
   @Test
@@ -249,17 +291,18 @@
 
   private RevCommit commit(ProjectConfig cfg) throws IOException,
       MissingObjectException, IncorrectObjectTypeException {
-    MetaDataUpdate md = new MetaDataUpdate(
-        GitReferenceUpdated.DISABLED,
-        cfg.getProject().getNameKey(),
-        db);
-    util.tick(5);
-    util.setAuthorAndCommitter(md.getCommitBuilder());
-    md.setMessage("Edit\n");
-    cfg.commit(md);
+    try (MetaDataUpdate md = new MetaDataUpdate(
+          GitReferenceUpdated.DISABLED,
+          cfg.getProject().getNameKey(),
+          db)) {
+      util.tick(5);
+      util.setAuthorAndCommitter(md.getCommitBuilder());
+      md.setMessage("Edit\n");
+      cfg.commit(md);
 
-    Ref ref = db.getRef(RefNames.REFS_CONFIG);
-    return util.getRevWalk().parseCommit(ref.getObjectId());
+      Ref ref = db.exactRef(RefNames.REFS_CONFIG);
+      return util.getRevWalk().parseCommit(ref.getObjectId());
+    }
   }
 
   private void update(RevCommit rev) throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeIndex.java
deleted file mode 100644
index d38da43..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeIndex.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.QueryOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-class FakeIndex implements ChangeIndex {
-  static Schema<ChangeData> V1 = new Schema<>(1,
-    ImmutableList.<FieldDef<ChangeData, ?>> of(
-      ChangeField.STATUS));
-
-  static Schema<ChangeData> V2 = new Schema<>(2,
-    ImmutableList.of(
-      ChangeField.STATUS,
-      ChangeField.PATH,
-      ChangeField.UPDATED));
-
-  private static class Source implements ChangeDataSource {
-    private final Predicate<ChangeData> p;
-
-    Source(Predicate<ChangeData> p) {
-      this.p = p;
-    }
-
-    @Override
-    public int getCardinality() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public boolean hasChange() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public String toString() {
-      return p.toString();
-    }
-  }
-
-  private final Schema<ChangeData> schema;
-
-  FakeIndex(Schema<ChangeData> schema) {
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(ChangeData cd) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void delete(Change.Id id) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void deleteAll() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    return new FakeIndex.Source(p);
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    throw new UnsupportedOperationException();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java
deleted file mode 100644
index 63ae818..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.server.query.OperatorPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gwtorm.server.OrmException;
-
-public class FakeQueryBuilder extends ChangeQueryBuilder {
-  FakeQueryBuilder(IndexCollection indexes) {
-    super(
-        new FakeQueryBuilder.Definition<>(
-          FakeQueryBuilder.class),
-        new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
-            null, null, null, null, null, null, null, null, null, null, null,
-            null, indexes, null, null, null, null, null, null));
-  }
-
-  @Operator
-  public Predicate<ChangeData> foo(String value) {
-    return predicate("foo", value);
-  }
-
-  @Operator
-  public Predicate<ChangeData> bar(String value) {
-    return predicate("bar", value);
-  }
-
-  private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {
-      @Override
-      public boolean match(ChangeData object) throws OrmException {
-        return false;
-      }
-
-      @Override
-      public int getCost() {
-        return 0;
-      }
-    };
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java
deleted file mode 100644
index 9ac83d5..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java
+++ /dev/null
@@ -1,298 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
-import static com.google.gerrit.reviewdb.client.Change.Status.ABANDONED;
-import static com.google.gerrit.reviewdb.client.Change.Status.DRAFT;
-import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
-import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
-import static com.google.gerrit.server.index.IndexedChangeQuery.convertOptions;
-import static com.google.gerrit.server.query.Predicate.and;
-import static com.google.gerrit.server.query.Predicate.or;
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.AndSource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.OrSource;
-import com.google.gerrit.server.query.change.QueryOptions;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-import java.util.Arrays;
-import java.util.EnumSet;
-import java.util.Set;
-
-public class IndexRewriterTest {
-  private static final IndexConfig CONFIG = IndexConfig.createDefault();
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
-  private FakeIndex index;
-  private IndexCollection indexes;
-  private ChangeQueryBuilder queryBuilder;
-  private IndexRewriter rewrite;
-
-  @Before
-  public void setUp() throws Exception {
-    index = new FakeIndex(FakeIndex.V2);
-    indexes = new IndexCollection();
-    indexes.setSearchIndex(index);
-    queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new IndexRewriter(indexes,
-        IndexConfig.create(0, 0, 3, 100));
-  }
-
-  @Test
-  public void testIndexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("file:a");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-  }
-
-  @Test
-  public void testNonIndexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a");
-    assertThat(in).isSameAs(rewrite(in));
-  }
-
-  @Test
-  public void testIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("file:a file:b");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-  }
-
-  @Test
-  public void testNonIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a OR foo:b");
-    assertThat(in).isEqualTo(rewrite(in));
-  }
-
-  @Test
-  public void testOneIndexPredicate() throws Exception {
-    Predicate<ChangeData> in = parse("foo:a file:b");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(
-            query(in.getChild(1)),
-            in.getChild(0))
-        .inOrder();
-  }
-
-  @Test
-  public void testThreeLevelTreeWithAllIndexPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("-status:abandoned (file:a OR file:b)");
-    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT)))
-        .isEqualTo(query(in));
-  }
-
-  @Test
-  public void testThreeLevelTreeWithSomeIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(AndSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(1)),
-          in.getChild(0))
-        .inOrder();
-  }
-
-  @Test
-  public void testMultipleIndexPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("file:a OR foo:b OR file:c OR foo:d");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(OrSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(or(in.getChild(0), in.getChild(2))),
-          in.getChild(1),
-          in.getChild(3))
-        .inOrder();
-  }
-
-  @Test
-  public void testIndexAndNonIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("status:new bar:p file:a");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void testDuplicateCompoundNonIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("(status:new OR status:draft) bar:p file:a");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void testDuplicateCompoundIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("(status:new OR file:a) bar:p file:b");
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void testOptionsArgumentOverridesAllLimitPredicates()
-      throws Exception {
-    Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
-    Predicate<ChangeData> out = rewrite(in, options(0, 5));
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(1), 5),
-          parse("limit:5"),
-          parse("limit:5"))
-        .inOrder();
-  }
-
-  @Test
-  public void testStartIncreasesLimitInQueryButNotPredicate() throws Exception {
-    int n = 3;
-    Predicate<ChangeData> f = parse("file:a");
-    Predicate<ChangeData> l = parse("limit:" + n);
-    Predicate<ChangeData> in = andSource(f, l);
-    assertThat(rewrite.rewrite(in, options(0, n)))
-        .isEqualTo(andSource(query(f, 3), l));
-    assertThat(rewrite.rewrite(in, options(1, n)))
-        .isEqualTo(andSource(query(f, 4), l));
-    assertThat(rewrite.rewrite(in, options(2, n)))
-        .isEqualTo(andSource(query(f, 5), l));
-  }
-
-  @Test
-  public void testGetPossibleStatus() throws Exception {
-    assertThat(status("file:a")).isEqualTo(EnumSet.allOf(Change.Status.class));
-    assertThat(status("is:new")).containsExactly(NEW);
-    assertThat(status("-is:new"))
-        .containsExactly(DRAFT, MERGED, ABANDONED);
-    assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
-
-    assertThat(status("is:new is:merged")).isEmpty();
-    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
-    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
-
-    assertThat(status("(is:new is:draft) OR (is:merged)"))
-        .containsExactly(MERGED);
-  }
-
-  @Test
-  public void testUnsupportedIndexOperator() throws Exception {
-    Predicate<ChangeData> in = parse("status:merged file:a");
-    assertThat(rewrite(in)).isEqualTo(query(in));
-
-    indexes.setSearchIndex(new FakeIndex(FakeIndex.V1));
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out).isInstanceOf(AndPredicate.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(0)),
-          in.getChild(1))
-        .inOrder();
-  }
-
-  @Test
-  public void testTooManyTerms() throws Exception {
-    String q = "file:a OR file:b OR file:c";
-    Predicate<ChangeData> in = parse(q);
-    assertEquals(query(in), rewrite(in));
-
-    exception.expect(QueryParseException.class);
-    exception.expectMessage("too many terms in query");
-    rewrite(parse(q + " OR file:d"));
-  }
-
-  @Test
-  public void testConvertOptions() throws Exception {
-    assertEquals(options(0, 3), convertOptions(options(0, 3)));
-    assertEquals(options(0, 4), convertOptions(options(1, 3)));
-    assertEquals(options(0, 5), convertOptions(options(2, 3)));
-  }
-
-  @Test
-  public void testAddingStartToLimitDoesNotExceedBackendLimit() throws Exception {
-    int max = CONFIG.maxLimit();
-    assertEquals(options(0, max), convertOptions(options(0, max)));
-    assertEquals(options(0, max), convertOptions(options(1, max)));
-    assertEquals(options(0, max), convertOptions(options(1, max - 1)));
-    assertEquals(options(0, max), convertOptions(options(2, max - 1)));
-  }
-
-  private Predicate<ChangeData> parse(String query) throws QueryParseException {
-    return queryBuilder.parse(query);
-  }
-
-  @SafeVarargs
-  private static AndSource andSource(Predicate<ChangeData>... preds) {
-    return new AndSource(Arrays.asList(preds));
-  }
-
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in)
-      throws QueryParseException {
-    return rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT));
-  }
-
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
-      QueryOptions opts) throws QueryParseException {
-    return rewrite.rewrite(in, opts);
-  }
-
-  private IndexedChangeQuery query(Predicate<ChangeData> p)
-      throws QueryParseException {
-    return query(p, DEFAULT_MAX_QUERY_LIMIT);
-  }
-
-  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit)
-      throws QueryParseException {
-    return new IndexedChangeQuery(index, p, options(0, limit));
-  }
-
-  private static QueryOptions options(int start, int limit) {
-    return QueryOptions.create(CONFIG, start, limit);
-  }
-
-  private Set<Change.Status> status(String query) throws QueryParseException {
-    return IndexRewriter.getPossibleStatus(parse(query));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
new file mode 100644
index 0000000..b623ae8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.index.SchemaUtil.getPersonParts;
+import static com.google.gerrit.server.index.SchemaUtil.schema;
+
+import com.google.gerrit.testutil.GerritBaseTests;
+
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Test;
+
+import java.util.Map;
+
+public class SchemaUtilTest extends GerritBaseTests {
+  static class TestSchemas {
+    static final Schema<String> V1 = schema();
+    static final Schema<String> V2 = schema();
+    static Schema<String> V3 = schema(); // Not final, ignored.
+    private static final Schema<String> V4 = schema();
+
+    // Ignored.
+    static Schema<String> V10 = schema();
+    final Schema<String> V11 = schema();
+  }
+
+  @Test
+  public void schemasFromClassBuildsMap() {
+    Map<Integer, Schema<String>> all =
+        SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
+    assertThat(all.keySet()).containsExactly(1, 2, 4);
+    assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
+    assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
+    assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
+
+    exception.expect(IllegalArgumentException.class);
+    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
+  }
+
+  @Test
+  public void getPersonPartsExtractsParts() {
+    // PersonIdent allows empty email, which should be extracted as the empty
+    // string. However, it converts empty names to null internally.
+    assertThat(getPersonParts(new PersonIdent("", ""))).containsExactly("");
+    assertThat(getPersonParts(new PersonIdent("foo bar", "")))
+        .containsExactly("foo", "bar", "");
+
+    assertThat(getPersonParts(new PersonIdent("", "foo@example.com")))
+        .containsExactly(
+            "foo@example.com", "foo", "example.com", "example", "com");
+    assertThat(
+            getPersonParts(new PersonIdent("foO J. bAr", "bA-z@exAmple.cOm")))
+        .containsExactly(
+            "foo", "j", "bar",
+            "ba-z@example.com", "ba-z", "ba", "z",
+            "example.com", "example", "com");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
new file mode 100644
index 0000000..839d349
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.testutil.GerritBaseTests;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class ChangeFieldTest extends GerritBaseTests {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void reviewerFieldValues() {
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t =
+        HashBasedTable.create();
+    Timestamp t1 = TimeUtil.nowTs();
+    t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
+    Timestamp t2 = TimeUtil.nowTs();
+    t.put(ReviewerStateInternal.CC, new Account.Id(2), t2);
+    ReviewerSet reviewers = ReviewerSet.fromTable(t);
+
+    List<String> values = ChangeField.getReviewerFieldValues(reviewers);
+    assertThat(values).containsExactly(
+        "REVIEWER,1",
+        "REVIEWER,1," + t1.getTime(),
+        "CC,2",
+        "CC,2," + t2.getTime());
+
+    assertThat(ChangeField.parseReviewerFieldValues(values))
+        .isEqualTo(reviewers);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
new file mode 100644
index 0000000..ac7aed7
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -0,0 +1,306 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.reviewdb.client.Change.Status.ABANDONED;
+import static com.google.gerrit.reviewdb.client.Change.Status.DRAFT;
+import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
+import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
+import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
+import static com.google.gerrit.server.query.Predicate.and;
+import static com.google.gerrit.server.query.Predicate.or;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AndChangeSource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
+import com.google.gerrit.testutil.GerritBaseTests;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.Set;
+
+public class ChangeIndexRewriterTest extends GerritBaseTests {
+  private static final IndexConfig CONFIG = IndexConfig.createDefault();
+
+  private FakeChangeIndex index;
+  private ChangeIndexCollection indexes;
+  private ChangeQueryBuilder queryBuilder;
+  private ChangeIndexRewriter rewrite;
+
+  @Before
+  public void setUp() throws Exception {
+    index = new FakeChangeIndex(FakeChangeIndex.V2);
+    indexes = new ChangeIndexCollection();
+    indexes.setSearchIndex(index);
+    queryBuilder = new FakeQueryBuilder(indexes);
+    rewrite = new ChangeIndexRewriter(indexes,
+        IndexConfig.create(0, 0, 3));
+  }
+
+  @Test
+  public void testIndexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("file:a");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+  }
+
+  @Test
+  public void testNonIndexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .inOrder();
+  }
+
+  @Test
+  public void testIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("file:a file:b");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+  }
+
+  @Test
+  public void testNonIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a OR foo:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(query(ChangeStatusPredicate.open()), in)
+        .inOrder();
+  }
+
+  @Test
+  public void testOneIndexPredicate() throws Exception {
+    Predicate<ChangeData> in = parse("foo:a file:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(
+            query(in.getChild(1)),
+            in.getChild(0))
+        .inOrder();
+  }
+
+  @Test
+  public void testThreeLevelTreeWithAllIndexPredicates() throws Exception {
+    Predicate<ChangeData> in =
+        parse("-status:abandoned (file:a OR file:b)");
+    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT)))
+        .isEqualTo(query(in));
+  }
+
+  @Test
+  public void testThreeLevelTreeWithSomeIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameAs(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(in.getChild(1)),
+          in.getChild(0))
+        .inOrder();
+  }
+
+  @Test
+  public void testMultipleIndexPredicates() throws Exception {
+    Predicate<ChangeData> in =
+        parse("file:a OR foo:b OR file:c OR foo:d");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameAs(OrSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(or(in.getChild(0), in.getChild(2))),
+          in.getChild(1),
+          in.getChild(3))
+        .inOrder();
+  }
+
+  @Test
+  public void testIndexAndNonIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("status:new bar:p file:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(and(in.getChild(0), in.getChild(2))),
+          in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void testDuplicateCompoundNonIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in =
+        parse("(status:new OR status:draft) bar:p file:a");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(and(in.getChild(0), in.getChild(2))),
+          in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void testDuplicateCompoundIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in =
+        parse("(status:new OR file:a) bar:p file:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(and(in.getChild(0), in.getChild(2))),
+          in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void testOptionsArgumentOverridesAllLimitPredicates()
+      throws Exception {
+    Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
+    Predicate<ChangeData> out = rewrite(in, options(0, 5));
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(in.getChild(1), 5),
+          parse("limit:5"),
+          parse("limit:5"))
+        .inOrder();
+  }
+
+  @Test
+  public void testStartIncreasesLimitInQueryButNotPredicate() throws Exception {
+    int n = 3;
+    Predicate<ChangeData> f = parse("file:a");
+    Predicate<ChangeData> l = parse("limit:" + n);
+    Predicate<ChangeData> in = andSource(f, l);
+    assertThat(rewrite.rewrite(in, options(0, n)))
+        .isEqualTo(andSource(query(f, 3), l));
+    assertThat(rewrite.rewrite(in, options(1, n)))
+        .isEqualTo(andSource(query(f, 4), l));
+    assertThat(rewrite.rewrite(in, options(2, n)))
+        .isEqualTo(andSource(query(f, 5), l));
+  }
+
+  @Test
+  public void testGetPossibleStatus() throws Exception {
+    assertThat(status("file:a")).isEqualTo(EnumSet.allOf(Change.Status.class));
+    assertThat(status("is:new")).containsExactly(NEW);
+    assertThat(status("-is:new"))
+        .containsExactly(DRAFT, MERGED, ABANDONED);
+    assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
+
+    assertThat(status("is:new is:merged")).isEmpty();
+    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
+    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
+
+    assertThat(status("(is:new is:draft) OR (is:merged)"))
+        .containsExactly(MERGED);
+  }
+
+  @Test
+  public void testUnsupportedIndexOperator() throws Exception {
+    Predicate<ChangeData> in = parse("status:merged file:a");
+    assertThat(rewrite(in)).isEqualTo(query(in));
+
+    indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out).isInstanceOf(AndPredicate.class);
+    assertThat(out.getChildren())
+        .containsExactly(
+          query(in.getChild(0)),
+          in.getChild(1))
+        .inOrder();
+  }
+
+  @Test
+  public void testTooManyTerms() throws Exception {
+    String q = "file:a OR file:b OR file:c";
+    Predicate<ChangeData> in = parse(q);
+    assertEquals(query(in), rewrite(in));
+
+    exception.expect(QueryParseException.class);
+    exception.expectMessage("too many terms in query");
+    rewrite(parse(q + " OR file:d"));
+  }
+
+  @Test
+  public void testConvertOptions() throws Exception {
+    assertEquals(options(0, 3), convertOptions(options(0, 3)));
+    assertEquals(options(0, 4), convertOptions(options(1, 3)));
+    assertEquals(options(0, 5), convertOptions(options(2, 3)));
+  }
+
+  @Test
+  public void testAddingStartToLimitDoesNotExceedBackendLimit() throws Exception {
+    int max = CONFIG.maxLimit();
+    assertEquals(options(0, max), convertOptions(options(0, max)));
+    assertEquals(options(0, max), convertOptions(options(1, max)));
+    assertEquals(options(0, max), convertOptions(options(1, max - 1)));
+    assertEquals(options(0, max), convertOptions(options(2, max - 1)));
+  }
+
+  private Predicate<ChangeData> parse(String query) throws QueryParseException {
+    return queryBuilder.parse(query);
+  }
+
+  @SafeVarargs
+  private static AndChangeSource andSource(Predicate<ChangeData>... preds) {
+    return new AndChangeSource(Arrays.asList(preds));
+  }
+
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in)
+      throws QueryParseException {
+    return rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT));
+  }
+
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
+      QueryOptions opts) throws QueryParseException {
+    return rewrite.rewrite(in, opts);
+  }
+
+  private IndexedChangeQuery query(Predicate<ChangeData> p)
+      throws QueryParseException {
+    return query(p, DEFAULT_MAX_QUERY_LIMIT);
+  }
+
+  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit)
+      throws QueryParseException {
+    return new IndexedChangeQuery(index, p, options(0, limit));
+  }
+
+  private static QueryOptions options(int start, int limit) {
+    return IndexedChangeQuery.createOptions(CONFIG, start, limit,
+        ImmutableSet.<String> of());
+  }
+
+  private Set<Change.Status> status(String query) throws QueryParseException {
+    return ChangeIndexRewriter.getPossibleStatus(parse(query));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
new file mode 100644
index 0000000..ea13ec4
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+import org.junit.Ignore;
+
+@Ignore
+public class FakeChangeIndex implements ChangeIndex {
+  static Schema<ChangeData> V1 = new Schema<>(1,
+    ImmutableList.<FieldDef<ChangeData, ?>> of(
+      ChangeField.STATUS));
+
+  static Schema<ChangeData> V2 = new Schema<>(2,
+    ImmutableList.of(
+      ChangeField.STATUS,
+      ChangeField.PATH,
+      ChangeField.UPDATED));
+
+  private static class Source implements ChangeDataSource {
+    private final Predicate<ChangeData> p;
+
+    Source(Predicate<ChangeData> p) {
+      this.p = p;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+
+    @Override
+    public boolean hasChange() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String toString() {
+      return p.toString();
+    }
+  }
+
+  private final Schema<ChangeData> schema;
+
+  FakeChangeIndex(Schema<ChangeData> schema) {
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(ChangeData cd) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void delete(Change.Id id) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void deleteAll() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new FakeChangeIndex.Source(p);
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void stop() {
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
new file mode 100644
index 0000000..545fd08
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import com.google.gerrit.server.query.OperatorPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+
+import org.junit.Ignore;
+
+@Ignore
+public class FakeQueryBuilder extends ChangeQueryBuilder {
+  FakeQueryBuilder(ChangeIndexCollection indexes) {
+    super(
+        new FakeQueryBuilder.Definition<>(
+          FakeQueryBuilder.class),
+        new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
+          null, null, null, null, null, null, null, null, null, null, null,
+          null, null, null, indexes, null, null, null, null, null, null, null,
+          null));
+  }
+
+  @Operator
+  public Predicate<ChangeData> foo(String value) {
+    return predicate("foo", value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> bar(String value) {
+    return predicate("bar", value);
+  }
+
+  private Predicate<ChangeData> predicate(String name, String value) {
+    return new OperatorPredicate<ChangeData>(name, value) {};
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
index dbc8f02..f6bdeac 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
@@ -156,6 +156,6 @@
 
   private static byte[] b(int a, int b, int c, int d, int e, int f, int g, int h) {
     return new byte[] {(byte) a, (byte) b, (byte) c, (byte) d, //
-        (byte) e, (byte) f, (byte) g, (byte) h};
+        (byte) e, (byte) f, (byte) g, (byte) h,};
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
index fa4ac0e..049e17d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
@@ -29,7 +29,7 @@
     private PrintWriter printWriter;
     private StringWriter stringWriter;
 
-    public PrintWriterComparator() {
+    PrintWriterComparator() {
       stringWriter = new StringWriter();
       printWriter = new PrintWriter(stringWriter);
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
index 145042c..d5f3132 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
-import org.junit.Rule;
+import com.google.gerrit.testutil.GerritBaseTests;
+
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
-public class AddressTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class AddressTest extends GerritBaseTests {
   @Test
   public void testParse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
@@ -100,9 +98,12 @@
   }
 
   private void assertInvalid(final String in) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Invalid email address: " + in);
-    Address.parse(in);
+    try {
+      Address.parse(in);
+      fail("Expected IllegalArgumentException for " + in);
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
+    }
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
index b96b780..11f1d54 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -34,6 +36,8 @@
 import org.junit.Test;
 
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.Set;
 
 public class FromAddressGeneratorProviderTest {
   private Config config;
@@ -298,6 +302,7 @@
     account.setFullName(name);
     account.setPreferredEmail(email);
     return new AccountState(account, Collections.<AccountGroup.UUID> emptySet(),
-          Collections.<AccountExternalId> emptySet());
+        Collections.<AccountExternalId> emptySet(),
+        new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
index 05dc24b..4f2c776 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.mail;
 
-import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
 
 import org.junit.Test;
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index cbd8ff5..fabb53d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.CommentRange;
@@ -29,27 +31,34 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DisableReverseDnsLookup;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.testutil.FakeAccountCache;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
+import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
@@ -60,19 +69,24 @@
 import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Ignore;
 
 import java.sql.Timestamp;
 import java.util.TimeZone;
 
-public class AbstractChangeNotesTest {
+@Ignore
+public abstract class AbstractChangeNotesTest extends GerritBaseTests {
   private static final TimeZone TZ =
       TimeZone.getTimeZone("America/Los_Angeles");
 
-  private static final NotesMigration MIGRATION = NotesMigration.allEnabled();
+  private static final NotesMigration MIGRATION =
+      new TestNotesMigration().setAllEnabled(true);
 
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
@@ -81,15 +95,29 @@
   protected InMemoryRepository repo;
   protected InMemoryRepositoryManager repoManager;
   protected PersonIdent serverIdent;
+  protected InternalUser internalUser;
   protected Project.NameKey project;
+  protected RevWalk rw;
+  protected TestRepository<InMemoryRepository> tr;
 
-  @Inject protected IdentifiedUser.GenericFactory userFactory;
+  @Inject
+  protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  protected NoteDbUpdateManager.Factory updateManagerFactory;
+
+  @Inject
+  protected AllUsersName allUsers;
+
+  @Inject
+  protected ChangeNoteUtil noteUtil;
+
+  @Inject
+  protected AbstractChangeNotes.Args args;
 
   private Injector injector;
   private String systemTimeZone;
 
-  @Inject private AllUsersNameProvider allUsers;
-
   @Before
   public void setUp() throws Exception {
     setTimeForTesting();
@@ -100,6 +128,8 @@
     project = new Project.NameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
     Account co = new Account(new Account.Id(1), TimeUtil.nowTs());
     co.setFullName("Change Owner");
@@ -113,14 +143,19 @@
     injector = Guice.createInjector(new FactoryModule() {
       @Override
       public void configure() {
+        Config cfg = new Config();
         install(new GitModule());
+        install(NoteDbModule.forTest(cfg));
+        bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+        bind(String.class).annotatedWith(GerritServerId.class)
+            .toInstance("gerrit");
         bind(NotesMigration.class).toInstance(MIGRATION);
         bind(GitRepositoryManager.class).toInstance(repoManager);
         bind(ProjectCache.class).toProvider(Providers.<ProjectCache> of(null));
         bind(CapabilityControl.Factory.class)
             .toProvider(Providers.<CapabilityControl.Factory> of(null));
         bind(Config.class).annotatedWith(GerritServerConfig.class)
-            .toInstance(new Config());
+            .toInstance(cfg);
         bind(String.class).annotatedWith(AnonymousCowardName.class)
             .toProvider(AnonymousCowardNameProvider.class);
         bind(String.class).annotatedWith(CanonicalWebUrl.class)
@@ -134,14 +169,17 @@
             .toInstance(serverIdent);
         bind(GitReferenceUpdated.class)
             .toInstance(GitReferenceUpdated.DISABLED);
+        bind(MetricMaker.class).to(DisabledMetricMaker.class);
+        bind(ReviewDb.class).toProvider(Providers.<ReviewDb> of(null));
       }
     });
 
     injector.injectMembers(this);
-    repoManager.createRepository(allUsers.get());
+    repoManager.createRepository(allUsers);
     changeOwner = userFactory.create(co.getId());
     otherUser = userFactory.create(ou.getId());
     otherUserId = otherUser.getAccountId();
+    internalUser = new InternalUser(null);
   }
 
   private void setTimeForTesting() {
@@ -155,18 +193,25 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  protected Change newChange() {
-    return TestChanges.newChange(project, changeOwner.getAccountId());
+  protected Change newChange() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    ChangeUpdate u = newUpdate(c, changeOwner);
+    u.setChangeId(c.getKey().get());
+    u.setBranch(c.getDest().get());
+    u.commit();
+    return c;
   }
 
-  protected ChangeUpdate newUpdate(Change c, IdentifiedUser user)
-      throws OrmException {
-    return TestChanges.newUpdate(
-        injector, repoManager, MIGRATION, c, allUsers, user);
+  protected ChangeUpdate newUpdate(Change c, CurrentUser user)
+      throws Exception {
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.setAllowWriteToNewRef(true);
+    return update;
   }
 
   protected ChangeNotes newNotes(Change c) throws OrmException {
-    return new ChangeNotes(repoManager, MIGRATION, allUsers, c).load();
+    return new ChangeNotes(args, c).load();
   }
 
   protected static SubmitRecord submitRecord(String status,
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
new file mode 100644
index 0000000..c093b75
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -0,0 +1,1307 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.TimeUtil.roundToSecond;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimeZone;
+
+public class ChangeBundleTest {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  private static final ProtobufCodec<Change> CHANGE_CODEC =
+      CodecFactory.encoder(Change.class);
+  private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
+      CodecFactory.encoder(ChangeMessage.class);
+  private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
+      CodecFactory.encoder(PatchSet.class);
+  private static final ProtobufCodec<PatchSetApproval>
+      PATCH_SET_APPROVAL_CODEC = CodecFactory.encoder(PatchSetApproval.class);
+  private static final ProtobufCodec<PatchLineComment>
+      PATCH_LINE_COMMENT_CODEC = CodecFactory.encoder(PatchLineComment.class);
+
+  private String systemTimeZoneProperty;
+  private TimeZone systemTimeZone;
+
+  private Project.NameKey project;
+  private Account.Id accountId;
+
+  @Before
+  public void setUp() {
+    String tz = "US/Eastern";
+    systemTimeZoneProperty = System.setProperty("user.timezone", tz);
+    systemTimeZone = TimeZone.getDefault();
+    TimeZone.setDefault(TimeZone.getTimeZone(tz));
+    long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
+    assertThat(maxMs).isGreaterThan(1000L);
+    TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
+    project = new Project.NameKey("project");
+    accountId = new Account.Id(100);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZoneProperty);
+    TimeZone.setDefault(systemTimeZone);
+  }
+
+  private void superWindowResolution() {
+    TestTimeUtil.setClockStep(
+        ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
+    TimeUtil.nowTs();
+  }
+
+  private void subWindowResolution() {
+    TestTimeUtil.setClockStep(1, SECONDS);
+    TimeUtil.nowTs();
+  }
+
+  @Test
+  public void diffChangesDifferentIds() throws Exception {
+    Change c1 = TestChanges.newChange(project, accountId);
+    int id1 = c1.getId().get();
+    Change c2 = TestChanges.newChange(project, accountId);
+    int id2 = c2.getId().get();
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2,
+        "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
+        "createdOn differs for Changes:"
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
+        "effective last updated time differs for Changes:"
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
+  }
+
+  @Test
+  public void diffChangesSameId() throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    c2.setTopic("topic");
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
+  }
+
+  @Test
+  public void diffChangesMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCreatedOn(TimeUtil.nowTs());
+    c2.setLastUpdatedOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "createdOn differs for Change.Id " + c1.getId() + ":"
+            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}",
+        "effective last updated time differs for Change.Id " + c1.getId() + ":"
+            + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // But not too much slop.
+    superWindowResolution();
+    Change c3 = clone(c1);
+    c3.setLastUpdatedOn(TimeUtil.nowTs());
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c3, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    String msg = "effective last updated time differs for Change.Id "
+        + c1.getId() + " in NoteDb vs. ReviewDb:"
+        + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffChangesIgnoresOriginalSubjectInReviewDb()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A");
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(
+        c2.currentPatchSetId(), c1.getSubject(), "Original B");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "originalSubject differs for Change.Id " + c1.getId() + ":"
+            + " {Original A} != {Original B}");
+
+    // Both NoteDb, exact match required.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "originalSubject differs for Change.Id " + c1.getId() + ":"
+            + " {Original A} != {Original B}");
+
+    // One ReviewDb, one NoteDb, original subject is ignored.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    c1.setTopic("");
+    Change c2 = clone(c1);
+    c2.setTopic(null);
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ":"
+            + " {} != {null}");
+
+    // Topic ignored if ReviewDb is empty and NoteDb is null.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+
+    // Exact match still required if NoteDb has empty value (not realistic).
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ":"
+            + " {} != {null}");
+
+    // Null is not equal to a non-empty string.
+    Change c3 = clone(c1);
+    c3.setTopic("topic");
+    b1 = new ChangeBundle(c3, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ":"
+            + " {topic} != {null}");
+
+    // Null is equal to a string that is all whitespace.
+    Change c4 = clone(c1);
+    c4.setTopic("  ");
+    b1 = new ChangeBundle(c4, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesIgnoresLeadingWhitespaceInReviewDbTopics()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    c1.setTopic(" abc");
+    Change c2 = clone(c1);
+    c2.setTopic("abc");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ":"
+            + " { abc} != {abc}");
+
+    // Leading whitespace in ReviewDb topic is ignored.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Must match except for the leading whitespace.
+    Change c3 = clone(c1);
+    c3.setTopic("cba");
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c3, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "topic differs for Change.Id " + c1.getId() + ":"
+            + " { abc} != {cba}");
+  }
+
+  @Test
+  public void diffChangesTakesMaxEntityTimestampFromReviewDb()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    PatchSetApproval a = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+
+    Change c2 = clone(c1);
+    c2.setLastUpdatedOn(a.getGranted());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
+        approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
+        approvals(a), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "effective last updated time differs for Change.Id " + c1.getId() + ":"
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
+
+    // NoteDb allows latest timestamp from all entities in bundle.
+    b2 = new ChangeBundle(c2, messages(), patchSets(),
+        approvals(a), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  @Test
+  public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    PatchSetApproval a = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+    c1.setLastUpdatedOn(a.getGranted());
+
+    Change c2 = clone(c1);
+    c2.setLastUpdatedOn(TimeUtil.nowTs());
+
+    // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
+    // NoteDb matches the latest timestamp of a non-Change entity.
+    ChangeBundle b1 = new ChangeBundle(c2, messages(), patchSets(),
+        approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c1, messages(), patchSets(),
+        approvals(a), comments(), reviewers(), NOTE_DB);
+    assertThat(b1.getChange().getLastUpdatedOn())
+        .isGreaterThan(b2.getChange().getLastUpdatedOn());
+    assertNoDiffs(b1, b2);
+
+    // Timestamps must actually match if Change is the only entity.
+    b1 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "effective last updated time differs for Change.Id " + c1.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:12.0}");
+  }
+
+  @Test
+  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c1.currentPatchSetId(),
+        c1.getSubject().substring(0, 10), c1.getOriginalSubject());
+    assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {Change sub}");
+
+    // ReviewDb has shorter subject, allowed.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // NoteDb has shorter subject, not allowed.
+    b1 = new ChangeBundle(c1, messages(), latest(c1), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), latest(c2), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {Change sub}");
+  }
+
+  @Test
+  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c1.currentPatchSetId(),
+        "   " + c1.getSubject(), c1.getOriginalSubject());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {   Change subject}");
+
+    // ReviewDb is missing leading spaces, allowed.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c1.currentPatchSetId(),
+        "\t" + c1.getSubject(), c1.getOriginalSubject());
+
+    // Both ReviewDb.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {\tChange subject}");
+
+    // One NoteDb.
+    b1 = new ChangeBundle(c1, messages(), latest(c1), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c2, messages(), latest(c2), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {\tChange subject}");
+    assertDiffs(b2, b1,
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {\tChange subject} != {Change subject}");
+  }
+
+  @Test
+  public void diffChangesHandlesBuggyJGitSubjectExtraction() throws Exception {
+    Change c1 = TestChanges.newChange(project, accountId);
+    String buggySubject = "Subject\r \r Rest of message.";
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), buggySubject, buggySubject);
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject");
+
+    // Both ReviewDb.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "originalSubject differs for Change.Id " + c1.getId() + ":"
+            + " {Subject\r \r Rest of message.} != {Subject}",
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Subject\r \r Rest of message.} != {Subject}");
+
+    // NoteDb has correct subject without "\r ".
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb()
+      throws Exception {
+    Change c1 = TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(100));
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(new PatchSet.Id(c2.getId(), 0), "Unrelated subject",
+        c2.getOriginalSubject());
+
+    // Both ReviewDb.
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "currentPatchSetId differs for Change.Id " + c1.getId() + ":"
+            + " {1} != {0}",
+        "subject differs for Change.Id " + c1.getId() + ":"
+            + " {Change subject} != {Unrelated subject}");
+
+    // One NoteDb.
+    //
+    // This is based on a real corrupt change where all patch sets were deleted
+    // but the Change entity stuck around, resulting in a currentPatchSetId of 0
+    // after converting to NoteDb.
+    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangeMessageKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm2 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid2"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2,
+        "ChangeMessage.Key sets differ:"
+            + " [" + id + ",uuid1] only in A; [" + id + ",uuid2] only in B");
+  }
+
+  @Test
+  public void diffChangeMessages() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    cm2.setMessage("message 2");
+    assertDiffs(b1, b2,
+        "message differs for ChangeMessage.Key " + c.getId() + ",uuid:"
+            + " {message 1} != {message 2}");
+  }
+
+  @Test
+  public void diffChangeMessagesIgnoresUuids() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.getKey().set("uuid2");
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    // Both are ReviewDb, exact UUID match is required.
+    assertDiffs(b1, b2,
+        "ChangeMessage.Key sets differ:"
+            + " [" + id + ",uuid1] only in A; [" + id + ",uuid2] only in B");
+
+    // One NoteDb, UUIDs are ignored.
+    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  @Test
+  public void diffChangeMessagesWithDifferentCounts() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid2"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 2");
+
+    // Both ReviewDb: Uses same keySet diff as other types.
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm2), latest(c),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "ChangeMessage.Key sets differ: [" + id
+        + ",uuid2] only in A; [] only in B");
+
+    // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts.
+    b1 = new ChangeBundle(c, messages(cm1, cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm2);
+    assertDiffs(b2, b1,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in B:\n  " + cm2);
+  }
+
+  @Test
+  public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setMessage("message 2");
+    ChangeMessage cm3 = clone(cm1);
+    cm3.getKey().set("uuid2"); // Differs only in UUID.
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm3), latest(c),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2, cm3), latest(c),
+        approvals(), comments(), reviewers(), NOTE_DB);
+    // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it
+    // depends on iteration order and doesn't care about UUIDs. The important
+    // thing is that there's some diff.
+    assertDiffs(b1, b2,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm3 + "\n"
+            + "Only in B:\n  " + cm2);
+    assertDiffs(b2, b1,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm2 + "\n"
+            + "Only in B:\n  " + cm3);
+  }
+
+  @Test
+  public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setWrittenOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "writtenOn differs for ChangeMessage.Key " + c.getId() + ",uuid1:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // But not too much slop.
+    superWindowResolution();
+    ChangeMessage cm3 = clone(cm1);
+    cm3.setWrittenOn(TimeUtil.nowTs());
+    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c, messages(cm3), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    int id = c.getId().get();
+    assertDiffs(b1, b3,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm1 + "\n"
+            + "Only in B:\n  " + cm3);
+    assertDiffs(b3, b1,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm3 + "\n"
+            + "Only in B:\n  " + cm1);
+  }
+
+  @Test
+  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid"),
+        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    cm1.setMessage("message 1");
+    ChangeMessage cm2 = clone(cm1);
+    cm2.setPatchSetId(null);
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+
+    // Both are ReviewDb, exact patch set ID match is required.
+    assertDiffs(b1, b2,
+        "patchset differs for ChangeMessage.Key " + c.getId() + ",uuid:"
+            + " {" + id + ",1} != {null}");
+
+    // Null patch set ID on ReviewDb is ignored.
+    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // Null patch set ID on NoteDb is not ignored (but is not realistic).
+    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
+        reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
+        reviewers(), NOTE_DB);
+    assertDiffs(b1, b2,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm1 + "\n"
+            + "Only in B:\n  " + cm2);
+    assertDiffs(b2, b1,
+        "ChangeMessages differ for Change.Id " + id + "\n"
+            + "Only in A:\n  " + cm2 + "\n"
+            + "Only in B:\n  " + cm1);
+  }
+
+  @Test
+  public void diffChangeMessagesIgnoresMessagesOnPatchSetGreaterThanCurrent()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    ps2.setUploader(accountId);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+
+    assertThat(c.currentPatchSetId()).isEqualTo(ps1.getId());
+
+    ChangeMessage cm1 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid1"),
+        accountId, TimeUtil.nowTs(), ps1.getId());
+    cm1.setMessage("a message");
+    ChangeMessage cm2 = new ChangeMessage(
+        new ChangeMessage.Key(c.getId(), "uuid2"),
+        accountId, TimeUtil.nowTs(), ps2.getId());
+    cm2.setMessage("other message");
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm2),
+        patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(cm1), patchSets(ps1),
+        approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffPatchSetIdSets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    TestChanges.incrementPatchSet(c);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    ps2.setUploader(accountId);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps2),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2,
+        "PatchSet.Id sets differ:"
+            + " [] only in A; [" + c.getId() + ",1] only in B");
+  }
+
+  @Test
+  public void diffPatchSets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = clone(ps1);
+    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    assertDiffs(b1, b2,
+        "revision differs for PatchSet.Id " + c.getId() + ",1:"
+            + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}"
+            + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}");
+  }
+
+  @Test
+  public void diffPatchSetsMixedSourcesAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
+    PatchSet ps2 = clone(ps1);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "createdOn differs for PatchSet.Id " + c.getId() + ",1:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchSet ps3 = clone(ps1);
+    ps3.setCreatedOn(TimeUtil.nowTs());
+    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c, messages(), patchSets(ps3),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    String msg = "createdOn differs for PatchSet.Id " + c.getId()
+        + ",1 in NoteDb vs. ReviewDb:"
+        + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate()
+      throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSet ps1 = new PatchSet(c.currentPatchSetId());
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
+    ps1.setPushCertificate("some cert");
+    PatchSet ps2 = clone(ps1);
+    ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
+        approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
+        approvals(), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
+        comments(), reviewers(), REVIEW_DB);
+    b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
+        comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffIgnoresPatchSetsGreaterThanCurrent() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
+    ps2.setUploader(accountId);
+    ps2.setCreatedOn(TimeUtil.nowTs());
+    assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get());
+
+    PatchSetApproval a1 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            ps1.getId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+    PatchSetApproval a2 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            ps2.getId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+
+    // Both ReviewDb.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
+        approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
+        approvals(a1, a2), comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // One NoteDb.
+    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
+        comments(), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Both NoteDb.
+    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
+        comments(), reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
+        comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  @Test
+  public void diffPatchSetApprovalKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    PatchSetApproval a1 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+    PatchSetApproval a2 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c.currentPatchSetId(), accountId, new LabelId("Verified")),
+        (short) 1,
+        TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2,
+        "PatchSetApproval.Key sets differ:"
+            + " [" + id + "%2C1,100,Code-Review] only in A;"
+            + " [" + id + "%2C1,100,Verified] only in B");
+  }
+
+  @Test
+  public void diffPatchSetApprovals() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSetApproval a1 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        TimeUtil.nowTs());
+    PatchSetApproval a2 = clone(a1);
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
+        comments(), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    a2.setValue((short) -1);
+    assertDiffs(b1, b2,
+        "value differs for PatchSetApproval.Key "
+            + c.getId() + "%2C1,100,Code-Review: {1} != {-1}");
+  }
+
+  @Test
+  public void diffPatchSetApprovalsMixedSourcesAllowsSlop()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    subWindowResolution();
+    PatchSetApproval a1 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        roundToSecond(TimeUtil.nowTs()));
+    PatchSetApproval a2 = clone(a1);
+    a2.setGranted(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "granted differs for PatchSetApproval.Key "
+            + c.getId() + "%2C1,100,Code-Review:"
+            + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
+        reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchSetApproval a3 = clone(a1);
+    a3.setGranted(TimeUtil.nowTs());
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
+        reviewers(), NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(a3),
+        comments(), reviewers(), REVIEW_DB);
+    String msg = "granted differs for PatchSetApproval.Key "
+        + c.getId() + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:"
+        + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchSetApproval a1 = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+        (short) 1,
+        c.getCreatedOn());
+    PatchSetApproval a2 = clone(a1);
+    a2.setGranted(new Timestamp(new DateTime(
+            1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
+        .getMillis()));
+
+    // Both are ReviewDb, exact match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
+        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
+        comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "granted differs for PatchSetApproval.Key "
+            + c.getId() + "%2C1,100,Code-Review:"
+            + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}");
+
+    // Truncating NoteDb timestamp is allowed.
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
+        reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffReviewers() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    Timestamp now = TimeUtil.nowTs();
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now);
+    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now);
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r1, REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r2, REVIEW_DB);
+    assertNoDiffs(b1, b1);
+    assertNoDiffs(b2, b2);
+    assertDiffs(b1, b2,
+        "reviewer sets differ:"
+            + " [1] only in A;"
+            + " [2] only in B");
+  }
+
+  @Test
+  public void diffReviewersIgnoresStateAndTimestamp() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+    ReviewerSet r2 = reviewers(CC, new Account.Id(1), TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r1, REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r2, REVIEW_DB);
+    assertNoDiffs(b1, b1);
+    assertNoDiffs(b2, b2);
+  }
+
+  @Test
+  public void diffPatchLineCommentKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    int id = c.getId().get();
+    PatchLineComment c1 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c2 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
+        5, accountId, null, TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c2), reviewers(), REVIEW_DB);
+
+    assertDiffs(b1, b2,
+        "PatchLineComment.Key sets differ:"
+            + " [" + id + ",1,filename1,uuid1] only in A;"
+            + " [" + id + ",1,filename2,uuid2] only in B");
+  }
+
+  @Test
+  public void diffPatchLineComments() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c2 = clone(c1);
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c2), reviewers(), REVIEW_DB);
+
+    assertNoDiffs(b1, b2);
+
+    c2.setStatus(PatchLineComment.Status.PUBLISHED);
+    assertDiffs(b1, b2,
+        "status differs for PatchLineComment.Key "
+            + c.getId() + ",1,filename,uuid: {d} != {P}");
+  }
+
+  @Test
+  public void diffPatchLineCommentsMixedSourcesAllowsSlop()
+      throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+        5, accountId, null, roundToSecond(TimeUtil.nowTs()));
+    PatchLineComment c2 = clone(c1);
+    c2.setWrittenOn(TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c2), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2,
+        "writtenOn differs for PatchLineComment.Key "
+            + c.getId() + ",1,filename,uuid:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed.
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1),
+        reviewers(), NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c2),
+        reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    PatchLineComment c3 = clone(c1);
+    c3.setWrittenOn(TimeUtil.nowTs());
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1),
+        reviewers(), NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c3), reviewers(), REVIEW_DB);
+    String msg = "writtenOn differs for PatchLineComment.Key " + c.getId()
+        + ",1,filename,uuid in NoteDb vs. ReviewDb:"
+        + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
+    assertDiffs(b1, b3, msg);
+    assertDiffs(b3, b1, msg);
+  }
+
+  @Test
+  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    PatchLineComment c1 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c2 = new PatchLineComment(
+        new PatchLineComment.Key(
+            new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
+        5, accountId, null, TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c1, c2), reviewers(), REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(c1), reviewers(), REVIEW_DB);
+    assertNoDiffs(b1, b2);
+  }
+
+  private static void assertNoDiffs(ChangeBundle a, ChangeBundle b) {
+    assertThat(a.differencesFrom(b)).isEmpty();
+    assertThat(b.differencesFrom(a)).isEmpty();
+  }
+
+  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first,
+      String... rest) {
+    List<String> actual = a.differencesFrom(b);
+    if (actual.size() == 1 && rest.length == 0) {
+      // This error message is much easier to read.
+      assertThat(actual.get(0)).isEqualTo(first);
+    } else {
+      List<String> expected = new ArrayList<>(1 + rest.length);
+      expected.add(first);
+      Collections.addAll(expected, rest);
+      assertThat(actual).containsExactlyElementsIn(expected).inOrder();
+    }
+    assertThat(a).isNotEqualTo(b);
+  }
+
+  private static List<ChangeMessage> messages(ChangeMessage... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static List<PatchSet> patchSets(PatchSet... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static List<PatchSet> latest(Change c) {
+    return ImmutableList.of(new PatchSet(c.currentPatchSetId()));
+  }
+
+  private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static ReviewerSet reviewers(Object... ents) {
+    checkArgument(ents.length % 3 == 0);
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t =
+        HashBasedTable.create();
+    for (int i = 0; i < ents.length; i += 3) {
+      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1],
+          (Timestamp) ents[i + 2]);
+    }
+    return ReviewerSet.fromTable(t);
+  }
+
+  private static List<PatchLineComment> comments(PatchLineComment... ents) {
+    return Arrays.asList(ents);
+  }
+
+  private static Change clone(Change ent) {
+    return clone(CHANGE_CODEC, ent);
+  }
+
+  private static ChangeMessage clone(ChangeMessage ent) {
+    return clone(CHANGE_MESSAGE_CODEC, ent);
+  }
+
+  private static PatchSet clone(PatchSet ent) {
+    return clone(PATCH_SET_CODEC, ent);
+  }
+
+  private static PatchSetApproval clone(PatchSetApproval ent) {
+    return clone(PATCH_SET_APPROVAL_CODEC, ent);
+  }
+
+  private static PatchLineComment clone(PatchLineComment ent) {
+    return clone(PATCH_LINE_COMMENT_CODEC, ent);
+  }
+
+  private static <T> T clone(ProtobufCodec<T> codec, T obj) {
+    return codec.decode(codec.encodeToByteArray(obj));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index bac72f0..ab37ec9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.notedb;
 
+import static org.junit.Assert.fail;
+
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -24,24 +28,18 @@
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class ChangeNotesParserTest extends AbstractChangeNotesTest {
   private TestRepository<InMemoryRepository> testRepo;
-  private RevWalk walk;
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  private ChangeNotesRevWalk walk;
 
   @Before
   public void setUpTestRepo() throws Exception {
     testRepo = new TestRepository<>(repo);
-    walk = new RevWalk(repo);
+    walk = ChangeNotesCommit.newRevWalk(repo);
   }
 
   @After
@@ -53,36 +51,50 @@
   public void parseAuthor() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n");
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
     assertParseFails(writeCommit("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n",
+        + "Patch-set: 1\n",
         new PersonIdent("Change Owner", "owner@example.com",
           serverIdent.getWhen(), serverIdent.getTimeZone())));
     assertParseFails(writeCommit("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n",
+        + "Patch-set: 1\n",
         new PersonIdent("Change Owner", "x@gerrit",
           serverIdent.getWhen(), serverIdent.getTimeZone())));
+    assertParseFails(writeCommit("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n",
+        new PersonIdent("Change\n\u1234<Owner>", "\n\nx<@>\u0002gerrit",
+          serverIdent.getWhen(), serverIdent.getTimeZone())));
   }
 
   @Test
   public void parseStatus() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
-        + "Status: NEW\n");
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Status: NEW\n"
+        + "Subject: This is a test change\n");
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
-        + "Status: new\n");
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Status: new\n"
+        + "Subject: This is a test change\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Status: OOPS\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Status: NEW\n"
         + "Status: NEW\n");
   }
@@ -91,49 +103,81 @@
   public void parsePatchSetId() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n");
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
     assertParseFails("Update change\n"
         + "\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
-        + "Patch-Set: 1\n");
+        + "Patch-set: 1\n"
+        + "Patch-set: 1\n");
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n");
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: x\n");
+        + "Patch-set: x\n");
   }
 
   @Test
   public void parseApproval() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
         + "Label: Label1=+1\n"
         + "Label: Label2=1\n"
         + "Label: Label3=0\n"
-        + "Label: Label4=-1\n");
+        + "Label: Label4=-1\n"
+        + "Subject: This is a test change\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Label: -Label1\n"
+        + "Label: -Label1 Other Account <2@gerrit>\n"
+        + "Subject: This is a test change\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Label: Label1=X\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Label: Label1 = 1\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Label: X+Y\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Label: Label1 Other Account <2@gerrit>\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Label: -Label!1\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Label: -Label!1 Other Account <2@gerrit>\n");
   }
 
   @Test
   public void parseSubmitRecords() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n"
         + "Submitted-with: NOT_READY\n"
         + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
         + "Submitted-with: NEED: Code-Review\n"
@@ -142,45 +186,281 @@
         + "Submitted-with: NEED: Alternative-Code-Review\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Submitted-with: OOPS\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Submitted-with: NEED: X+Y\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Submitted-with: OK: X+Y: Change Owner <1@gerrit>\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Submitted-with: OK: Code-Review: 1@gerrit\n");
   }
 
   @Test
+  public void parseSubmissionId() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n"
+        + "Submission-id: 1-1453387607626-96fabc25");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Submission-id: 1-1453387607626-96fabc25\n"
+        + "Submission-id: 1-1453387901516-5d1e2450");
+  }
+
+  @Test
   public void parseReviewer() throws Exception {
     assertParseSucceeds("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
         + "Reviewer: Change Owner <1@gerrit>\n"
-        + "CC: Other Account <2@gerrit>\n");
+        + "CC: Other Account <2@gerrit>\n"
+        + "Subject: This is a test change\n");
     assertParseFails("Update change\n"
         + "\n"
-        + "Patch-Set: 1\n"
+        + "Patch-set: 1\n"
         + "Reviewer: 1@gerrit\n");
   }
 
+  @Test
+  public void parseTopic() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Topic: Some Topic\n"
+        + "Subject: This is a test change\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Topic:\n"
+        + "Subject: This is a test change\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Topic: Some Topic\n"
+        + "Topic: Other Topic");
+  }
+
+  @Test
+  public void parseBranch() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Branch: refs/heads/stable");
+  }
+
+  @Test
+  public void parseChangeId() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Patch-set: 1\n"
+        + "Subject: This is a test change\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Change-id: I159532ef4844d7c18f7f3fd37a0b275590d41b1b");
+  }
+
+  @Test
+  public void parseSubject() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Some subject of a change\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Subject: Some subject of a change\n"
+        + "Subject: Some other subject\n");
+  }
+
+  @Test
+  public void parseCommit() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 2\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Some subject of a change\n"
+        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 2\n"
+        + "Branch: refs/heads/master\n"
+        + "Subject: Some subject of a change\n"
+        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+        + "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    assertParseFails("Update patch set 1\n"
+        + "Uploaded patch set 1.\n"
+        + "Patch-set: 2\n"
+        + "Branch: refs/heads/master\n"
+        + "Subject: Some subject of a change\n"
+        + "Commit: beef");
+  }
+
+  @Test
+  public void parsePatchSetState() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1 (PUBLISHED)\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Some subject of a change\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1 (DRAFT)\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Some subject of a change\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1 (DELETED)\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Some subject of a change\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1 (NOT A STATUS)\n"
+        + "Branch: refs/heads/master\n"
+        + "Subject: Some subject of a change\n");
+  }
+
+  @Test
+  public void parsePatchSetGroups() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 2\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+        + "Subject: Change subject\n"
+        + "Groups: a,b,c\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 2\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+        + "Subject: Change subject\n"
+        + "Groups: a,b,c\n"
+        + "Groups: d,e,f\n");
+  }
+
+  @Test
+  public void parseServerIdent() throws Exception {
+    String msg = "Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n";
+    assertParseSucceeds(msg);
+    assertParseSucceeds(writeCommit(msg, serverIdent));
+
+    msg = "Update change\n"
+        + "\n"
+        + "With a message."
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n";
+    assertParseSucceeds(msg);
+    assertParseSucceeds(writeCommit(msg, serverIdent));
+
+    msg = "Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n"
+        + "Label: Label1=+1\n";
+    assertParseSucceeds(msg);
+    assertParseFails(writeCommit(msg, serverIdent));
+  }
+
+  @Test
+  public void parseTag() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n"
+        + "Tag:\n");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n"
+        + "Tag: jenkins\n");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Branch: refs/heads/master\n"
+        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "Subject: Change subject\n"
+        + "Tag: ci\n"
+        + "Tag: jenkins\n");
+  }
+
+  @Test
+  public void caseInsensitiveFooters() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "BRaNch: refs/heads/master\n"
+        + "Change-ID: I577fb248e474018276351785930358ec0450e9f7\n"
+        + "patcH-set: 1\n"
+        + "subject: This is a test change\n");
+  }
+
   private RevCommit writeCommit(String body) throws Exception {
-    return writeCommit(body, ChangeNoteUtil.newIdent(
+    return writeCommit(body, noteUtil.newIdent(
         changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent,
         "Anonymous Coward"));
   }
 
   private RevCommit writeCommit(String body, PersonIdent author)
       throws Exception {
+    Change change = newChange();
+    ChangeNotes notes = newNotes(change).load();
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(notes.getRevision());
       cb.setAuthor(author);
       cb.setCommitter(new PersonIdent(serverIdent, author.getWhen()));
       cb.setTreeId(testRepo.tree());
@@ -194,9 +474,11 @@
   }
 
   private void assertParseSucceeds(String body) throws Exception {
-    try (ChangeNotesParser parser = newParser(writeCommit(body))) {
-      parser.parseAll();
-    }
+    assertParseSucceeds(writeCommit(body));
+  }
+
+  private void assertParseSucceeds(RevCommit commit) throws Exception {
+    newParser(commit).parseAll();
   }
 
   private void assertParseFails(String body) throws Exception {
@@ -204,13 +486,17 @@
   }
 
   private void assertParseFails(RevCommit commit) throws Exception {
-    try (ChangeNotesParser parser = newParser(commit)) {
-      exception.expect(ConfigInvalidException.class);
-      parser.parseAll();
+    try {
+      newParser(commit).parseAll();
+      fail("Expected parse to fail:\n" + commit.getFullMessage());
+    } catch (ConfigInvalidException e) {
+      // Expected
     }
   }
 
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
-    return new ChangeNotesParser(newChange(), tip, walk, repoManager);
+    walk.reset();
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, noteUtil, args.metrics);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index f19987f..0173b05 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -15,21 +15,30 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.ReviewerState.CC;
-import static com.google.gerrit.server.notedb.ReviewerState.REVIEWER;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.testutil.TestChanges.incrementPatchSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
 
+import com.google.common.base.Function;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.CommentRange;
@@ -38,26 +47,146 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.Test;
 
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 
 public class ChangeNotesTest extends AbstractChangeNotesTest {
+  @Inject
+  private DraftCommentNotes.Factory draftNotesFactory;
+
+  @Test
+  public void tagChangeMessage() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("verification from jenkins");
+    update.setTag(tag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    assertThat(notes.getChangeMessages()).hasSize(1);
+    assertThat(notes.getChangeMessages().get(0).getTag()).isEqualTo(tag);
+  }
+
+  @Test
+  public void tagInlineCommenrts() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    RevCommit commit = tr.commit().message("PS2").create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
+        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.setTag(tag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<RevId, PatchLineComment> comments = notes.getComments();
+    assertThat(comments).hasSize(1);
+    assertThat(
+        comments.entries().asList().get(0).getValue().getTag())
+            .isEqualTo(tag);
+  }
+
+  @Test
+  public void tagApprovals() throws Exception {
+    String tag1 = "jenkins";
+    String tag2 = "ip";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) -1);
+    update.setTag(tag1);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) 1);
+    update.setTag(tag2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals =
+        notes.getApprovals();
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.entries().asList().get(0).getValue().getTag())
+        .isEqualTo(tag1);
+    assertThat(approvals.entries().asList().get(1).getValue().getTag())
+        .isEqualTo(tag2);
+  }
+
+  @Test
+  public void multipleTags() throws Exception {
+    String ipTag = "ip";
+    String coverageTag = "coverage";
+    String integrationTag = "integration";
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Verified", (short) -1);
+    update.setChangeMessage("integration verification");
+    update.setTag(integrationTag);
+    update.commit();
+
+    RevCommit commit = tr.commit().message("PS2").create();
+    update = newUpdate(c, changeOwner);
+    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
+        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.setChangeMessage("coverage verification");
+    update.setTag(coverageTag);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setChangeMessage("ip clear");
+    update.setTag(ipTag);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals =
+        notes.getApprovals();
+    assertThat(approvals).hasSize(1);
+    PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
+    assertThat(approval.getTag()).isEqualTo(integrationTag);
+    assertThat(approval.getValue()).isEqualTo(-1);
+
+    ImmutableListMultimap<RevId, PatchLineComment> comments =
+        notes.getComments();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.entries().asList().get(0).getValue().getTag())
+        .isEqualTo(coverageTag);
+
+    ImmutableList<ChangeMessage> messages = notes.getChangeMessages();
+    assertThat(messages).hasSize(3);
+    assertThat(messages.get(0).getTag()).isEqualTo(integrationTag);
+    assertThat(messages.get(1).getTag()).isEqualTo(coverageTag);
+    assertThat(messages.get(2).getTag()).isEqualTo(ipTag);
+  }
+
   @Test
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
@@ -77,7 +206,7 @@
     assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
     assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
     assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 1000)));
+    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).getAccountId().get()).isEqualTo(1);
@@ -109,14 +238,14 @@
     assertThat(psa1.getAccountId().get()).isEqualTo(1);
     assertThat(psa1.getLabel()).isEqualTo("Code-Review");
     assertThat(psa1.getValue()).isEqualTo((short) -1);
-    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 1000)));
+    assertThat(psa1.getGranted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.getPatchSetId()).isEqualTo(ps2);
     assertThat(psa2.getAccountId().get()).isEqualTo(1);
     assertThat(psa2.getLabel()).isEqualTo("Code-Review");
     assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 3000)));
   }
 
   @Test
@@ -165,13 +294,13 @@
     assertThat(psas.get(0).getAccountId().get()).isEqualTo(1);
     assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
     assertThat(psas.get(0).getValue()).isEqualTo((short) -1);
-    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 1000)));
+    assertThat(psas.get(0).getGranted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).getPatchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).getAccountId().get()).isEqualTo(2);
     assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
     assertThat(psas.get(1).getValue()).isEqualTo((short) 1);
-    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 2000)));
+    assertThat(psas.get(1).getGranted()).isEqualTo(truncate(after(c, 3000)));
   }
 
   @Test
@@ -197,6 +326,69 @@
   }
 
   @Test
+  public void removeOtherUsersApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.putApproval("Not-For-Long", (short) 1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
+    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
+    assertThat(psa.getValue()).isEqualTo((short) 1);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApprovalFor(otherUserId, "Not-For-Long");
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals()).isEmpty();
+
+    // Add back approval on same label.
+    update = newUpdate(c, otherUser);
+    update.putApproval("Not-For-Long", (short) 2);
+    update.commit();
+
+    notes = newNotes(c);
+    psa = Iterables.getOnlyElement(
+        notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.getAccountId()).isEqualTo(otherUserId);
+    assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
+    assertThat(psa.getValue()).isEqualTo((short) 2);
+  }
+
+  @Test
+  public void putOtherUsersApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals = Ordering.natural().onResultOf(
+        new Function<PatchSetApproval, Integer>() {
+          @Override
+          public Integer apply(PatchSetApproval in) {
+            return in.getAccountId().get();
+          }
+        }).sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(approvals).hasSize(2);
+
+    assertThat(approvals.get(0).getAccountId())
+        .isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+
+    assertThat(approvals.get(1).getAccountId())
+        .isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
+  }
+
+  @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -205,10 +397,12 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(
-          REVIEWER, new Account.Id(1),
-          REVIEWER, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+            .put(REVIEWER, new Account.Id(1), ts)
+            .put(REVIEWER, new Account.Id(2), ts)
+            .build()));
   }
 
   @Test
@@ -220,10 +414,12 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(
-            REVIEWER, new Account.Id(1),
-            CC, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+            .put(REVIEWER, new Account.Id(1), ts)
+            .put(CC, new Account.Id(2), ts)
+            .build()));
   }
 
   @Test
@@ -234,16 +430,18 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(REVIEWER, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
 
     update = newUpdate(c, otherUser);
     update.putReviewer(otherUser.getAccount().getId(), CC);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(CC, new Account.Id(2)));
+    ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.of(CC, new Account.Id(2), ts)));
   }
 
   @Test
@@ -284,10 +482,11 @@
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubject("Submit patch set 1");
+    update.setSubjectForCommit("Submit patch set 1");
 
-    update.merge(ImmutableList.of(
+    update.merge(submissionId, ImmutableList.of(
         submitRecord("NOT_READY", null,
           submitLabel("Verified", "OK", changeOwner.getAccountId()),
           submitLabel("Code-Review", "NEED", null)),
@@ -307,22 +506,25 @@
         submitRecord("NOT_READY", null,
           submitLabel("Verified", "OK", changeOwner.getAccountId()),
           submitLabel("Alternative-Code-Review", "NEED", null)));
+    assertThat(notes.getChange().getSubmissionId())
+        .isEqualTo(submissionId.toStringForStorage());
   }
 
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubject("Submit patch set 1");
-    update.merge(ImmutableList.of(
+    update.setSubjectForCommit("Submit patch set 1");
+    update.merge(submissionId, ImmutableList.of(
         submitRecord("OK", null,
           submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
     update.commit();
 
     incrementPatchSet(c);
     update = newUpdate(c, changeOwner);
-    update.setSubject("Submit patch set 2");
-    update.merge(ImmutableList.of(
+    update.setSubjectForCommit("Submit patch set 2");
+    update.merge(submissionId, ImmutableList.of(
         submitRecord("OK", null,
           submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
     update.commit();
@@ -331,13 +533,23 @@
     assertThat(notes.getSubmitRecords()).containsExactly(
         submitRecord("OK", null,
           submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+    assertThat(notes.getChange().getSubmissionId())
+        .isEqualTo(submissionId.toStringForStorage());
   }
 
   @Test
   public void emptyChangeUpdate() throws Exception {
-    ChangeUpdate update = newUpdate(newChange(), changeOwner);
+    Change c = newChange();
+    Ref initial = repo.exactRef(changeMetaRef(c.getId()));
+    assertThat(initial).isNotNull();
+
+    // Empty update doesn't create a new commit.
+    ChangeUpdate update = newUpdate(c, changeOwner);
     update.commit();
-    assertThat(update.getRevision()).isNull();
+    assertThat(update.getResult()).isNull();
+
+    Ref updated = repo.exactRef(changeMetaRef(c.getId()));
+    assertThat(updated.getObjectId()).isEqualTo(initial.getObjectId());
   }
 
   @Test
@@ -350,7 +562,7 @@
     update.setHashtags(hashtags);
     update.commit();
     try (RevWalk walk = new RevWalk(repo)) {
-      RevCommit commit = walk.parseCommit(update.getRevision());
+      RevCommit commit = walk.parseCommit(update.getResult());
       walk.parseBody(commit);
       assertThat(commit.getFullMessage()).endsWith("Hashtags: tag1,tag2\n");
     }
@@ -371,15 +583,427 @@
   }
 
   @Test
-  public void emptyExceptSubject() throws Exception {
-    ChangeUpdate update = newUpdate(newChange(), changeOwner);
-    update.setSubject("Create change");
+  public void topicChangeNotes() throws Exception {
+    Change c = newChange();
+
+    // initially topic is not set
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set topic
+    String topic = "myTopic";
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic(topic);
     update.commit();
-    assertThat(update.getRevision()).isNotNull();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting empty string
+    update = newUpdate(c, changeOwner);
+    update.setTopic("");
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set other topic
+    topic = "otherTopic";
+    update = newUpdate(c, changeOwner);
+    update.setTopic(topic);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting null
+    update = newUpdate(c, changeOwner);
+    update.setTopic(null);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
   }
 
   @Test
-  public void multipleUpdatesInBatch() throws Exception {
+  public void changeIdChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
+
+    // An update doesn't affect the Change-Id
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
+
+    // Trying to set another Change-Id fails
+    String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
+    update = newUpdate(c, changeOwner);
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("The Change-Id was already set to " + c.getKey()
+        + ", so we cannot set this Change-Id: " + otherChangeId);
+    update.setChangeId(otherChangeId);
+  }
+
+  @Test
+  public void branchChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    Branch.NameKey expectedBranch =
+        new Branch.NameKey(project, "refs/heads/master");
+    assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
+
+    // An update doesn't affect the branch
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getDest()).isEqualTo(expectedBranch);
+
+    // Set another branch
+    String otherBranch = "refs/heads/stable";
+    update = newUpdate(c, changeOwner);
+    update.setBranch(otherBranch);
+    update.commit();
+    assertThat(newNotes(c).getChange().getDest()).isEqualTo(
+        new Branch.NameKey(project, otherBranch));
+  }
+
+  @Test
+  public void ownerChangeNotes() throws Exception {
+    Change c = newChange();
+
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(
+        changeOwner.getAccountId());
+
+    // An update doesn't affect the owner
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(
+        changeOwner.getAccountId());
+  }
+
+  @Test
+  public void createdOnChangeNotes() throws Exception {
+    Change c = newChange();
+
+    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
+    assertThat(createdOn).isNotNull();
+
+    // An update doesn't affect the createdOn timestamp.
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    assertThat(newNotes(c).getChange().getCreatedOn()).isEqualTo(createdOn);
+  }
+
+  @Test
+  public void lastUpdatedOnChangeNotes() throws Exception {
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
+    assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
+
+    // Various kinds of updates that update the timestamp.
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setTopic("topic"); // Change something to get a new commit.
+    update.commit();
+    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts2).isGreaterThan(ts1);
+
+    update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Some message");
+    update.commit();
+    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts3).isGreaterThan(ts2);
+
+    update = newUpdate(c, changeOwner);
+    update.setHashtags(ImmutableSet.of("foo"));
+    update.commit();
+    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts4).isGreaterThan(ts3);
+
+    incrementPatchSet(c);
+    RevCommit commit = tr.commit().message("PS2").create();
+    update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.commit();
+    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts5).isGreaterThan(ts4);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts6).isGreaterThan(ts5);
+
+    update = newUpdate(c, changeOwner);
+    update.setStatus(Change.Status.ABANDONED);
+    update.commit();
+    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts7).isGreaterThan(ts6);
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
+    update.commit();
+    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts8).isGreaterThan(ts7);
+
+    update = newUpdate(c, changeOwner);
+    update.setGroups(ImmutableList.of("a", "b"));
+    update.commit();
+    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts9).isGreaterThan(ts8);
+
+    // Finish off by merging the change.
+    update = newUpdate(c, changeOwner);
+    update.merge(RequestId.forChange(c), ImmutableList.of(
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
+    assertThat(ts10).isGreaterThan(ts9);
+  }
+
+  @Test
+  public void subjectLeadingWhitespaceChangeNotes() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    String trimmedSubj = c.getSubject();
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj,
+        c.getOriginalSubject());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getSubject()).isEqualTo(trimmedSubj);
+
+    String tabSubj = "\t\t" + trimmedSubj;
+
+    c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj,
+        c.getOriginalSubject());
+    update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChange().getSubject()).isEqualTo(tabSubj);
+  }
+
+  @Test
+  public void commitChangeNotesUnique() throws Exception {
+    // PatchSetId -> RevId must be a one to one mapping
+    Change c = newChange();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSet ps = notes.getCurrentPatchSet();
+    assertThat(ps).isNotNull();
+
+    // new revId for the same patch set, ps1
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    RevCommit commit = tr.commit().message("PS1 again").create();
+    update.setCommit(rw, commit);
+    update.commit();
+
+    try {
+      notes = newNotes(c);
+      fail("Expected IOException");
+    } catch (OrmException e) {
+      assertCause(e, ConfigInvalidException.class,
+          "Multiple revisions parsed for patch set 1:"
+              + " RevId{" + commit.name() + "} and " + ps.getRevision().get());
+    }
+  }
+
+  @Test
+  public void patchSetChangeNotes() throws Exception {
+    Change c = newChange();
+
+    // ps1 created by newChange()
+    ChangeNotes notes = newNotes(c);
+    PatchSet ps1 = notes.getCurrentPatchSet();
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
+    assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
+    assertThat(notes.getChange().getOriginalSubject())
+        .isEqualTo("Change subject");
+    assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
+    assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
+
+    // ps2 by other user
+    incrementPatchSet(c);
+    RevCommit commit = tr.commit().message("PS2").create();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setCommit(rw, commit);
+    update.commit();
+    notes = newNotes(c);
+    PatchSet ps2 = notes.getCurrentPatchSet();
+    assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
+    assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
+    assertThat(notes.getChange().getOriginalSubject())
+        .isEqualTo("Change subject");
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+    assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
+    assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
+    assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
+    assertThat(ps2.getCreatedOn()).isEqualTo(update.getWhen());
+
+    // comment on ps1, current patch set is still ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetId(ps1.getId());
+    update.setChangeMessage("Comment on old patch set.");
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
+  }
+
+  @Test
+  public void patchSetStates() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+
+    // ps2
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    RevCommit commit = tr.commit().message("PS2").create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.setPatchSetState(PatchSetState.DRAFT);
+    update.putApproval("Code-Review", (short) 1);
+    update.setChangeMessage("This is a message");
+    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
+        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId2).isDraft()).isTrue();
+    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getChangeMessagesByPatchSet()).isNotEmpty();
+    assertThat(notes.getChangeMessages()).isNotEmpty();
+    assertThat(notes.getComments()).isNotEmpty();
+
+    // publish ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.PUBLISHED);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId2).isDraft()).isFalse();
+
+    // delete ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
+    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getChangeMessagesByPatchSet()).isEmpty();
+    assertThat(notes.getChangeMessages()).isEmpty();
+    assertThat(notes.getComments()).isEmpty();
+  }
+
+  @Test
+  public void patchSetGroups() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).isEmpty();
+
+    // ps1
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setGroups(ImmutableList.of("a", "b"));
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId1).getGroups())
+      .containsExactly("a", "b").inOrder();
+
+    // ps2
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    update = newUpdate(c, changeOwner);
+    update.setCommit(rw, tr.commit().message("PS2").create());
+    update.setGroups(ImmutableList.of("d"));
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPatchSets().get(psId2).getGroups())
+      .containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).getGroups())
+      .containsExactly("a", "b").inOrder();
+  }
+
+  @Test
+  public void pushCertificate() throws Exception {
+    String pushCert = "certificate version 0.1\n"
+      + "pusher This is not a real push cert\n"
+      + "-----BEGIN PGP SIGNATURE-----\n"
+      + "Version: GnuPG v1\n"
+      + "\n"
+      + "Nor is this a real signature.\n"
+      + "-----END PGP SIGNATURE-----\n";
+
+    // ps2 with push cert
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(psId2);
+    RevCommit commit = tr.commit().message("PS2").create();
+    update.setCommit(rw, commit, pushCert);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(readNote(notes, commit)).isEqualTo(pushCert);
+    Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
+    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
+    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(notes.getComments()).isEmpty();
+
+    // comment on ps2
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetId(psId2);
+    Timestamp ts = TimeUtil.nowTs();
+    update.putComment(newPublishedComment(psId2, "a.txt",
+        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null, ts,
+        "Comment", (short) 1, commit.name()));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(readNote(notes, commit)).isEqualTo(
+        pushCert
+        + "Revision: " + commit.name() + "\n"
+        + "Patch-set: 2\n"
+        + "File: a.txt\n"
+        + "\n"
+        + "1:2-3:4\n"
+        + ChangeNoteUtil.formatTime(serverIdent, ts) + "\n"
+        + "Author: Change Owner <1@gerrit>\n"
+        + "UUID: uuid1\n"
+        + "Bytes: 7\n"
+        + "Comment\n"
+        + "\n");
+    patchSets = notes.getPatchSets();
+    assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
+    assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
+    assertThat(notes.getComments()).isNotEmpty();
+  }
+
+  @Test
+  public void emptyExceptSubject() throws Exception {
+    ChangeUpdate update = newUpdate(newChange(), changeOwner);
+    update.setSubjectForCommit("Create change");
+    assertThat(update.commit()).isNotNull();
+  }
+
+  @Test
+  public void multipleUpdatesInManager() throws Exception {
     Change c = newChange();
     ChangeUpdate update1 = newUpdate(c, changeOwner);
     update1.putApproval("Verified", (short) 1);
@@ -387,13 +1011,11 @@
     ChangeUpdate update2 = newUpdate(c, otherUser);
     update2.putApproval("Code-Review", (short) 2);
 
-    BatchMetaDataUpdate batch = update1.openUpdate();
-    try {
-      batch.write(update1, new CommitBuilder());
-      batch.write(update2, new CommitBuilder());
-      batch.commit();
-    } finally {
-      batch.close();
+    try (NoteDbUpdateManager updateManager =
+        updateManagerFactory.create(project)) {
+      updateManager.add(update1);
+      updateManager.add(update2);
+      updateManager.execute();
     }
 
     ChangeNotes notes = newNotes(c);
@@ -421,48 +1043,46 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     Timestamp time1 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
-    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-    BatchMetaDataUpdate batch = update1.openUpdateInBatch(bru);
-    PatchLineComment comment1 = newPublishedComment(psId, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
-        (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
-    update1.setPatchSetId(psId);
-    update1.upsertComment(comment1);
-    update1.writeCommit(batch);
-    ChangeUpdate update2 = newUpdate(c, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
-    update2.writeCommit(batch);
+    RevCommit tipCommit;
+    try (NoteDbUpdateManager updateManager =
+        updateManagerFactory.create(project)) {
+      PatchLineComment comment1 = newPublishedComment(psId, "file1",
+          uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
+          (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+      update1.setPatchSetId(psId);
+      update1.putComment(comment1);
+      updateManager.add(update1);
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      batch.commit();
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
+      ChangeUpdate update2 = newUpdate(c, otherUser);
+      update2.putApproval("Code-Review", (short) 2);
+      updateManager.add(update2);
 
-      ChangeNotes notes = newNotes(c);
-      ObjectId tip = notes.getRevision();
-      RevCommit commitWithApprovals = rw.parseCommit(tip);
-      assertThat(commitWithApprovals).isNotNull();
-      RevCommit commitWithComments = commitWithApprovals.getParent(0);
-      assertThat(commitWithComments).isNotNull();
+      updateManager.execute();
+    }
 
-      try (ChangeNotesParser notesWithComments =
-          new ChangeNotesParser(c, commitWithComments.copy(), rw, repoManager)) {
-        notesWithComments.parseAll();
-        ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals1 =
-            notesWithComments.buildApprovals();
-        assertThat(approvals1).isEmpty();
-        assertThat(notesWithComments.comments).hasSize(1);
-      }
+    ChangeNotes notes = newNotes(c);
+    ObjectId tip = notes.getRevision();
+    tipCommit = rw.parseCommit(tip);
 
-      try (ChangeNotesParser notesWithApprovals =
-          new ChangeNotesParser(c, commitWithApprovals.copy(), rw, repoManager)) {
-        notesWithApprovals.parseAll();
-        ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals2 =
-            notesWithApprovals.buildApprovals();
-        assertThat(approvals2).hasSize(1);
-        assertThat(notesWithApprovals.comments).hasSize(1);
-      }
-    } finally {
-      batch.close();
+    RevCommit commitWithApprovals = tipCommit;
+    assertThat(commitWithApprovals).isNotNull();
+    RevCommit commitWithComments = commitWithApprovals.getParent(0);
+    assertThat(commitWithComments).isNotNull();
+
+    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
+      ChangeNotesParser notesWithComments = new ChangeNotesParser(
+          c.getId(), commitWithComments.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesState state = notesWithComments.parseAll();
+      assertThat(state.approvals()).isEmpty();
+      assertThat(state.publishedComments()).hasSize(1);
+    }
+
+    try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
+      ChangeNotesParser notesWithApprovals = new ChangeNotesParser(c.getId(),
+          commitWithApprovals.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesState state = notesWithApprovals.parseAll();
+      assertThat(state.approvals()).hasSize(1);
+      assertThat(state.publishedComments()).hasSize(1);
     }
   }
 
@@ -476,43 +1096,32 @@
     ChangeUpdate update2 = newUpdate(c2, otherUser);
     update2.putApproval("Code-Review", (short) 2);
 
-    BatchMetaDataUpdate batch1 = null;
-    BatchMetaDataUpdate batch2 = null;
+    Ref initial1 = repo.exactRef(update1.getRefName());
+    assertThat(initial1).isNotNull();
+    Ref initial2 = repo.exactRef(update2.getRefName());
+    assertThat(initial2).isNotNull();
 
-    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-    try {
-      batch1 = update1.openUpdateInBatch(bru);
-      batch1.write(update1, new CommitBuilder());
-      batch1.commit();
-      assertThat(repo.getRef(update1.getRefName())).isNull();
-
-      batch2 = update2.openUpdateInBatch(bru);
-      batch2.write(update2, new CommitBuilder());
-      batch2.commit();
-      assertThat(repo.getRef(update2.getRefName())).isNull();
-    } finally {
-      if (batch1 != null) {
-        batch1.close();
-      }
-      if (batch2 != null) {
-        batch2.close();
-      }
+    try (NoteDbUpdateManager updateManager =
+        updateManagerFactory.create(project)) {
+      updateManager.add(update1);
+      updateManager.add(update2);
+      updateManager.execute();
     }
 
-    List<ReceiveCommand> cmds = bru.getCommands();
-    assertThat(cmds).hasSize(2);
-    assertThat(cmds.get(0).getRefName()).isEqualTo(update1.getRefName());
-    assertThat(cmds.get(1).getRefName()).isEqualTo(update2.getRefName());
+    Ref ref1 = repo.exactRef(update1.getRefName());
+    assertThat(ref1.getObjectId()).isEqualTo(update1.getResult());
+    assertThat(ref1.getObjectId()).isNotEqualTo(initial1.getObjectId());
+    Ref ref2 = repo.exactRef(update2.getRefName());
+    assertThat(ref2.getObjectId()).isEqualTo(update2.getResult());
+    assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    }
+    PatchSetApproval approval1 = newNotes(c1).getApprovals()
+        .get(c1.currentPatchSetId()).iterator().next();
+    assertThat(approval1.getLabel()).isEqualTo("Verified");
 
-    assertThat(cmds.get(0).getResult()).isEqualTo(ReceiveCommand.Result.OK);
-    assertThat(cmds.get(1).getResult()).isEqualTo(ReceiveCommand.Result.OK);
-
-    assertThat(repo.getRef(update1.getRefName())).isNotNull();
-    assertThat(repo.getRef(update2.getRefName())).isNotNull();
+    PatchSetApproval approval2 = newNotes(c2).getApprovals()
+        .get(c2.currentPatchSetId()).iterator().next();
+    assertThat(approval2.getLabel()).isEqualTo("Code-Review");
   }
 
   @Test
@@ -526,7 +1135,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).containsExactly(ps1);
 
     ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -557,7 +1166,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -579,7 +1188,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -609,7 +1218,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(2);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -642,7 +1251,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).hasSize(1);
 
     List<ChangeMessage> cm = changeMessages.get(ps1);
@@ -658,6 +1267,85 @@
   }
 
   @Test
+  public void patchLineCommentsFileComment() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    PatchLineComment comment = newPublishedComment(psId, "file1",
+        "uuid", null, 0, otherUser, null,
+        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    update.setPatchSetId(psId);
+    update.putComment(comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentsZeroColumns() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 0, 2, 0);
+
+    PatchLineComment comment = newPublishedComment(psId, "file1",
+        "uuid", range, range.getEndLine(), otherUser, null,
+        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    update.setPatchSetId(psId);
+    update.putComment(comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentZeroRange() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(0, 0, 0, 0);
+
+    PatchLineComment comment = newPublishedComment(psId, "file",
+        "uuid", range, range.getEndLine(), otherUser, null,
+        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    update.setPatchSetId(psId);
+    update.putComment(comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
+  public void patchLineCommentEmptyFilename() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 2, 3, 4);
+
+    PatchLineComment comment = newPublishedComment(psId, "",
+        "uuid", range, range.getEndLine(), otherUser, null,
+        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    update.setPatchSetId(psId);
+    update.putComment(comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
   public void patchLineCommentNotesFormatSide1() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -677,7 +1365,7 @@
         uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
         (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -686,42 +1374,43 @@
         uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
         (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 1, 4, 1);
+    CommentRange range3 = new CommentRange(3, 0, 4, 1);
     PatchLineComment comment3 = newPublishedComment(psId, "file2",
         uuid3, range3, range3.getEndLine(), otherUser, null, time3, message3,
         (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.upsertComment(comment3);
+    update.putComment(comment3);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
       ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.getNoteMap().iterator());
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
       byte[] bytes =
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo("Patch-set: 1\n"
-          + "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Patch-set: 1\n"
           + "File: file1\n"
           + "\n"
           + "1:1-2:1\n"
-          + CommentsInNotesUtil.formatTime(serverIdent, time1) + "\n"
+          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid1\n"
           + "Bytes: 9\n"
           + "comment 1\n"
           + "\n"
           + "2:1-3:1\n"
-          + CommentsInNotesUtil.formatTime(serverIdent, time2) + "\n"
+          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid2\n"
           + "Bytes: 9\n"
@@ -729,8 +1418,8 @@
           + "\n"
           + "File: file2\n"
           + "\n"
-          + "3:1-4:1\n"
-          + CommentsInNotesUtil.formatTime(serverIdent, time3) + "\n"
+          + "3:0-4:1\n"
+          + ChangeNoteUtil.formatTime(serverIdent, time3) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid3\n"
           + "Bytes: 9\n"
@@ -756,7 +1445,7 @@
         uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
         (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -765,33 +1454,34 @@
         uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
         (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
       ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.getNoteMap().iterator());
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
       byte[] bytes =
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo("Base-for-patch-set: 1\n"
-          + "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Base-for-patch-set: 1\n"
           + "File: file1\n"
           + "\n"
           + "1:1-2:1\n"
-          + CommentsInNotesUtil.formatTime(serverIdent, time1) + "\n"
+          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid1\n"
           + "Bytes: 9\n"
           + "comment 1\n"
           + "\n"
           + "2:1-3:1\n"
-          + CommentsInNotesUtil.formatTime(serverIdent, time2) + "\n"
+          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid2\n"
           + "Bytes: 9\n"
@@ -801,6 +1491,143 @@
   }
 
   @Test
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId()
+      throws Exception {
+    Change c = newChange();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String uuid3 = "uuid3";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    String message3 = "comment 3";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    CommentRange range2 = new CommentRange(2, 1, 3, 1);
+    Timestamp time = TimeUtil.nowTs();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), psId1.get() + 1);
+
+    PatchLineComment comment1 = newPublishedComment(psId1, "file1",
+        uuid1, range1, range1.getEndLine(), otherUser, null, time, message1,
+        (short) 0, revId.get());
+    PatchLineComment comment2 = newPublishedComment(psId1, "file1",
+        uuid2, range2, range2.getEndLine(), otherUser, null, time, message2,
+        (short) 0, revId.get());
+    PatchLineComment comment3 = newPublishedComment(psId2, "file1",
+        uuid3, range1, range1.getEndLine(), otherUser, null, time, message3,
+        (short) 0, revId.get());
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId2);
+    update.putComment(comment3);
+    update.putComment(comment2);
+    update.putComment(comment1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree =
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes =
+          walk.getObjectReader().open(
+              note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Base-for-patch-set: 1\n"
+          + "File: file1\n"
+          + "\n"
+          + "1:1-2:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid1\n"
+          + "Bytes: 9\n"
+          + "comment 1\n"
+          + "\n"
+          + "2:1-3:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid2\n"
+          + "Bytes: 9\n"
+          + "comment 2\n"
+          + "\n"
+          + "Base-for-patch-set: 2\n"
+          + "File: file1\n"
+          + "\n"
+          + "1:1-2:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid3\n"
+          + "Bytes: 9\n"
+          + "comment 3\n"
+          + "\n");
+    }
+
+    assertThat(notes.getComments()).isEqualTo(
+        ImmutableMultimap.of(
+            revId, comment1,
+            revId, comment2,
+            revId, comment3));
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatWeirdUser() throws Exception {
+    Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
+    account.setFullName("Weird\n\u0002<User>\n");
+    account.setPreferredEmail(" we\r\nird@ex>ample<.com");
+    accountCache.put(account);
+    IdentifiedUser user = userFactory.create(account.getId());
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, user);
+    String uuid = "uuid";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    PatchLineComment comment = newPublishedComment(psId, "file1",
+        uuid, range, range.getEndLine(), user, null, time, "comment",
+        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    update.setPatchSetId(psId);
+    update.putComment(comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree =
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes =
+          walk.getObjectReader().open(
+              note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Patch-set: 1\n"
+          + "File: file1\n"
+          + "\n"
+          + "1:1-2:1\n"
+          + timeStr + "\n"
+          + "Author: Weird\u0002User <3@gerrit>\n"
+          + "UUID: uuid\n"
+          + "Bytes: 7\n"
+          + "comment\n"
+          + "\n");
+    }
+
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(comment.getRevId(), comment));
+  }
+
+  @Test
   public void patchLineCommentMultipleOnePatchsetOneFileBothSides()
       throws Exception {
     Change c = newChange();
@@ -820,7 +1647,7 @@
         range, range.getEndLine(), otherUser, null, now, messageForBase,
         (short) 0, rev1);
     update.setPatchSetId(psId);
-    update.upsertComment(commentForBase);
+    update.putComment(commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -829,10 +1656,10 @@
         range, range.getEndLine(), otherUser, null, now, messageForPS,
         (short) 1, rev2);
     update.setPatchSetId(psId);
-    update.upsertComment(commentForPS);
+    update.putComment(commentForPS);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(
             new RevId(rev1), commentForBase,
             new RevId(rev2), commentForPS));
@@ -856,7 +1683,7 @@
         uuid1, range, range.getEndLine(), otherUser, null, timeForComment1,
         "comment 1", side, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -864,10 +1691,10 @@
         uuid2, range, range.getEndLine(), otherUser, null, timeForComment2,
         "comment 2", side, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(
           new RevId(rev), comment1,
           new RevId(rev), comment2)).inOrder();
@@ -891,7 +1718,7 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment 1",
         side, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -899,10 +1726,10 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment 2",
         side, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(
           new RevId(rev), comment1,
           new RevId(rev), comment2)).inOrder();
@@ -925,7 +1752,7 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
         side, rev1);
     update.setPatchSetId(ps1);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -937,10 +1764,10 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
         side, rev2);
     update.setPatchSetId(ps2);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(
           new RevId(rev1), comment1,
           new RevId(rev2), comment2));
@@ -962,23 +1789,23 @@
         range.getEndLine(), otherUser, null, now, "comment on ps1", side,
         rev, Status.DRAFT);
     update.setPatchSetId(ps1);
-    update.insertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactly(
+    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment1));
     assertThat(notes.getComments()).isEmpty();
 
     comment1.setStatus(Status.PUBLISHED);
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.updateComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).containsExactly(
+    assertThat(notes.getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment1));
   }
 
@@ -1005,12 +1832,12 @@
     PatchLineComment comment2 = newComment(psId, filename, uuid2,
         range2, range2.getEndLine(), otherUser, null, now, "other on ps1",
         side, rev, Status.DRAFT);
-    update.insertComment(comment1);
-    update.insertComment(comment2);
+    update.putComment(comment1);
+    update.putComment(comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactly(
+    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
         ImmutableMultimap.of(
           new RevId(rev), comment1,
           new RevId(rev), comment2)).inOrder();
@@ -1020,13 +1847,13 @@
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
     comment1.setStatus(Status.PUBLISHED);
-    update.updateComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactly(
+    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment2));
-    assertThat(notes.getComments()).containsExactly(
+    assertThat(notes.getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment1));
   }
 
@@ -1054,12 +1881,12 @@
         range2, range2.getEndLine(), otherUser, null, now, "comment on ps",
         (short) 1, rev2, Status.DRAFT);
 
-    update.insertComment(baseComment);
-    update.insertComment(psComment);
+    update.putComment(baseComment);
+    update.putComment(psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactly(
+    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
         ImmutableMultimap.of(
             new RevId(rev1), baseComment,
             new RevId(rev2), psComment));
@@ -1071,13 +1898,13 @@
 
     baseComment.setStatus(Status.PUBLISHED);
     psComment.setStatus(Status.PUBLISHED);
-    update.updateComment(baseComment);
-    update.updateComment(psComment);
+    update.putComment(baseComment);
+    update.putComment(psComment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).containsExactly(
+    assertThat(notes.getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(
             new RevId(rev1), baseComment,
             new RevId(rev2), psComment));
@@ -1100,7 +1927,7 @@
         range.getEndLine(), otherUser, null, now, "comment on ps1", side,
         rev, Status.DRAFT);
     update.setPatchSetId(psId);
-    update.upsertComment(comment);
+    update.putComment(comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1139,7 +1966,7 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
         side, rev1, Status.DRAFT);
     update.setPatchSetId(ps1);
-    update.upsertComment(comment1);
+    update.putComment(comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1151,7 +1978,7 @@
         uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
         side, rev2, Status.DRAFT);
     update.setPatchSetId(ps2);
-    update.upsertComment(comment2);
+    update.putComment(comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1171,6 +1998,62 @@
   }
 
   @Test
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef()
+      throws Exception {
+    Change c = newChange();
+    String uuid = "uuid";
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    PatchLineComment comment = newComment(ps1, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
+        rev, Status.PUBLISHED);
+    update.putComment(comment);
+    update.commit();
+
+    assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
+    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
+    assertThat(exactRefAllUsers(draftRef)).isNull();
+  }
+
+  @Test
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef()
+      throws Exception {
+    Change c = newChange();
+    String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    String filename = "filename1";
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    PatchLineComment draft = newComment(ps1, filename, "uuid1", range,
+        range.getEndLine(), otherUser, null, now, "draft comment on ps1", side,
+        rev, Status.DRAFT);
+    update.putComment(draft);
+    update.commit();
+
+    String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
+    ObjectId old = exactRefAllUsers(draftRef);
+    assertThat(old).isNotNull();
+
+    update = newUpdate(c, otherUser);
+    PatchLineComment pub = newComment(ps1, filename, "uuid2", range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
+        rev, Status.PUBLISHED);
+    update.putComment(pub);
+    update.commit();
+
+    assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
+  }
+
+  @Test
   public void fileComment() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -1184,10 +2067,10 @@
         psId, "filename", uuid, null, 0, otherUser, null, now, messageForBase,
         (short) 0, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment);
+    update.putComment(comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment));
   }
 
@@ -1205,15 +2088,15 @@
         psId, "filename", uuid, null, 1, otherUser, null, now, messageForBase,
         (short) 0, rev);
     update.setPatchSetId(psId);
-    update.upsertComment(comment);
+    update.putComment(comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactly(
+    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
         ImmutableMultimap.of(new RevId(rev), comment));
   }
 
   @Test
-  public void updateCommentsForMultipleRevisions() throws Exception {
+  public void putCommentsForMultipleRevisions() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
     String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
@@ -1235,8 +2118,8 @@
     PatchLineComment comment2 = newComment(ps2, filename,
         uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
         side, rev2, Status.DRAFT);
-    update.upsertComment(comment1);
-    update.upsertComment(comment2);
+    update.putComment(comment1);
+    update.putComment(comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1247,12 +2130,196 @@
     update.setPatchSetId(ps2);
     comment1.setStatus(Status.PUBLISHED);
     comment2.setStatus(Status.PUBLISHED);
-    update.upsertComment(comment1);
-    update.upsertComment(comment2);
+    update.putComment(comment1);
+    update.putComment(comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
     assertThat(notes.getComments()).hasSize(2);
   }
+
+  @Test
+  public void publishSubsetOfCommentsOnRevision() throws Exception {
+    Change c = newChange();
+    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    Timestamp now = TimeUtil.nowTs();
+    PatchLineComment comment1 = newComment(ps1, "file1",
+        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment1",
+        side, rev1.get(), Status.DRAFT);
+    PatchLineComment comment2 = newComment(ps1, "file2",
+        "uuid2", range, range.getEndLine(), otherUser, null, now, "comment2",
+        side, rev1.get(), Status.DRAFT);
+    update.putComment(comment1);
+    update.putComment(comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1))
+        .containsExactly(comment1, comment2);
+    assertThat(notes.getComments()).isEmpty();
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    comment2.setStatus(Status.PUBLISHED);
+    update.putComment(comment2);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1))
+        .containsExactly(comment1);
+    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
+  }
+
+  @Test
+  public void updateWithServerIdent() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, internalUser);
+    update.setChangeMessage("A message.");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("A message.");
+    assertThat(msg.getAuthor()).isNull();
+
+    update = newUpdate(c, internalUser);
+    exception.expect(IllegalStateException.class);
+    update.putApproval("Code-Review", (short) 1);
+  }
+
+  @Test
+  public void filterOutAndFixUpZombieDraftComments() throws Exception {
+    Change c = newChange();
+    RevId rev1 = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    PatchSet.Id ps1 = c.currentPatchSetId();
+    short side = (short) 1;
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    Timestamp now = TimeUtil.nowTs();
+    PatchLineComment comment1 = newComment(ps1, "file1",
+        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment on ps1",
+        side, rev1.get(), Status.DRAFT);
+    PatchLineComment comment2 = newComment(ps1, "file2",
+        "uuid2", range, range.getEndLine(), otherUser, null, now, "another comment",
+        side, rev1.get(), Status.DRAFT);
+    update.putComment(comment1);
+    update.putComment(comment2);
+    update.commit();
+
+    String refName = refsDraftComments(c.getId(), otherUserId);
+    ObjectId oldDraftId = exactRefAllUsers(refName);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    comment2.setStatus(Status.PUBLISHED);
+    update.putComment(comment2);
+    update.commit();
+    assertThat(exactRefAllUsers(refName)).isNotNull();
+    assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
+
+    // Re-add draft version of comment2 back to draft ref without updating
+    // change ref. Simulates the case where deleting the draft failed
+    // non-atomically after adding the published comment succeeded.
+    ChangeDraftUpdate draftUpdate =
+        newUpdate(c, otherUser).createDraftUpdateIfNull();
+    comment2.setStatus(Status.DRAFT);
+    draftUpdate.putComment(comment2);
+    try (NoteDbUpdateManager manager =
+        updateManagerFactory.create(c.getProject())) {
+      manager.add(draftUpdate);
+      manager.execute();
+    }
+
+    // Looking at drafts directly shows the zombie comment.
+    DraftCommentNotes draftNotes = draftNotesFactory.create(c, otherUserId);
+    assertThat(draftNotes.load().getComments().get(rev1))
+        .containsExactly(comment1, comment2);
+
+    comment2.setStatus(Status.PUBLISHED); // Reset for later assertions.
+
+    // Zombie comment is filtered out of drafts via ChangeNotes.
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1))
+        .containsExactly(comment1);
+    assertThat(notes.getComments().get(rev1))
+        .containsExactly(comment2);
+
+    update = newUpdate(c, otherUser);
+    update.setPatchSetId(ps1);
+    comment1.setStatus(Status.PUBLISHED);
+    update.putComment(comment1);
+    update.commit();
+
+    // Updating an unrelated comment causes the zombie comment to get fixed up.
+    assertThat(exactRefAllUsers(refName)).isNull();
+  }
+
+  @Test
+  public void updateCommentsInSequentialUpdates() throws Exception {
+    Change c = newChange();
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
+
+    ChangeUpdate update1 = newUpdate(c, otherUser);
+    PatchLineComment comment1 = newComment(c.currentPatchSetId(), "filename",
+        "uuid1", range, range.getEndLine(), otherUser, null,
+        new Timestamp(update1.getWhen().getTime()), "comment 1", (short) 1, rev,
+        Status.PUBLISHED);
+    update1.putComment(comment1);
+
+    ChangeUpdate update2 = newUpdate(c, otherUser);
+    PatchLineComment comment2 = newComment(c.currentPatchSetId(), "filename",
+        "uuid2", range, range.getEndLine(), otherUser, null,
+        new Timestamp(update2.getWhen().getTime()), "comment 2", (short) 1, rev,
+        Status.PUBLISHED);
+    update2.putComment(comment2);
+
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
+      manager.add(update1);
+      manager.add(update2);
+      manager.execute();
+    }
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchLineComment> comments = notes.getComments().get(new RevId(rev));
+    assertThat(comments).hasSize(2);
+    assertThat(comments.get(0).getMessage()).isEqualTo("comment 1");
+    assertThat(comments.get(1).getMessage()).isEqualTo("comment 2");
+  }
+
+  private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
+    ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
+    return new String(
+        rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
+  }
+
+  private ObjectId exactRefAllUsers(String refName) throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Ref ref = allUsersRepo.exactRef(refName);
+      return ref != null ? ref.getObjectId() : null;
+    }
+  }
+
+  private void assertCause(Throwable e,
+      Class<? extends Throwable> expectedClass, String expectedMsg) {
+    Throwable cause = null;
+    for (Throwable t : Throwables.getCausalChain(e)) {
+      if (expectedClass.isAssignableFrom(t.getClass())) {
+        cause = t;
+        break;
+      }
+    }
+    assertThat(cause)
+        .named(expectedClass.getSimpleName() + " in causal chain of:\n"
+            + Throwables.getStackTraceAsString(e))
+        .isNotNull();
+    assertThat(cause.getMessage()).isEqualTo(expectedMsg);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index c206902..bf5abba 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.ReviewerState.CC;
-import static com.google.gerrit.server.notedb.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,10 +46,14 @@
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
-    RevCommit commit = parseCommit(update.getRevision());
+    RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals("Update patch set 1\n"
         + "\n"
         + "Patch-set: 1\n"
+        + "Change-id: " + c.getKey().get() + "\n"
+        + "Subject: Change subject\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: " + update.getCommit().name() + "\n"
         + "Reviewer: Change Owner <1@gerrit>\n"
         + "CC: Other Account <2@gerrit>\n"
         + "Label: Code-Review=-1\n"
@@ -84,8 +89,34 @@
         + "Just a little code change.\n"
         + "How about a new line\n"
         + "\n"
-        + "Patch-set: 1\n",
-        update.getRevision());
+        + "Patch-set: 1\n"
+        + "Change-id: " + c.getKey().get() + "\n"
+        + "Subject: Change subject\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: " + update.getCommit().name() + "\n",
+        update.getResult());
+  }
+
+  @Test
+  public void changeWithRevision() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Foo");
+    RevCommit commit = tr.commit().message("Subject").create();
+    update.setCommit(rw, commit);
+    update.commit();
+    assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Foo\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Change-id: " + c.getKey().get() + "\n"
+        + "Subject: Subject\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: " + commit.name() + "\n",
+        update.getResult());
   }
 
   @Test
@@ -99,16 +130,17 @@
         + "\n"
         + "Patch-set: 1\n"
         + "Label: -Code-Review\n",
-        update.getRevision());
+        update.getResult());
   }
 
   @Test
   public void submitCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubject("Submit patch set 1");
+    update.setSubjectForCommit("Submit patch set 1");
 
-    update.merge(ImmutableList.of(
+    RequestId submissionId = RequestId.forChange(c);
+    update.merge(submissionId, ImmutableList.of(
         submitRecord("NOT_READY", null,
           submitLabel("Verified", "OK", changeOwner.getAccountId()),
           submitLabel("Code-Review", "NEED", null)),
@@ -117,11 +149,12 @@
           submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
-    RevCommit commit = parseCommit(update.getRevision());
+    RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals("Submit patch set 1\n"
         + "\n"
         + "Patch-set: 1\n"
         + "Status: merged\n"
+        + "Submission-id: " + submissionId.toStringForStorage() + "\n"
         + "Submitted-with: NOT_READY\n"
         + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
         + "Submitted-with: NEED: Code-Review\n"
@@ -134,7 +167,7 @@
     assertThat(author.getName()).isEqualTo("Change Owner");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
     assertThat(author.getWhen())
-        .isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
+        .isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
     assertThat(author.getTimeZone())
         .isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
 
@@ -154,7 +187,7 @@
     update.setChangeMessage("Comment on the change.");
     update.commit();
 
-    RevCommit commit = parseCommit(update.getRevision());
+    RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals("Update patch set 1\n"
         + "\n"
         + "Comment on the change.\n"
@@ -171,9 +204,10 @@
   public void submitWithErrorMessage() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setSubject("Submit patch set 1");
+    update.setSubjectForCommit("Submit patch set 1");
 
-    update.merge(ImmutableList.of(
+    RequestId submissionId = RequestId.forChange(c);
+    update.merge(submissionId, ImmutableList.of(
         submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
 
@@ -181,8 +215,9 @@
         + "\n"
         + "Patch-set: 1\n"
         + "Status: merged\n"
+        + "Submission-id: " + submissionId.toStringForStorage() + "\n"
         + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
-        update.getRevision());
+        update.getResult());
   }
 
   @Test
@@ -196,7 +231,7 @@
         + "\n"
         + "Patch-set: 1\n"
         + "Reviewer: Change Owner <1@gerrit>\n",
-        update.getRevision());
+        update.getResult());
   }
 
   @Test
@@ -214,7 +249,7 @@
         + "\n"
         + "\n"
         + "Patch-set: 1\n",
-        update.getRevision());
+        update.getResult());
   }
 
   @Test
@@ -237,7 +272,61 @@
         + "Testing paragraph 3\n"
         + "\n"
         + "Patch-set: 1\n",
-        update.getRevision());
+        update.getResult());
+  }
+
+  @Test
+  public void changeMessageWithTag() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Change message with tag");
+    update.setTag("jenkins");
+    update.commit();
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Change message with tag\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Tag: jenkins\n",
+        update.getResult());
+  }
+
+  @Test
+  public void leadingWhitespace() throws Exception {
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(),
+        c.getOriginalSubject());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Change-id: " + c.getKey().get() + "\n"
+        + "Subject:   Change subject\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: " + update.getCommit().name() + "\n",
+        update.getResult());
+
+    c = TestChanges.newChange(project, changeOwner.getAccountId());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(),
+        c.getOriginalSubject());
+    update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setBranch(c.getDest().get());
+    update.commit();
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Change-id: " + c.getKey().get() + "\n"
+        + "Subject: \t\tChange subject\n"
+        + "Branch: refs/heads/master\n"
+        + "Commit: " + update.getCommit().name() + "\n",
+        update.getResult());
   }
 
   private RevCommit parseCommit(ObjectId id) throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
new file mode 100644
index 0000000..216f71b
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
+import static org.eclipse.jgit.lib.ObjectId.zeroId;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.notedb.NoteDbChangeState.Delta;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+/** Unit tests for {@link NoteDbChangeState}. */
+public class NoteDbChangeStateTest {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  ObjectId SHA1 =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+  ObjectId SHA2 =
+      ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+  ObjectId SHA3 =
+      ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
+
+  @Test
+  public void parseWithoutDrafts() {
+    NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
+
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).isEmpty();
+
+    assertThat(state.toString()).isEqualTo(SHA1.name());
+  }
+
+  @Test
+  public void parseWithDrafts() {
+    NoteDbChangeState state = parse(
+        new Change.Id(1),
+        SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name());
+
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).containsExactly(
+        new Account.Id(1001), SHA3,
+        new Account.Id(2003), SHA2);
+
+    assertThat(state.toString()).isEqualTo(
+        SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name());
+  }
+
+  @Test
+  public void applyDeltaToNullWithNoNewMetaId() {
+    Change c = newChange();
+    assertThat(c.getNoteDbState()).isNull();
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
+    assertThat(c.getNoteDbState()).isNull();
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(),
+          drafts(new Account.Id(1001), zeroId())));
+    assertThat(c.getNoteDbState()).isNull();
+  }
+
+  @Test
+  public void applyDeltaToMetaId() {
+    Change c = newChange();
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name());
+
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA2), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
+
+    // No-op delta.
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA2.name());
+
+    // Set to zero clears the field.
+    applyDelta(c, Delta.create(c.getId(), metaId(zeroId()), noDrafts()));
+    assertThat(c.getNoteDbState()).isNull();
+  }
+
+  @Test
+  public void applyDeltaToDrafts() {
+    Change c = newChange();
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1),
+          drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo(
+        SHA1.name() + ",1001=" + SHA2.name());
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(),
+          drafts(new Account.Id(2003), SHA3)));
+    assertThat(c.getNoteDbState()).isEqualTo(
+        SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
+
+    applyDelta(c, Delta.create(c.getId(), noMetaId(),
+          drafts(new Account.Id(2003), zeroId())));
+    assertThat(c.getNoteDbState()).isEqualTo(
+        SHA1.name() + ",1001=" + SHA2.name());
+
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA3), noDrafts()));
+    assertThat(c.getNoteDbState()).isEqualTo(
+        SHA3.name() + ",1001=" + SHA2.name());
+  }
+
+  private static Change newChange() {
+    return TestChanges.newChange(
+        new Project.NameKey("project"), new Account.Id(12345));
+  }
+
+  // Static factory methods to avoid type arguments when using as method args.
+
+  private static Optional<ObjectId> noMetaId() {
+    return Optional.absent();
+  }
+
+  private static Optional<ObjectId> metaId(ObjectId id) {
+    return Optional.of(id);
+  }
+
+  private static ImmutableMap<Account.Id, ObjectId> noDrafts() {
+    return ImmutableMap.of();
+  }
+
+  private static ImmutableMap<Account.Id, ObjectId> drafts(Object... args) {
+    checkArgument(args.length % 2 == 0);
+    ImmutableMap.Builder<Account.Id, ObjectId> b = ImmutableMap.builder();
+    for (int i = 0; i < args.length / 2; i++) {
+      b.put((Account.Id) args[2 * i], (ObjectId) args[2 * i + 1]);
+    }
+    return b.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
new file mode 100644
index 0000000..cab6549
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -0,0 +1,322 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
+
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+
+import com.github.rholder.retry.BlockStrategy;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class RepoSequenceTest {
+  private static final Retryer<RefUpdate.Result> RETRYER =
+      RepoSequence.retryerBuilder().withBlockStrategy(new BlockStrategy() {
+        @Override
+        public void block(long sleepTime) {
+          // Don't sleep in tests.
+        }
+      }).build();
+
+  @Rule
+  public ExpectedException exception = ExpectedException.none();
+
+  private InMemoryRepositoryManager repoManager;
+  private Project.NameKey project;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    project = new Project.NameKey("project");
+    repoManager.createRepository(project);
+  }
+
+  @Test
+  public void oneCaller() throws Exception {
+    int max = 20;
+    for (int batchSize = 1; batchSize <= 10; batchSize++) {
+      String name = "batch-size-" + batchSize;
+      RepoSequence s = newSequence(name, 1, batchSize);
+      for (int i = 1; i <= max; i++) {
+        try {
+          assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
+        } catch (OrmException e) {
+          throw new AssertionError(
+              "failed batchSize=" + batchSize + ", i=" + i, e);
+        }
+      }
+      assertThat(s.acquireCount)
+          .named("acquireCount for " + name)
+          .isEqualTo(divCeil(max, batchSize));
+    }
+  }
+
+  @Test
+  public void oneCallerNoLoop() throws Exception {
+    RepoSequence s = newSequence("id", 1, 3);
+    assertThat(s.acquireCount).isEqualTo(0);
+
+    assertThat(s.next()).isEqualTo(1);
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next()).isEqualTo(2);
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next()).isEqualTo(3);
+    assertThat(s.acquireCount).isEqualTo(1);
+
+    assertThat(s.next()).isEqualTo(4);
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next()).isEqualTo(5);
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next()).isEqualTo(6);
+    assertThat(s.acquireCount).isEqualTo(2);
+
+    assertThat(s.next()).isEqualTo(7);
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next()).isEqualTo(8);
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next()).isEqualTo(9);
+    assertThat(s.acquireCount).isEqualTo(3);
+
+    assertThat(s.next()).isEqualTo(10);
+    assertThat(s.acquireCount).isEqualTo(4);
+  }
+
+  @Test
+  public void twoCallers() throws Exception {
+    RepoSequence s1 = newSequence("id", 1, 3);
+    RepoSequence s2 = newSequence("id", 1, 3);
+
+    // s1 acquires 1-3; s2 acquires 4-6.
+    assertThat(s1.next()).isEqualTo(1);
+    assertThat(s2.next()).isEqualTo(4);
+    assertThat(s1.next()).isEqualTo(2);
+    assertThat(s2.next()).isEqualTo(5);
+    assertThat(s1.next()).isEqualTo(3);
+    assertThat(s2.next()).isEqualTo(6);
+
+    // s2 acquires 7-9; s1 acquires 10-12.
+    assertThat(s2.next()).isEqualTo(7);
+    assertThat(s1.next()).isEqualTo(10);
+    assertThat(s2.next()).isEqualTo(8);
+    assertThat(s1.next()).isEqualTo(11);
+    assertThat(s2.next()).isEqualTo(9);
+    assertThat(s1.next()).isEqualTo(12);
+  }
+
+  @Test
+  public void populateEmptyRefWithStartValue() throws Exception {
+    RepoSequence s = newSequence("id", 1234, 10);
+    assertThat(s.next()).isEqualTo(1234);
+    assertThat(readBlob("id")).isEqualTo("1244");
+  }
+
+  @Test
+  public void startIsIgnoredIfRefIsPresent() throws Exception {
+    writeBlob("id", "1234");
+    RepoSequence s = newSequence("id", 3456, 10);
+    assertThat(s.next()).isEqualTo(1234);
+    assertThat(readBlob("id")).isEqualTo("1244");
+  }
+
+  @Test
+  public void retryOnLockFailure() throws Exception {
+    // Seed existing ref value.
+    writeBlob("id", "1");
+
+    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    Runnable bgUpdate = new Runnable() {
+      @Override
+      public void run() {
+        if (!doneBgUpdate.getAndSet(true)) {
+          writeBlob("id", "1234");
+        }
+      }
+    };
+
+    RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
+    assertThat(doneBgUpdate.get()).isFalse();
+    assertThat(s.next()).isEqualTo(1234);
+    // Single acquire call that results in 2 ref reads.
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(doneBgUpdate.get()).isTrue();
+  }
+
+  @Test
+  public void failOnInvalidValue() throws Exception {
+    ObjectId id = writeBlob("id", "not a number");
+    exception.expect(OrmException.class);
+    exception.expectMessage(
+        "invalid value in refs/sequences/id blob at " + id.name());
+    newSequence("id", 1, 3).next();
+  }
+
+  @Test
+  public void failOnWrongType() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<Repository> tr = new TestRepository<>(repo);
+      tr.branch(RefNames.REFS_SEQUENCES + "id").commit().create();
+      try {
+        newSequence("id", 1, 3).next();
+        fail();
+      } catch (OrmException e) {
+        assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
+        assertThat(e.getCause().getCause())
+            .isInstanceOf(IncorrectObjectTypeException.class);
+      }
+    }
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    final AtomicInteger bgCounter = new AtomicInteger(1234);
+    Runnable bgUpdate = new Runnable() {
+      @Override
+      public void run() {
+        writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
+      }
+    };
+    RepoSequence s = newSequence(
+        "id", 1, 10, bgUpdate,
+        RetryerBuilder.<RefUpdate.Result> newBuilder()
+          .withStopStrategy(StopStrategies.stopAfterAttempt(3))
+          .build());
+    exception.expect(OrmException.class);
+    exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
+    s.next();
+  }
+
+  @Test
+  public void nextWithCountOneCaller() throws Exception {
+    RepoSequence s = newSequence("id", 1, 3);
+    assertThat(s.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.next(2)).containsExactly(3, 4).inOrder();
+    assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.next(2)).containsExactly(5, 6).inOrder();
+    assertThat(s.acquireCount).isEqualTo(2);
+
+    assertThat(s.next(3)).containsExactly(7, 8, 9).inOrder();
+    assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.next(3)).containsExactly(10, 11, 12).inOrder();
+    assertThat(s.acquireCount).isEqualTo(4);
+    assertThat(s.next(3)).containsExactly(13, 14, 15).inOrder();
+    assertThat(s.acquireCount).isEqualTo(5);
+
+    assertThat(s.next(7)).containsExactly(16, 17, 18, 19, 20, 21, 22).inOrder();
+    assertThat(s.acquireCount).isEqualTo(6);
+    assertThat(s.next(7)).containsExactly(23, 24, 25, 26, 27, 28, 29).inOrder();
+    assertThat(s.acquireCount).isEqualTo(7);
+    assertThat(s.next(7)).containsExactly(30, 31, 32, 33, 34, 35, 36).inOrder();
+    assertThat(s.acquireCount).isEqualTo(8);
+  }
+
+  @Test
+  public void nextWithCountMultipleCallers() throws Exception {
+    RepoSequence s1 = newSequence("id", 1, 3);
+    RepoSequence s2 = newSequence("id", 1, 4);
+
+    assertThat(s1.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s1.acquireCount).isEqualTo(1);
+
+    // s1 hasn't exhausted its last batch.
+    assertThat(s2.next(2)).containsExactly(4, 5).inOrder();
+    assertThat(s2.acquireCount).isEqualTo(1);
+
+    // s1 acquires again to cover this request, plus a whole new batch.
+    assertThat(s1.next(3)).containsExactly(3, 8, 9);
+    assertThat(s1.acquireCount).isEqualTo(2);
+
+    // s2 hasn't exhausted its last batch, do so now.
+    assertThat(s2.next(2)).containsExactly(6, 7);
+    assertThat(s2.acquireCount).isEqualTo(1);
+  }
+
+  private RepoSequence newSequence(String name, int start, int batchSize) {
+    return newSequence(
+        name, start, batchSize, Runnables.doNothing(), RETRYER);
+  }
+
+  private RepoSequence newSequence(String name, final int start, int batchSize,
+      Runnable afterReadRef, Retryer<RefUpdate.Result> retryer) {
+    return new RepoSequence(
+        repoManager,
+        project,
+        name,
+        new RepoSequence.Seed() {
+          @Override
+          public int get() {
+            return start;
+          }
+        },
+        batchSize,
+        afterReadRef,
+        retryer);
+  }
+
+  private ObjectId writeBlob(String sequenceName, String value) {
+    String refName = RefNames.REFS_SEQUENCES + sequenceName;
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId newId = ins.insert(OBJ_BLOB, value.getBytes(UTF_8));
+      ins.flush();
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setNewObjectId(newId);
+      assertThat(ru.forceUpdate())
+          .isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
+      return newId;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private String readBlob(String sequenceName) throws Exception {
+    String refName = RefNames.REFS_SEQUENCES + sequenceName;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId id = repo.exactRef(refName).getObjectId();
+      return new String(rw.getObjectReader().open(id).getCachedBytes(), UTF_8);
+    }
+  }
+
+  private static long divCeil(float a, float b) {
+    return Math.round(Math.ceil(a / b));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
new file mode 100644
index 0000000..eda2b82
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -0,0 +1,222 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.ReplaceEdit;
+import org.junit.Test;
+
+import java.util.List;
+
+public class IntraLineLoaderTest {
+
+  @Test
+  public void rewriteAtStartOfLineIsRecognized() throws Exception {
+    String a = "abc1\n";
+    String b = "def1\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .replace("abc", "def").common("1\n").edits
+    );
+  }
+
+  @Test
+  public void rewriteAtEndOfLineIsRecognized() throws Exception {
+    String a = "abc1\n";
+    String b = "abc2\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common("abc").replace("1", "2").common("\n").edits
+    );
+  }
+
+  @Test
+  public void completeRewriteIncludesNewline() throws Exception {
+    String a = "abc1\n";
+    String b = "def2\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .replace("abc1\n", "def2\n").edits
+    );
+  }
+
+  @Test
+  public void closeEditsAreCombined() throws Exception {
+    String a = "ab1cdef2gh\n";
+    String b = "ab2cdef3gh\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common("ab").replace("1cdef2", "2cdef3").common("gh\n").edits
+    );
+  }
+
+  @Test
+  public void preferInsertAfterCommonPart1() throws Exception {
+    String a = "start middle end\n";
+    String b = "start middlemiddle end\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common("start middle").insert("middle").common(" end\n").edits
+    );
+  }
+
+  @Test
+  public void preferInsertAfterCommonPart2() throws Exception {
+    String a = "abc def\n";
+    String b = "abc  def\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common("abc ").insert(" ").common("def\n").edits
+    );
+  }
+
+  @Test
+  public void preferInsertAtLineBreak1() throws Exception {
+    String a = "multi\nline\n";
+    String b = "multi\nlinemulti\nline\n";
+    assertThat(intraline(a, b)).isEqualTo(wordEdit(10, 10, 6, 16));
+    // better would be:
+    //assertThat(intraline(a, b)).isEqualTo(wordEdit(6, 6, 6, 16));
+    // or the equivalent:
+    //assertThat(intraline(a, b)).isEqualTo(ref()
+    //    .common("multi\n").insert("linemulti\n").common("line\n").edits
+    //);
+  }
+
+  //TODO: expected failure
+  // the current code does not work on the first line
+  // and the insert marker is in the wrong location
+  @Test(expected = AssertionError.class)
+  public void preferInsertAtLineBreak2() throws Exception {
+    String a = "  abc\n    def\n";
+    String b = "    abc\n      def\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .insert("  ").common("  abc\n")
+        .insert("  ").common("  def\n").edits
+    );
+  }
+
+  //TODO: expected failure
+  // the current code does not work on the first line
+  @Test(expected = AssertionError.class)
+  public void preferDeleteAtLineBreak() throws Exception {
+    String a = "    abc\n      def\n";
+    String b = "  abc\n    def\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .remove("  ").common("  abc\n")
+        .remove("  ").common("  def\n").edits
+    );
+  }
+
+  @Test
+  public void insertedWhitespaceIsRecognized() throws Exception {
+    String a = " int *foobar\n";
+    String b = " int * foobar\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common(" int *").insert(" ").common("foobar\n").edits
+    );
+  }
+
+  @Test
+  public void insertedWhitespaceIsRecognizedInMultipleLines() throws Exception {
+    //         |0    5   10  |  5   20    5   30
+    String a = " int *foobar\n int *foobar\n";
+    String b = " int * foobar\n int * foobar\n";
+    assertThat(intraline(a, b)).isEqualTo(ref()
+        .common(" int *").insert(" ").common("foobar\n")
+        .common(" int *").insert(" ").common("foobar\n").edits
+    );
+  }
+
+  // helper functions to call IntraLineLoader.compute
+
+  private static int countLines(String s) {
+    int count = 0;
+    for (int i = 0; i < s.length(); i++) {
+      if (s.charAt(i) == '\n') {
+        count++;
+      }
+    }
+    return count;
+  }
+
+  private static List<Edit> intraline(String a, String b) throws Exception {
+    return intraline(a, b, new Edit(0, countLines(a), 0, countLines(b)));
+  }
+
+  private static List<Edit> intraline(String a, String b, Edit lines)
+      throws Exception {
+    Text aText = new Text(a.getBytes(UTF_8));
+    Text bText = new Text(b.getBytes(UTF_8));
+
+    IntraLineDiff diff;
+    diff = IntraLineLoader.compute(aText, bText, EditList.singleton(lines));
+
+    assertThat(diff.getStatus()).isEqualTo(IntraLineDiff.Status.EDIT_LIST);
+    List<Edit> actualEdits = diff.getEdits();
+    assertThat(actualEdits).hasSize(1);
+    Edit actualEdit = actualEdits.get(0);
+    assertThat(actualEdit.getBeginA()).isEqualTo(lines.getBeginA());
+    assertThat(actualEdit.getEndA()).isEqualTo(lines.getEndA());
+    assertThat(actualEdit.getBeginB()).isEqualTo(lines.getBeginB());
+    assertThat(actualEdit.getEndB()).isEqualTo(lines.getEndB());
+    assertThat(actualEdit).isInstanceOf(ReplaceEdit.class);
+
+    return ((ReplaceEdit) actualEdit).getInternalEdits();
+  }
+
+  // helpers to compute reference values
+
+  private static List<Edit> wordEdit(int as, int ae, int bs, int be) {
+    return EditList.singleton(new Edit(as, ae, bs, be));
+  }
+
+  private static Reference ref() {
+    return new Reference();
+  }
+
+  private static class Reference {
+    List<Edit> edits;
+    private int posA;
+    private int posB;
+
+    Reference() {
+      edits = new EditList();
+      posA = posB = 0;
+    }
+
+    Reference common(String s) {
+      int len = s.length();
+      posA += len;
+      posB += len;
+      return this;
+    }
+
+    Reference insert(String s) {
+      return replace("", s);
+    }
+
+    Reference remove(String s) {
+      return replace(s, "");
+    }
+
+    Reference replace(String a, String b) {
+      int lenA = a.length();
+      int lenB = b.length();
+      edits.add(new Edit(posA, posA + lenA, posB, posB + lenB));
+      posA += lenA;
+      posB += lenB;
+      return this;
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
index 0bd4f51..79983f9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
@@ -16,31 +16,40 @@
 
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.allow;
-import static com.google.gerrit.server.project.Util.deny;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
@@ -55,12 +64,18 @@
   @Inject private InMemoryRepositoryManager repoManager;
   @Inject private ProjectControl.GenericFactory projectControlFactory;
   @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected GroupCache groupCache;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private TestRepository<InMemoryRepository> repo;
   private ProjectConfig project;
   private IdentifiedUser user;
+  private AccountGroup.UUID admins;
 
   @Before
   public void setUp() throws Exception {
@@ -72,15 +87,34 @@
 
     db = schemaFactory.open();
     schemaCreator.create(db);
+    // Need to create at least one user to be admin before creating a "normal"
+    // registered user.
+    // See AccountManager#create().
+    accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId();
+    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID();
+    setUpPermissions();
+
     Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
         .getAccountId();
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
 
     Project.NameKey name = new Project.NameKey("project");
     InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
     project = new ProjectConfig(name);
     project.load(inMemoryRepo);
     repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return user;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
   }
 
   @After
@@ -91,6 +125,7 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
+    requestContext.setContext(null);
     if (db != null) {
       db.close();
     }
@@ -103,7 +138,25 @@
     ObjectId id = repo.branch("master").commit().create();
     ProjectControl pc = newProjectControl();
     RevWalk rw = repo.getRevWalk();
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(id)));
+    Repository r = repo.getRepository();
+
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id)));
+  }
+
+  @Test
+  public void canReadCommitIfTwoRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    ObjectId id1 = repo.branch("branch1").commit().create();
+    ObjectId id2 = repo.branch("branch2").commit().create();
+
+    ProjectControl pc = newProjectControl();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id2)));
   }
 
   @Test
@@ -116,8 +169,10 @@
 
     ProjectControl pc = newProjectControl();
     RevWalk rw = repo.getRevWalk();
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(id1)));
-    assertFalse(pc.canReadCommit(db, rw, rw.parseCommit(id2)));
+    Repository r = repo.getRepository();
+
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
+    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id2)));
   }
 
   @Test
@@ -133,8 +188,9 @@
 
     ProjectControl pc = newProjectControl();
     RevWalk rw = repo.getRevWalk();
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, rw, rw.parseCommit(parent2)));
+    Repository r = repo.getRepository();
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
+    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(parent2)));
   }
 
   @Test
@@ -146,12 +202,14 @@
 
     ProjectControl pc = newProjectControl();
     RevWalk rw = repo.getRevWalk();
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(parent1)));
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(id1)));
+    Repository r = repo.getRepository();
+
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
 
     repo.branch("branch1").update(parent1);
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, rw, rw.parseCommit(id1)));
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
+    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id1)));
   }
 
   @Test
@@ -163,15 +221,49 @@
 
     ProjectControl pc = newProjectControl();
     RevWalk rw = repo.getRevWalk();
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(parent1)));
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(id1)));
+    Repository r = repo.getRepository();
+
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
 
     repo.branch("branch1").update(parent1);
-    assertTrue(pc.canReadCommit(db, rw, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, rw, rw.parseCommit(id1)));
+    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
+    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id1)));
   }
 
   private ProjectControl newProjectControl() throws Exception {
     return projectControlFactory.controlFor(project.getName(), user);
   }
+
+  protected void allow(ProjectConfig project, String permission,
+      AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.allow(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void deny(ProjectConfig project, String permission,
+      AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.deny(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+  }
+
+  private void setUpPermissions() throws Exception {
+    // Remove read permissions for all users besides admin, because by default
+    // Anonymous user group has ALLOW READ permission in refs/*.
+    // This method is idempotent, so is safe to call on every test setup.
+    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    allow(pc, Permission.READ, admins, "refs/*");
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 9706feb..d4d77bd 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -32,21 +32,62 @@
 import static com.google.gerrit.server.project.Util.doNotInherit;
 import static com.google.gerrit.testutil.InMemoryRepositoryManager.newRepository;
 
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.rules.PrologEnvironment;
+import com.google.gerrit.rules.RulesCache;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 public class RefControlTest {
   private void assertAdminsAreOwnersAndDevsAreNot() {
-    ProjectControl uBlah = util.user(local, DEVS);
-    ProjectControl uAdmin = util.user(local, DEVS, ADMIN);
+    ProjectControl uBlah = user(local, DEVS);
+    ProjectControl uAdmin = user(local, DEVS, ADMIN);
 
     assertThat(uBlah.isOwner()).named("not owner").isFalse();
     assertThat(uAdmin.isOwner()).named("is owner").isTrue();
@@ -179,27 +220,138 @@
       .isFalse();
   }
 
+  private final AllProjectsName allProjectsName =
+      new AllProjectsName(AllProjectsNameProvider.DEFAULT);
+  private final AllUsersName allUsersName =
+      new AllUsersName(AllUsersNameProvider.DEFAULT);
   private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
+  private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
   private Project.NameKey localKey = new Project.NameKey("local");
   private ProjectConfig local;
   private Project.NameKey parentKey = new Project.NameKey("parent");
   private ProjectConfig parent;
-  private final Util util;
+  private InMemoryRepositoryManager repoManager;
+  private ProjectCache projectCache;
+  private PermissionCollection.Factory sectionSorter;
+  private ChangeControl.Factory changeControlFactory;
+  private ReviewDb db;
 
-  public RefControlTest() {
-    util = new Util();
-  }
+  @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
+  @Inject private CapabilityControl.Factory capabilityControlFactory;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private InMemoryDatabase schemaFactory;
+  @Inject private ThreadLocalRequestContext requestContext;
 
   @Before
   public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    projectCache = new ProjectCache() {
+      @Override
+      public ProjectState getAllProjects() {
+        return get(allProjectsName);
+      }
+
+      @Override
+      public ProjectState getAllUsers() {
+        return null;
+      }
+
+      @Override
+      public ProjectState get(Project.NameKey projectName) {
+        return all.get(projectName);
+      }
+
+      @Override
+      public void evict(Project p) {
+      }
+
+      @Override
+      public void remove(Project p) {
+      }
+
+      @Override
+      public Iterable<Project.NameKey> all() {
+        return Collections.emptySet();
+      }
+
+      @Override
+      public Iterable<Project.NameKey> byName(String prefix) {
+        return Collections.emptySet();
+      }
+
+      @Override
+      public void onCreateProject(Project.NameKey newProjectName) {
+      }
+
+      @Override
+      public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
+        return Collections.emptySet();
+      }
+
+      @Override
+      public ProjectState checkedGet(Project.NameKey projectName)
+          throws IOException {
+        return all.get(projectName);
+      }
+
+      @Override
+      public void evict(Project.NameKey p) {
+      }
+    };
+
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    try {
+      Repository repo = repoManager.createRepository(allProjectsName);
+      ProjectConfig allProjects =
+          new ProjectConfig(new Project.NameKey(allProjectsName.get()));
+      allProjects.load(repo);
+      LabelType cr = Util.codeReview();
+      allProjects.getLabelSections().put(cr.getName(), cr);
+      add(allProjects);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RuntimeException(e);
+    }
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
+        CacheBuilder.newBuilder().build();
+    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c));
+
     parent = new ProjectConfig(parentKey);
     parent.load(newRepository(parentKey));
-    util.add(parent);
+    add(parent);
 
     local = new ProjectConfig(localKey);
     local.load(newRepository(localKey));
-    util.add(local);
+    add(local);
     local.getProject().setParentName(parentKey);
+
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return null;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
+
+    changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
+  }
+
+  @After
+  public void tearDown() {
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
   }
 
   @Test
@@ -230,7 +382,7 @@
     allow(local, OWNER, ADMIN, "refs/*");
     allow(local, OWNER, DEVS, "refs/heads/x/*");
 
-    ProjectControl uDev = util.user(local, DEVS);
+    ProjectControl uDev = user(local, DEVS);
     assertNotOwner(uDev);
     assertOwnerAnyRef(uDev);
 
@@ -249,7 +401,7 @@
     allow(local, OWNER, fixers, "refs/heads/x/y/*");
     doNotInherit(local, OWNER, "refs/heads/x/y/*");
 
-    ProjectControl uDev = util.user(local, DEVS);
+    ProjectControl uDev = user(local, DEVS);
     assertNotOwner(uDev);
     assertOwnerAnyRef(uDev);
 
@@ -259,7 +411,7 @@
     assertNotOwner("refs/*", uDev);
     assertNotOwner("refs/heads/master", uDev);
 
-    ProjectControl uFix = util.user(local, fixers);
+    ProjectControl uFix = user(local, fixers);
     assertNotOwner(uFix);
     assertOwnerAnyRef(uFix);
 
@@ -279,7 +431,7 @@
     doNotInherit(local, READ, "refs/heads/foobar");
     doNotInherit(local, PUSH, "refs/for/refs/heads/foobar");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCanUpload(u);
     assertCanUpload("refs/heads/master", u);
     assertCannotUpload("refs/heads/foobar", u);
@@ -290,7 +442,7 @@
     allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCanUpload("refs/heads/master", u);
     assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
   }
@@ -300,8 +452,8 @@
     block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
     allow(parent, PUSH, ADMIN, "refs/drafts/*");
 
-    ProjectControl u = util.user(local);
-    ProjectControl a = util.user(local, "a", ADMIN);
+    ProjectControl u = user(local);
+    ProjectControl a = user(local, "a", ADMIN);
     assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
     assertNotBlocked(PUSH, "refs/drafts/refs/heads/master", a);
   }
@@ -312,7 +464,7 @@
     allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCanUpload(u);
     assertCanUpload("refs/heads/master", u);
     assertCanUpload("refs/heads/foobar", u);
@@ -322,13 +474,13 @@
   public void testInheritDuplicateSections() throws Exception {
     allow(parent, READ, ADMIN, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
-    assertCanRead(util.user(local, "a", ADMIN));
+    assertCanRead(user(local, "a", ADMIN));
 
     local = new ProjectConfig(localKey);
     local.load(newRepository(localKey));
     local.getProject().setParentName(parentKey);
     allow(local, READ, DEVS, "refs/*");
-    assertCanRead(util.user(local, "d", DEVS));
+    assertCanRead(user(local, "d", DEVS));
   }
 
   @Test
@@ -336,7 +488,7 @@
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
 
-    assertCannotRead(util.user(local));
+    assertCannotRead(user(local));
   }
 
   @Test
@@ -344,7 +496,7 @@
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCanRead(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
@@ -357,7 +509,7 @@
     deny(local, READ, REGISTERED_USERS, "refs/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCanRead(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
@@ -370,7 +522,7 @@
     deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
     allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCannotSubmit("refs/foobar", u);
     assertCannotSubmit("refs/tags/foobar", u);
     assertCanSubmit("refs/heads/foobar", u);
@@ -382,7 +534,7 @@
     allow(local, READ, DEVS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertCannotUpload(u);
     assertCannotUpload("refs/heads/master", u);
   }
@@ -390,7 +542,7 @@
   @Test
   public void testUsernamePatternCanUploadToAnyRef() {
     allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
-    ProjectControl u = util.user(local, "a-registered-user");
+    ProjectControl u = user(local, "a-registered-user");
     assertCanUpload(u);
   }
 
@@ -398,8 +550,8 @@
   public void testUsernamePatternNonRegex() {
     allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
 
-    ProjectControl u = util.user(local, "u", DEVS);
-    ProjectControl d = util.user(local, "d", DEVS);
+    ProjectControl u = user(local, "u", DEVS);
+    ProjectControl d = user(local, "d", DEVS);
     assertCannotRead("refs/sb/d/heads/foobar", u);
     assertCanRead("refs/sb/d/heads/foobar", d);
   }
@@ -408,8 +560,8 @@
   public void testUsernamePatternWithRegex() {
     allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
 
-    ProjectControl u = util.user(local, "d.v", DEVS);
-    ProjectControl d = util.user(local, "dev", DEVS);
+    ProjectControl u = user(local, "d.v", DEVS);
+    ProjectControl d = user(local, "dev", DEVS);
     assertCannotRead("refs/sb/dev/heads/foobar", u);
     assertCanRead("refs/sb/dev/heads/foobar", d);
   }
@@ -418,8 +570,8 @@
   public void testUsernameEmailPatternWithRegex() {
     allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
 
-    ProjectControl u = util.user(local, "d.v@ger-rit.org", DEVS);
-    ProjectControl d = util.user(local, "dev@ger-rit.org", DEVS);
+    ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
+    ProjectControl d = user(local, "dev@ger-rit.org", DEVS);
     assertCannotRead("refs/sb/dev@ger-rit.org/heads/foobar", u);
     assertCanRead("refs/sb/dev@ger-rit.org/heads/foobar", d);
   }
@@ -429,8 +581,8 @@
     allow(local, READ, DEVS, "^refs/heads/.*");
     allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
 
-    ProjectControl u = util.user(local, DEVS);
-    ProjectControl d = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
+    ProjectControl d = user(local, DEVS);
     assertCanRead("refs/heads/foo-QA-bar", u);
     assertCanRead("refs/heads/foo-QA-bar", d);
   }
@@ -439,7 +591,7 @@
   public void testBlockRule_ParentBlocksChild() {
     allow(local, PUSH, DEVS, "refs/tags/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
@@ -449,7 +601,7 @@
     block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCannotUpdate("refs/tags/V10", u);
   }
 
@@ -458,7 +610,7 @@
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
     block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
 
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
@@ -474,7 +626,7 @@
     block(parent, LABEL + "Code-Review", -2, +2, DEVS,
         "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
 
     PermissionRange range =
         u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
@@ -490,7 +642,7 @@
     allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
     allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local);
+    ProjectControl u = user(local);
     assertNotBlocked(SUBMIT, "refs/heads/master", u);
   }
 
@@ -499,7 +651,7 @@
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCanUpdate("refs/heads/master", u);
   }
 
@@ -509,7 +661,7 @@
     r.setForce(true);
     allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCanForceUpdate("refs/heads/master", u);
   }
 
@@ -519,7 +671,7 @@
     r.setForce(true);
     allow(local, PUSH, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCannotForceUpdate("refs/heads/master", u);
   }
 
@@ -528,7 +680,7 @@
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/master");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
@@ -537,7 +689,7 @@
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
     allow(local, PUSH, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertCannotUpdate("refs/heads/master", u);
   }
 
@@ -546,7 +698,7 @@
     block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, fixers, "refs/heads/*");
 
-    ProjectControl f = util.user(local, fixers);
+    ProjectControl f = user(local, fixers);
     assertCannotUpdate("refs/heads/master", f);
   }
 
@@ -556,7 +708,7 @@
     allow(parent, PUSH, DEVS, "refs/heads/*");
     block(local, PUSH, DEVS, "refs/heads/*");
 
-    ProjectControl d = util.user(local, DEVS);
+    ProjectControl d = user(local, DEVS);
     assertCannotUpdate("refs/heads/master", d);
   }
 
@@ -565,7 +717,7 @@
     block(local, READ, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, REGISTERED_USERS);
+    ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
       .named("u can read")
       .isTrue();
@@ -576,7 +728,7 @@
     block(parent, READ, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, REGISTERED_USERS);
+    ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
       .named("u can't read")
       .isFalse();
@@ -587,7 +739,7 @@
     block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
       .named("u can edit topic name")
       .isTrue();
@@ -598,7 +750,7 @@
     block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
 
-    ProjectControl u = util.user(local, REGISTERED_USERS);
+    ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
       .named("u can't edit topic name")
       .isFalse();
@@ -609,7 +761,7 @@
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-2, range);
     assertCanVote(2, range);
@@ -620,7 +772,7 @@
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -631,7 +783,7 @@
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
@@ -643,7 +795,7 @@
         "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range =
         u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
@@ -654,7 +806,7 @@
   public void testUnblockRangeForChangeOwner() {
     allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master")
         .getRange(LABEL + "Code-Review", true);
     assertCanVote(-2, range);
@@ -665,7 +817,7 @@
   public void testUnblockRangeForNotChangeOwner() {
     allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
 
-    ProjectControl u = util.user(local, DEVS);
+    ProjectControl u = user(local, DEVS);
     PermissionRange range = u.controlForRef("refs/heads/master")
         .getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
@@ -674,26 +826,92 @@
 
   @Test
   public void testValidateRefPatternsOK() throws Exception {
-    RefControl.validateRefPattern("refs/*");
-    RefControl.validateRefPattern("^refs/heads/*");
-    RefControl.validateRefPattern("^refs/tags/[0-9a-zA-Z-_.]+");
-    RefControl.validateRefPattern("refs/heads/review/${username}/*");
+    RefPattern.validate("refs/*");
+    RefPattern.validate("^refs/heads/*");
+    RefPattern.validate("^refs/tags/[0-9a-zA-Z-_.]+");
+    RefPattern.validate("refs/heads/review/${username}/*");
   }
 
   @Test(expected = InvalidNameException.class)
   public void testValidateBadRefPatternDoubleCaret() throws Exception {
-    RefControl.validateRefPattern("^^refs/*");
+    RefPattern.validate("^^refs/*");
   }
 
   @Test(expected = InvalidNameException.class)
   public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefControl
-        .validateRefPattern("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+    RefPattern
+        .validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
   }
 
   @Test
   public void testValidateRefPatternNoDanglingCharacter() throws Exception {
-    RefControl
-        .validateRefPattern("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
+    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
+  }
+
+  private InMemoryRepository add(ProjectConfig pc) {
+    PrologEnvironment.Factory envFactory = null;
+    ProjectControl.AssistedFactory projectControlFactory = null;
+    RulesCache rulesCache = null;
+    SitePaths sitePaths = null;
+    List<CommentLinkInfo> commentLinks = null;
+
+    InMemoryRepository repo;
+    try {
+      repo = repoManager.createRepository(pc.getName());
+      if (pc.getProject() == null) {
+        pc.load(repo);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new RuntimeException(e);
+    }
+    all.put(pc.getName(),
+        new ProjectState(sitePaths, projectCache, allProjectsName, allUsersName,
+            projectControlFactory, envFactory, repoManager, rulesCache,
+            commentLinks, capabilityCollectionFactory, pc));
+    return repo;
+  }
+
+  private ProjectControl user(ProjectConfig local,
+      AccountGroup.UUID... memberOf) {
+    return user(local, null, memberOf);
+  }
+
+  private ProjectControl user(ProjectConfig local, String name,
+      AccountGroup.UUID... memberOf) {
+    String canonicalWebUrl = "http://localhost";
+
+    return new ProjectControl(Collections.<AccountGroup.UUID> emptySet(),
+        Collections.<AccountGroup.UUID> emptySet(), projectCache,
+        sectionSorter, null, changeControlFactory, null, null,
+        canonicalWebUrl, new MockUser(name, memberOf), newProjectState(local));
+  }
+
+  private ProjectState newProjectState(ProjectConfig local) {
+    add(local);
+    return all.get(local.getProject().getNameKey());
+  }
+
+  private class MockUser extends CurrentUser {
+    private final String username;
+    private final GroupMembership groups;
+
+    MockUser(String name, AccountGroup.UUID[] groupId) {
+      super(capabilityControlFactory);
+      username = name;
+      ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
+      groupIds.add(REGISTERED_USERS);
+      groupIds.add(ANONYMOUS_USERS);
+      groups = new ListGroupMembership(groupIds);
+    }
+
+    @Override
+    public GroupMembership getEffectiveGroups() {
+      return groups;
+    }
+
+    @Override
+    public String getUserName() {
+      return username;
+    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
index a671523..772c778 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
@@ -14,12 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
@@ -28,60 +22,10 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.rules.PrologEnvironment;
-import com.google.gerrit.rules.RulesCache;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.FakeRealm;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.ChangeKindCacheImpl;
-import com.google.gerrit.server.change.MergeabilityCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.DisableReverseDnsLookup;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.FakeAccountCache;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
 
 public class Util {
   public static final AccountGroup.UUID ADMIN = new AccountGroup.UUID("test.admin");
@@ -96,6 +40,21 @@
         value(-2, "This shall not be merged"));
   }
 
+  public static final LabelType verified() {
+    return category("Verified",
+        value(1, "Verified"),
+        value(0, "No score"),
+        value(-1, "Fails"));
+  }
+
+  public static final LabelType patchSetLock() {
+    LabelType label = category("Patch-Set-Lock",
+        value(1, "Patch Set Locked"),
+        value(0, "Patch Set Unlocked"));
+    label.setFunctionName("PatchSetLock");
+    return label;
+  }
+
   public static LabelValue value(int value, String text) {
     return new LabelValue((short) value, text);
   }
@@ -210,197 +169,6 @@
     return rule;
   }
 
-  private final Map<Project.NameKey, ProjectState> all;
-  private final ProjectCache projectCache;
-  private final CapabilityControl.Factory capabilityControlFactory;
-  private final ChangeControl.AssistedFactory changeControlFactory;
-  private final PermissionCollection.Factory sectionSorter;
-  private final InMemoryRepositoryManager repoManager;
-
-  private final AllProjectsName allProjectsName =
-      new AllProjectsName("All-Projects");
-  private final ProjectConfig allProjects;
-
-  public Util() {
-    all = new HashMap<>();
-    repoManager = new InMemoryRepositoryManager();
-    try {
-      Repository repo = repoManager.createRepository(allProjectsName);
-      allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
-      allProjects.load(repo);
-      LabelType cr = codeReview();
-      allProjects.getLabelSections().put(cr.getName(), cr);
-      add(allProjects);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-
-    projectCache = new ProjectCache() {
-      @Override
-      public ProjectState getAllProjects() {
-        return get(allProjectsName);
-      }
-
-      @Override
-      public ProjectState getAllUsers() {
-        return null;
-      }
-
-      @Override
-      public ProjectState get(Project.NameKey projectName) {
-        return all.get(projectName);
-      }
-
-      @Override
-      public void evict(Project p) {
-      }
-
-      @Override
-      public void remove(Project p) {
-      }
-
-      @Override
-      public Iterable<Project.NameKey> all() {
-        return Collections.emptySet();
-      }
-
-      @Override
-      public Iterable<Project.NameKey> byName(String prefix) {
-        return Collections.emptySet();
-      }
-
-      @Override
-      public void onCreateProject(Project.NameKey newProjectName) {
-      }
-
-      @Override
-      public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-        return Collections.emptySet();
-      }
-
-      @Override
-      public ProjectState checkedGet(Project.NameKey projectName)
-          throws IOException {
-        return all.get(projectName);
-      }
-
-      @Override
-      public void evict(Project.NameKey p) {
-      }
-    };
-
-    Injector injector = Guice.createInjector(new FactoryModule() {
-      @SuppressWarnings({"rawtypes", "unchecked"})
-      @Override
-      protected void configure() {
-        Provider nullProvider = Providers.of(null);
-        bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(
-            new Config());
-        bind(ReviewDb.class).toProvider(nullProvider);
-        bind(GitRepositoryManager.class).toInstance(repoManager);
-        bind(PatchListCache.class).toProvider(nullProvider);
-        bind(Realm.class).to(FakeRealm.class);
-
-        factory(CapabilityControl.Factory.class);
-        factory(ChangeControl.AssistedFactory.class);
-        factory(ChangeData.Factory.class);
-        factory(MergeUtil.Factory.class);
-        bind(ProjectCache.class).toInstance(projectCache);
-        bind(AccountCache.class).toInstance(new FakeAccountCache());
-        bind(GroupBackend.class).to(SystemGroupBackend.class);
-        bind(String.class).annotatedWith(CanonicalWebUrl.class)
-            .toProvider(CanonicalWebUrlProvider.class);
-        bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-            .toInstance(Boolean.FALSE);
-        bind(String.class).annotatedWith(AnonymousCowardName.class)
-            .toProvider(AnonymousCowardNameProvider.class);
-        bind(ChangeKindCache.class).to(ChangeKindCacheImpl.NoCache.class);
-        bind(MergeabilityCache.class)
-          .to(MergeabilityCache.NotImplemented.class);
-      }
-    });
-
-    Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
-        CacheBuilder.newBuilder().build();
-    sectionSorter = new PermissionCollection.Factory(new SectionSortCache(c));
-    capabilityControlFactory =
-        injector.getInstance(CapabilityControl.Factory.class);
-    changeControlFactory =
-      injector.getInstance(ChangeControl.AssistedFactory.class);
-  }
-
-  public InMemoryRepository add(ProjectConfig pc) {
-    PrologEnvironment.Factory envFactory = null;
-    ProjectControl.AssistedFactory projectControlFactory = null;
-    RulesCache rulesCache = null;
-    SitePaths sitePaths = null;
-    List<CommentLinkInfo> commentLinks = null;
-
-    InMemoryRepository repo;
-    try {
-      repo = repoManager.createRepository(pc.getName());
-      if (pc.getProject() == null) {
-        pc.load(repo);
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RuntimeException(e);
-    }
-    all.put(pc.getName(), new ProjectState(sitePaths,
-        projectCache, allProjectsName, projectControlFactory, envFactory,
-        repoManager, rulesCache, commentLinks, pc));
-    return repo;
-  }
-
-  public ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
-    return user(local, null, memberOf);
-  }
-
-  public ProjectControl user(ProjectConfig local, String name,
-      AccountGroup.UUID... memberOf) {
-    String canonicalWebUrl = "http://localhost";
-
-    return new ProjectControl(Collections.<AccountGroup.UUID> emptySet(),
-        Collections.<AccountGroup.UUID> emptySet(), projectCache,
-        sectionSorter, repoManager, changeControlFactory, null, null,
-        canonicalWebUrl, new MockUser(name, memberOf), newProjectState(local));
-  }
-
-  private ProjectState newProjectState(ProjectConfig local) {
-    add(local);
-    return all.get(local.getProject().getNameKey());
-  }
-
-  private class MockUser extends CurrentUser {
-    private final String username;
-    private final GroupMembership groups;
-
-    MockUser(String name, AccountGroup.UUID[] groupId) {
-      super(capabilityControlFactory);
-      username = name;
-      ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
-      groupIds.add(REGISTERED_USERS);
-      groupIds.add(ANONYMOUS_USERS);
-      groups = new ListGroupMembership(groupIds);
-    }
-
-    @Override
-    public GroupMembership getEffectiveGroups() {
-      return groups;
-    }
-
-    @Override
-    public String getUserName() {
-      return username;
-    }
-
-    @Override
-    public Set<Change.Id> getStarredChanges() {
-      return Collections.emptySet();
-    }
-
-    @Override
-    public Collection<AccountProjectWatch> getNotificationFilters() {
-      return Collections.emptySet();
-    }
+  private Util() {
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
index 382610f..47df2db 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
@@ -21,33 +21,13 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
-import java.util.Collections;
 import java.util.List;
 
 public class AndPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testChildren() {
     final TestPredicate a = f("author", "alice");
@@ -64,17 +44,29 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("clear", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("remove(0)", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertChildren("remove(0)", n, of(a, b));
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
   }
 
   private static void assertChildren(String o, Predicate<String> p,
@@ -129,11 +121,5 @@
     assertNotSame(n2, n2.copy(s2));
     assertEquals(s2, n2.copy(s2).getChildren());
     assertEquals(s3, n2.copy(s3).getChildren());
-
-    try {
-      n2.copy(Collections.<Predicate<String>> emptyList());
-    } catch (IllegalArgumentException e) {
-      assertEquals("Need at least two predicates", e.getMessage());
-    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
index e31caaf..8f16670 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
@@ -23,27 +23,7 @@
 
 import java.util.Collections;
 
-public class FieldPredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
+public class FieldPredicateTest extends PredicateTest {
   @Test
   public void testToString() {
     assertEquals("author:bob", f("author", "bob").toString());
@@ -81,10 +61,8 @@
     assertSame(f, f.copy(Collections.<Predicate<String>> emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
-    try {
-      f.copy(Collections.singleton(f("owner", "bob")));
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected 0 children", e.getMessage());
-    }
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("Expected 0 children");
+    f.copy(Collections.singleton(f("owner", "bob")));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
index 45a747c..0256081 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
@@ -21,6 +21,7 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
@@ -28,26 +29,6 @@
 import java.util.List;
 
 public class NotPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testNotNot() {
     final TestPredicate p = f("author", "bob");
@@ -125,12 +106,14 @@
 
     try {
       n.copy(Collections.<Predicate> emptyList());
+      fail("Expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       assertEquals("Expected exactly one child", e.getMessage());
     }
 
     try {
       n.copy(and(a, b).getChildren());
+      fail("Expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       assertEquals("Expected exactly one child", e.getMessage());
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
index ee5e0b0..5640d1b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
@@ -21,33 +21,13 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
-import java.util.Collections;
 import java.util.List;
 
 public class OrPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testChildren() {
     final TestPredicate a = f("author", "alice");
@@ -64,17 +44,29 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("clear", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("remove(0)", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertChildren("remove(0)", n, of(a, b));
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
   }
 
   private static void assertChildren(String o, Predicate<String> p,
@@ -129,11 +121,5 @@
     assertNotSame(n2, n2.copy(s2));
     assertEquals(s2, n2.copy(s2).getChildren());
     assertEquals(s3, n2.copy(s3).getChildren());
-
-    try {
-      n2.copy(Collections.<Predicate<String>> emptyList());
-    } catch (IllegalArgumentException e) {
-      assertEquals("Need at least two predicates", e.getMessage());
-    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
index 865841e..7762e50 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
@@ -14,10 +14,19 @@
 
 package com.google.gerrit.server.query;
 
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
+import com.google.gerrit.testutil.GerritBaseTests;
 
-public class PredicateTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+import org.junit.Ignore;
+
+@Ignore
+public abstract class PredicateTest extends GerritBaseTests {
+  protected static final class TestPredicate extends OperatorPredicate<String> {
+    protected TestPredicate(String name, String value) {
+      super(name, value);
+    }
+  }
+
+  protected static TestPredicate f(String name, String value) {
+    return new TestPredicate(name, value);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
new file mode 100644
index 0000000..83f83bb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -0,0 +1,517 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.GerritServerTests;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+@Ignore
+public abstract class AbstractQueryAccountsTest extends GerritServerTests {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxPages", 10);
+    return cfg;
+  }
+
+  @Rule
+  public final TestName testName = new TestName();
+
+  @Inject
+  protected AccountCache accountCache;
+
+  @Inject
+  protected AccountManager accountManager;
+
+  @Inject
+  protected GerritApi gApi;
+
+  @Inject
+  protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  private Provider<AnonymousUser> anonymousUser;
+
+  @Inject
+  protected InMemoryDatabase schemaFactory;
+
+  @Inject
+  protected InternalChangeQuery internalChangeQuery;
+
+  @Inject
+  protected SchemaCreator schemaCreator;
+
+  @Inject
+  protected ThreadLocalRequestContext requestContext;
+
+  protected LifecycleManager lifecycle;
+  protected ReviewDb db;
+  protected AccountInfo currentUserInfo;
+  protected CurrentUser user;
+
+  protected abstract Injector createInjector();
+
+  @Before
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    Injector injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId = createAccount("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
+  protected RequestContext newRequestContext(Account.Id requestUserId) {
+    final CurrentUser requestUser =
+        userFactory.create(requestUserId);
+    return new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return requestUser;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    };
+  }
+
+  protected void setAnonymous() {
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return anonymousUser.get();
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byId() throws Exception {
+    AccountInfo user = newAccount("user");
+
+    assertQuery("9999999");
+    assertQuery(currentUserInfo._accountId, currentUserInfo);
+    assertQuery(user._accountId, user);
+  }
+
+  @Test
+  public void bySelf() throws Exception {
+    assertQuery("self", currentUserInfo);
+  }
+
+  @Test
+  public void byEmail() throws Exception {
+    AccountInfo user1 = newAccountWithEmail("user1", name("user1@example.com"));
+
+    String domain = name("test.com");
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    String prefix = name("prefix");
+    AccountInfo user4 =
+        newAccountWithEmail("user4", prefix + "user4@example.com");
+
+    AccountInfo user5 =
+        newAccountWithEmail("user5", name("user5MixedCase@example.com"));
+
+    assertQuery("notexisting@test.com");
+
+    assertQuery(currentUserInfo.email, currentUserInfo);
+    assertQuery("email:" + currentUserInfo.email, currentUserInfo);
+
+    assertQuery(user1.email, user1);
+    assertQuery("email:" + user1.email, user1);
+
+    assertQuery(domain, user2, user3);
+
+    assertQuery("email:" + prefix, user4);
+
+    assertQuery(user5.email, user5);
+    assertQuery("email:" + user5.email, user5);
+    assertQuery("email:" + user5.email.toUpperCase(), user5);
+  }
+
+  @Test
+  public void byUsername() throws Exception {
+    AccountInfo user1 = newAccount("myuser");
+
+    assertQuery("notexisting");
+    assertQuery("Not Existing");
+
+    assertQuery(user1.username, user1);
+    assertQuery("username:" + user1.username, user1);
+    assertQuery("username:" + user1.username.toUpperCase(), user1);
+  }
+
+  @Test
+  public void isActive() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccount("user3", "user3@" + domain, false);
+    AccountInfo user4 = newAccount("user4", "user4@" + domain, false);
+
+    // by default only active accounts are returned
+    assertQuery(domain, user1, user2);
+    assertQuery("name:" + domain, user1, user2);
+
+    assertQuery("is:active name:" + domain, user1, user2);
+
+    assertQuery("is:inactive name:" + domain, user3, user4);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
+
+    assertQuery("notexisting");
+    assertQuery("Not Existing");
+
+    assertQuery(quote(user1.name), user1);
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery("John", user1);
+    assertQuery("john", user1);
+    assertQuery("Doe", user1);
+    assertQuery("doe", user1);
+    assertQuery("DOE", user1);
+    assertQuery("Jo Do", user1);
+    assertQuery("jo do", user1);
+    assertQuery("self", currentUserInfo, user3);
+    assertQuery("name:John", user1);
+    assertQuery("name:john", user1);
+    assertQuery("name:Doe", user1);
+    assertQuery("name:doe", user1);
+    assertQuery("name:DOE", user1);
+    assertQuery("name:self", user3);
+
+    assertQuery(quote(user2.name), user2);
+    assertQuery("name:" + quote(user2.name), user2);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
+    assertThat(result.get(result.size() - 1)._moreAccounts).isNull();
+
+    result = assertQuery(newQuery(domain).withLimit(2), user1, user2);
+    assertThat(result.get(result.size() - 1)._moreAccounts).isTrue();
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
+
+    assertQuery(domain, user1, user2, user3);
+    assertQuery(newQuery(domain).withStart(1), user2, user3);
+  }
+
+  @Test
+  public void withDetails() throws Exception {
+    AccountInfo user1 =
+        newAccount("myuser", "My User", "my.user@example.com", true);
+
+    List<AccountInfo> result = assertQuery(user1.username, user1);
+    AccountInfo ai = result.get(0);
+    assertThat(ai._accountId).isEqualTo(user1._accountId);
+    assertThat(ai.name).isNull();
+    assertThat(ai.username).isNull();
+    assertThat(ai.email).isNull();
+    assertThat(ai.avatars).isNull();
+
+    result = assertQuery(
+        newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    ai = result.get(0);
+    assertThat(ai._accountId).isEqualTo(user1._accountId);
+    assertThat(ai.name).isEqualTo(user1.name);
+    assertThat(ai.username).isEqualTo(user1.username);
+    assertThat(ai.email).isEqualTo(user1.email);
+    assertThat(ai.avatars).isNull();
+  }
+
+  @Test
+  public void withSecondaryEmails() throws Exception {
+    AccountInfo user1 =
+        newAccount("myuser", "My User", "my.user@example.com", true);
+    String[] secondaryEmails =
+        new String[] {"bar@example.com", "foo@example.com"};
+    addEmails(user1, secondaryEmails);
+
+
+    List<AccountInfo> result = assertQuery(user1.username, user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result = assertQuery(
+        newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result = assertQuery(
+        newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS),
+        user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails)).inOrder();
+
+    result = assertQuery(newQuery(user1.username).withOptions(
+        ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS), user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails)).inOrder();
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    AccountInfo user1 = newAccount("user1");
+
+    setAnonymous();
+    assertQuery("9999999");
+    assertQuery("self");
+    assertQuery("username:" + user1.username, user1);
+  }
+
+  // reindex permissions are tested by {@link AccountIT#reindexPermissions}
+  @Test
+  public void reindex() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
+
+    // update account in the database so that account index is stale
+    String newName = "Test User";
+    Account account = db.accounts().get(new Account.Id(user1._accountId));
+    account.setFullName(newName);
+    db.accounts().update(Collections.singleton(account));
+
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery("name:" + quote(newName));
+
+    gApi.accounts().id(user1.username).index();
+    assertQuery("name:" + quote(user1.name));
+    assertQuery("name:" + quote(newName), user1);
+  }
+
+  protected AccountInfo newAccount(String username) throws Exception {
+    return newAccountWithEmail(username, null);
+  }
+
+  protected AccountInfo newAccountWithEmail(String username, String email)
+      throws Exception {
+    return newAccount(username, email, true);
+  }
+
+  protected AccountInfo newAccountWithFullName(String username, String fullName)
+      throws Exception {
+    return newAccount(username, fullName, null, true);
+  }
+
+  protected AccountInfo newAccount(String username, String email,
+      boolean active) throws Exception {
+    return newAccount(username, null, email, active);
+  }
+
+  protected AccountInfo newAccount(String username, String fullName,
+      String email, boolean active) throws Exception {
+    String uniqueName = name(username);
+
+    try {
+      gApi.accounts().id(uniqueName).get();
+      fail("user " + uniqueName + " already exists");
+    } catch (ResourceNotFoundException e) {
+      // expected: user does not exist yet
+    }
+
+    Account.Id id = createAccount(uniqueName, fullName, email, active);
+    return gApi.accounts().id(id.get()).get();
+  }
+
+  protected String quote(String s) {
+    return "\"" + s + "\"";
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+    String suffix = testName.getMethodName().toLowerCase();
+    if (name.contains("@")) {
+      return name + "." + suffix;
+    }
+    return name + "_" + suffix;
+  }
+
+  private Account.Id createAccount(String username, String fullName,
+      String email, boolean active) throws Exception {
+    Account.Id id =
+        accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+    if (email != null) {
+      accountManager.link(id, AuthRequest.forEmail(email));
+    }
+    Account a = db.accounts().get(id);
+    a.setFullName(fullName);
+    a.setPreferredEmail(email);
+    a.setActive(active);
+    db.accounts().update(ImmutableList.of(a));
+    accountCache.evict(id);
+    return id;
+  }
+
+  private void addEmails(AccountInfo account, String... emails)
+      throws Exception {
+    Account.Id id = new Account.Id(account._accountId);
+    for (String email : emails) {
+      accountManager.link(id, AuthRequest.forEmail(email));
+    }
+    accountCache.evict(id);
+  }
+
+  protected QueryRequest newQuery(Object query) throws RestApiException {
+    return gApi.accounts().query(query.toString());
+  }
+
+  protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts)
+      throws Exception {
+    return assertQuery(newQuery(query), accounts);
+  }
+
+  protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
+      throws Exception {
+    List<AccountInfo> result = query.get();
+    Iterable<Integer> ids = ids(result);
+    assertThat(ids).named(format(query, result, accounts))
+        .containsExactlyElementsIn(ids(accounts)).inOrder();
+    return result;
+  }
+
+  private String format(QueryRequest query, Iterable<AccountInfo> actualIds,
+      AccountInfo... expectedAccounts) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery())
+        .append("' with expected accounts ");
+    b.append(format(Arrays.asList(expectedAccounts)));
+    b.append(" and result ");
+    b.append(format(actualIds));
+    return b.toString();
+  }
+
+  private String format(Iterable<AccountInfo> accounts) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<AccountInfo> it = accounts.iterator();
+    while (it.hasNext()) {
+      AccountInfo a = it.next();
+      b.append("{").append(a._accountId).append(", ").append("name=")
+          .append(a.name).append(", ").append("email=").append(a.email)
+          .append(", ").append("username=").append(a.username).append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static Iterable<Integer> ids(AccountInfo... accounts) {
+    return FluentIterable.from(Arrays.asList(accounts)).transform(
+        new Function<AccountInfo, Integer>() {
+          @Override
+          public Integer apply(AccountInfo in) {
+            return in._accountId;
+          }
+        });
+  }
+
+  protected static Iterable<Integer> ids(Iterable<AccountInfo> accounts) {
+    return FluentIterable.from(accounts).transform(
+        new Function<AccountInfo, Integer>() {
+          @Override
+          public Integer apply(AccountInfo in) {
+            return in._accountId;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
new file mode 100644
index 0000000..857b661
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.account;
+
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryAccountsTest extends AbstractQueryAccountsTest {
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(
+        new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index db6301d..49e01ad 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -17,10 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
@@ -28,17 +29,22 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
+import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -49,25 +55,26 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.DisabledReviewDb;
+import com.google.gerrit.testutil.GerritServerTests;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
@@ -81,61 +88,48 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.junit.runner.RunWith;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 @Ignore
-@RunWith(ConfigSuite.class)
-public abstract class AbstractQueryChangesTest {
+public abstract class AbstractQueryChangesTest extends GerritServerTests {
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    return updateConfig(new Config());
-  }
-
-  @ConfigSuite.Config
-  public static Config noteDbEnabled() {
-    return updateConfig(NotesMigration.allEnabledConfig());
-  }
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
-  private static Config updateConfig(Config cfg) {
+    Config cfg = new Config();
     cfg.setInt("index", null, "maxPages", 10);
     return cfg;
   }
 
-  @ConfigSuite.Parameter public Config config;
   @Inject protected AccountManager accountManager;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected PatchSetInserter.Factory patchSetFactory;
-  @Inject protected ChangeControl.GenericFactory changeControlFactory;
+  @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
-  @Inject protected IndexCollection indexes;
+  @Inject protected ChangeIndexCollection indexes;
+  @Inject protected ChangeIndexer indexer;
   @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
-  @Inject protected NotesMigration notesMigration;
-  @Inject protected ProjectControl.GenericFactory projectControlFactory;
-  @Inject protected ChangeQueryBuilder queryBuilder;
-  @Inject protected QueryProcessor queryProcessor;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected PatchSetInserter.Factory patchSetFactory;
+  @Inject protected ChangeControl.GenericFactory changeControlFactory;
+  @Inject protected ChangeQueryProcessor queryProcessor;
   @Inject protected SchemaCreator schemaCreator;
+  @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
-  @Inject protected GroupCache groupCache;
-  @Inject protected AddMembers addMembers;
 
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
@@ -161,13 +155,13 @@
     Account userAccount = db.accounts().get(userId);
     userAccount.setPreferredEmail("user@example.com");
     db.accounts().update(ImmutableList.of(userAccount));
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userAccount.getId()));
   }
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser =
-        userFactory.create(Providers.of(db), requestUserId);
+        userFactory.create(requestUserId);
     return new RequestContext() {
       @Override
       public CurrentUser getUser() {
@@ -195,8 +189,15 @@
 
   @Before
   public void setTimeForTesting() {
+    resetTimeWithClockStep(1, MILLISECONDS);
+  }
+
+  private void resetTimeWithClockStep(long clockStep, TimeUnit clockStepUnit) {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    TestTimeUtil.resetWithClockStep(1, MILLISECONDS);
+    // TODO(dborowitz): Figure out why tests fail when stubbing out
+    // SystemReader.
+    TestTimeUtil.resetWithClockStep(clockStep, clockStepUnit);
+    SystemReader.setInstance(null);
   }
 
   @After
@@ -208,8 +209,8 @@
   @Test
   public void byId() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
 
     assertQuery("12345");
     assertQuery(change1.getId().get(), change1);
@@ -219,7 +220,7 @@
   @Test
   public void byKey() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(newChange(repo, null, null, null, null));
+    Change change = insert(repo, newChange(repo));
     String key = change.getKey().get();
 
     assertQuery("I0000000000000000000000000000000000000000");
@@ -231,38 +232,34 @@
 
   @Test
   public void byTriplet() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(newChange(repo, null, null, null, "branch"));
+    TestRepository<Repo> repo = createProject("iabcde");
+    Change change = insert(repo, newChangeForBranch(repo, "branch"));
     String k = change.getKey().get();
 
-    assertQuery("repo~branch~" + k, change);
-    assertQuery("change:repo~branch~" + k, change);
-    assertQuery("repo~refs/heads/branch~" + k, change);
-    assertQuery("change:repo~refs/heads/branch~" + k, change);
-    assertQuery("repo~branch~" + k.substring(0, 10), change);
-    assertQuery("change:repo~branch~" + k.substring(0, 10), change);
+    assertQuery("iabcde~branch~" + k, change);
+    assertQuery("change:iabcde~branch~" + k, change);
+    assertQuery("iabcde~refs/heads/branch~" + k, change);
+    assertQuery("change:iabcde~refs/heads/branch~" + k, change);
+    assertQuery("iabcde~branch~" + k.substring(0, 10), change);
+    assertQuery("change:iabcde~branch~" + k.substring(0, 10), change);
 
     assertQuery("foo~bar");
     assertBadQuery("change:foo~bar");
     assertQuery("otherrepo~branch~" + k);
     assertQuery("change:otherrepo~branch~" + k);
-    assertQuery("repo~otherbranch~" + k);
-    assertQuery("change:repo~otherbranch~" + k);
-    assertQuery("repo~branch~I0000000000000000000000000000000000000000");
-    assertQuery("change:repo~branch~I0000000000000000000000000000000000000000");
+    assertQuery("iabcde~otherbranch~" + k);
+    assertQuery("change:iabcde~otherbranch~" + k);
+    assertQuery("iabcde~branch~I0000000000000000000000000000000000000000");
+    assertQuery("change:iabcde~branch~I0000000000000000000000000000000000000000");
   }
 
   @Test
   public void byStatus() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setStatus(Change.Status.NEW);
-    insert(ins1);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.MERGED);
-    insert(ins2);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(repo, ins2);
 
     assertQuery("status:new", change1);
     assertQuery("status:NEW", change1);
@@ -274,18 +271,11 @@
   @Test
   public void byStatusOpen() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setStatus(Change.Status.NEW);
-    insert(ins1);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.DRAFT);
-    insert(ins2);
-    ChangeInserter ins3 = newChange(repo, null, null, null, null);
-    Change change3 = ins3.getChange();
-    change3.setStatus(Change.Status.MERGED);
-    insert(ins3);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.DRAFT);
+    Change change2 = insert(repo, ins2);
+    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:open", expected);
@@ -304,14 +294,9 @@
   @Test
   public void byStatusDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setStatus(Change.Status.NEW);
-    insert(ins1);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.DRAFT);
-    insert(ins2);
+    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.DRAFT);
+    Change change2 = insert(repo, ins2);
 
     Change[] expected = new Change[] {change2};
     assertQuery("status:draft", expected);
@@ -326,18 +311,11 @@
   @Test
   public void byStatusClosed() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setStatus(Change.Status.MERGED);
-    insert(ins1);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.ABANDONED);
-    insert(ins2);
-    ChangeInserter ins3 = newChange(repo, null, null, null, null);
-    Change change3 = ins3.getChange();
-    change3.setStatus(Change.Status.NEW);
-    insert(ins3);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
+    Change change2 = insert(repo, ins2);
+    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:closed", expected);
@@ -354,14 +332,9 @@
   @Test
   public void byStatusPrefix() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setStatus(Change.Status.NEW);
-    insert(ins1);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.MERGED);
-    insert(ins2);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
 
     assertQuery("status:n", change1);
     assertQuery("status:ne", change1);
@@ -376,9 +349,9 @@
   @Test
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null);
-    insert(ins);
-    String sha = ins.getPatchSet().getRevision().get();
+    ChangeInserter ins = newChange(repo);
+    insert(repo, ins);
+    String sha = ins.getCommit().name();
 
     assertQuery("0000000000000000000000000000000000000000");
     for (int i = 0; i <= 36; i++) {
@@ -390,10 +363,10 @@
   @Test
   public void byOwner() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
-    Change change2 = insert(newChange(repo, null, null, user2, null));
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 = accountManager.authenticate(
+        AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
 
     assertQuery("owner:" + userId.get(), change1);
     assertQuery("owner:" + user2, change2);
@@ -402,7 +375,7 @@
   @Test
   public void byAuthor() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
+    Change change1 = insert(repo, newChange(repo), userId);
 
     // By exact email address
     assertQuery("author:jauthor@example.com", change1);
@@ -429,7 +402,7 @@
   @Test
   public void byCommitter() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
+    Change change1 = insert(repo, newChange(repo), userId);
 
     // By exact email address
     assertQuery("committer:jcommitter@example.com", change1);
@@ -456,10 +429,10 @@
   @Test
   public void byOwnerIn() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
-    Change change2 = insert(newChange(repo, null, null, user2, null));
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 = accountManager.authenticate(
+        AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
 
     assertQuery("ownerin:Administrators", change1);
     assertQuery("ownerin:\"Registered Users\"", change2, change1);
@@ -469,8 +442,8 @@
   public void byProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(newChange(repo1, null, null, null, null));
-    Change change2 = insert(newChange(repo2, null, null, null, null));
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
 
     assertQuery("project:foo");
     assertQuery("project:repo");
@@ -482,8 +455,8 @@
   public void byProjectPrefix() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(newChange(repo1, null, null, null, null));
-    Change change2 = insert(newChange(repo2, null, null, null, null));
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
 
     assertQuery("projects:foo");
     assertQuery("projects:repo1", change1);
@@ -494,8 +467,8 @@
   @Test
   public void byBranchAndRef() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, "master"));
-    Change change2 = insert(newChange(repo, null, null, null, "branch"));
+    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
+    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
 
     assertQuery("branch:foo");
     assertQuery("branch:master", change1);
@@ -512,27 +485,19 @@
   @Test
   public void byTopic() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = ins1.getChange();
-    change1.setTopic("feature1");
-    insert(ins1);
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
 
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    Change change2 = ins2.getChange();
-    change2.setTopic("feature2");
-    insert(ins2);
+    ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
+    Change change2 = insert(repo, ins2);
 
-    ChangeInserter ins3 = newChange(repo, null, null, null, null);
-    Change change3 = ins3.getChange();
-    change3.setTopic("Cherrypick-feature2");
-    insert(ins3);
+    ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
+    Change change3 = insert(repo, ins3);
 
-    ChangeInserter ins4 = newChange(repo, null, null, null, null);
-    Change change4 = ins4.getChange();
-    change4.setTopic("feature2-fixup");
-    insert(ins4);
+    ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
+    Change change4 = insert(repo, ins4);
 
-    Change change5 = insert(newChange(repo, null, null, null, null));
+    Change change5 = insert(repo, newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -550,9 +515,9 @@
   public void byMessageExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert(newChange(repo, commit1, null, null, null));
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
@@ -564,10 +529,10 @@
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert(newChange(repo, commit1, null, null, null));
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
     assertQuery("message:12345", change1);
@@ -584,30 +549,37 @@
     ChangeInserter ins4 = newChange(repo, null, null, null, null);
     ChangeInserter ins5 = newChange(repo, null, null, null, null);
 
-    Change reviewMinus2Change = insert(ins);
+    Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current()
         .review(ReviewInput.reject());
 
-    Change reviewMinus1Change = insert(ins2);
+    Change reviewMinus1Change = insert(repo, ins2);
     gApi.changes().id(reviewMinus1Change.getId().get()).current()
         .review(ReviewInput.dislike());
 
-    Change noLabelChange = insert(ins3);
+    Change noLabelChange = insert(repo, ins3);
 
-    Change reviewPlus1Change = insert(ins4);
+    Change reviewPlus1Change = insert(repo, ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current()
         .review(ReviewInput.recommend());
 
-    Change reviewPlus2Change = insert(ins5);
+    Change reviewPlus2Change = insert(repo, ins5);
     gApi.changes().id(reviewPlus2Change.getId().get()).current()
         .review(ReviewInput.approve());
 
+    Map<String, Short> m = gApi.changes()
+        .id(reviewPlus1Change.getId().get())
+        .reviewer(user.getAccountId().toString())
+        .votes();
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
+
     Map<Integer, Change> changes = new LinkedHashMap<>(5);
     changes.put(2, reviewPlus2Change);
     changes.put(1, reviewPlus1Change);
+    changes.put(0, noLabelChange);
     changes.put(-1, reviewMinus1Change);
     changes.put(-2, reviewMinus2Change);
-    changes.put(0, noLabelChange);
 
     assertQuery("label:Code-Review=-2", reviewMinus2Change);
     assertQuery("label:Code-Review-2", reviewMinus2Change);
@@ -690,8 +662,7 @@
     gApi.groups().id(g2).addMembers("user2");
 
     // create a change
-    ChangeInserter ins = newChange(repo, null, null, user1.get(), null);
-    Change change1 = insert(ins);
+    Change change1 = insert(repo, newChange(repo), user1);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
@@ -713,7 +684,7 @@
     Change last = null;
     int n = 5;
     for (int i = 0; i < n; i++) {
-      last = insert(newChange(repo, null, null, null, null));
+      last = insert(repo, newChange(repo));
     }
 
     for (int i = 1; i <= n + 2; i++) {
@@ -738,9 +709,9 @@
   @Test
   public void start() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    List<Change> changes = Lists.newArrayList();
+    List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 2; i++) {
-      changes.add(insert(newChange(repo, null, null, null, null)));
+      changes.add(insert(repo, newChange(repo)));
     }
 
     assertQuery("status:new", changes.get(1), changes.get(0));
@@ -752,9 +723,9 @@
   @Test
   public void startWithLimit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    List<Change> changes = Lists.newArrayList();
+    List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 3; i++) {
-      changes.add(insert(newChange(repo, null, null, null, null)));
+      changes.add(insert(repo, newChange(repo)));
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -768,7 +739,7 @@
   @Test
   public void maxPages() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(newChange(repo, null, null, null, null));
+    Change change = insert(repo, newChange(repo));
 
     QueryRequest query = newQuery("status:new").withLimit(10);
     assertQuery(query, change);
@@ -780,13 +751,13 @@
 
   @Test
   public void updateOrder() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, MINUTES);
+    resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
-    List<ChangeInserter> inserters = Lists.newArrayList();
-    List<Change> changes = Lists.newArrayList();
+    List<ChangeInserter> inserters = new ArrayList<>();
+    List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 5; i++) {
-      inserters.add(newChange(repo, null, null, null, null));
-      changes.add(insert(inserters.get(i)));
+      inserters.add(newChange(repo));
+      changes.add(insert(repo, inserters.get(i)));
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -805,18 +776,18 @@
 
   @Test
   public void updatedOrderWithMinuteResolution() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, MINUTES);
+    resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = insert(ins1);
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    ChangeInserter ins1 = newChange(repo);
+    Change change1 = insert(repo, ins1);
+    Change change2 = insert(repo, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
 
-    gApi.changes().id(change1.getId().get()).current()
-        .review(new ReviewInput());
-    change1 = db.changes().get(change1.getId());
+    gApi.changes().id(change1.getId().get()).topic("new-topic");
+    change1 = notesFactory.create(db, change1.getProject(), change1.getId())
+        .getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -828,18 +799,20 @@
 
   @Test
   public void updatedOrderWithSubMinuteResolution() throws Exception {
+    resetTimeWithClockStep(1, SECONDS);
+
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo, null, null, null, null);
-    Change change1 = insert(ins1);
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    ChangeInserter ins1 = newChange(repo);
+    Change change1 = insert(repo, ins1);
+    Change change2 = insert(repo, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
 
     assertQuery("status:new", change2, change1);
 
-    gApi.changes().id(change1.getId().get()).current()
-        .review(new ReviewInput());
-    change1 = db.changes().get(change1.getId());
+    gApi.changes().id(change1.getId().get()).topic("new-topic");
+    change1 = notesFactory.create(db, change1.getProject(), change1.getId())
+        .getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -852,11 +825,11 @@
   @Test
   public void filterOutMoreThanOnePageOfResults() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(newChange(repo, null, null, userId.get(), null));
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
+    Change change = insert(repo, newChange(repo), userId);
+    Account.Id user2 = accountManager.authenticate(
+        AuthRequest.forUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(newChange(repo, null, null, user2, null));
+      insert(repo, newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators", change);
@@ -866,10 +839,10 @@
   @Test
   public void filterOutAllResults() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
+    Account.Id user2 = accountManager.authenticate(
+        AuthRequest.forUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(newChange(repo, null, null, user2, null));
+      insert(repo, newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators");
@@ -883,7 +856,7 @@
         repo.commit().message("one")
         .add("dir/file1", "contents1").add("dir/file2", "contents2")
         .create());
-    Change change = insert(newChange(repo, commit, null, null, null));
+    Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -900,7 +873,7 @@
         repo.commit().message("one")
         .add("dir/file1", "contents1").add("dir/file2", "contents2")
         .create());
-    Change change = insert(newChange(repo, commit, null, null, null));
+    Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -914,7 +887,7 @@
         repo.commit().message("one")
         .add("dir/file1", "contents1").add("dir/file2", "contents2")
         .create());
-    Change change = insert(newChange(repo, commit, null, null, null));
+    Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -931,7 +904,7 @@
         repo.commit().message("one")
         .add("dir/file1", "contents1").add("dir/file2", "contents2")
         .create());
-    Change change = insert(newChange(repo, commit, null, null, null));
+    Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
@@ -940,18 +913,30 @@
   @Test
   public void byComment() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null);
-    Change change = insert(ins);
+    ChangeInserter ins = newChange(repo);
+    Change change = insert(repo, ins);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
-    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
-    comment.line = 1;
-    comment.message = "inline";
+    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "inline";
     input.comments = ImmutableMap.<String, List<ReviewInput.CommentInput>> of(
-        Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput> of(comment));
+        Patch.COMMIT_MSG,
+        ImmutableList.<ReviewInput.CommentInput> of(commentInput));
     gApi.changes().id(change.getId().get()).current().review(input);
 
+    Map<String, List<CommentInfo>> comments =
+        gApi.changes().id(change.getId().get()).current().comments();
+    assertThat(comments).hasSize(1);
+    CommentInfo comment =
+        Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
+    assertThat(comment.message).isEqualTo(commentInput.message);
+    ChangeMessageInfo lastMsg = Iterables.getLast(
+        gApi.changes().id(change.getId().get()).get().messages, null);
+    assertThat(lastMsg.message)
+        .isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
+
     assertQuery("comment:foo");
     assertQuery("comment:toplevel", change);
     assertQuery("comment:inline", change);
@@ -960,10 +945,10 @@
   @Test
   public void byAge() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
-    TestTimeUtil.resetWithClockStep(thirtyHoursInMs, MILLISECONDS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
     // Queried by AgePredicate constructor.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
     long now = TimeUtil.nowMs();
@@ -983,10 +968,10 @@
 
   @Test
   public void byBefore() throws Exception {
-    TestTimeUtil.resetWithClockStep(30, HOURS);
+    resetTimeWithClockStep(30, HOURS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     assertQuery("before:2009-09-29");
@@ -1003,10 +988,10 @@
 
   @Test
   public void byAfter() throws Exception {
-    TestTimeUtil.resetWithClockStep(30, HOURS);
+    resetTimeWithClockStep(30, HOURS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     assertQuery("after:2009-10-03");
@@ -1027,8 +1012,8 @@
     RevCommit commit2 = repo.parseBody(
         repo.commit().parent(commit1).add("file1", "foo").create());
 
-    Change change1 = insert(newChange(repo, commit1, null, null, null));
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("added:>4");
     assertQuery("-added:<=4");
@@ -1077,8 +1062,8 @@
 
   private List<Change> setUpHashtagChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
 
     HashtagsInput in = new HashtagsInput();
     in.add = ImmutableSet.of("foo");
@@ -1091,8 +1076,8 @@
   }
 
   @Test
-  public void byHashtagWithNotedb() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+  public void byHashtagWithNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
     List<Change> changes = setUpHashtagChanges();
     assertQuery("hashtag:foo", changes.get(1), changes.get(0));
     assertQuery("hashtag:bar", changes.get(1));
@@ -1104,9 +1089,25 @@
   }
 
   @Test
-  public void byHashtagWithoutNotedb() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
-    setUpHashtagChanges();
+  public void byHashtagWithoutNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+
+    notesMigration.setWriteChanges(true);
+    notesMigration.setReadChanges(true);
+    db.close();
+    db = schemaFactory.open();
+    List<Change> changes;
+    try {
+      changes = setUpHashtagChanges();
+      notesMigration.setWriteChanges(false);
+      notesMigration.setReadChanges(false);
+    } finally {
+      db.close();
+    }
+    db = schemaFactory.open();
+    for (Change c : changes) {
+      indexer.index(db, c); // Reindex without hashtag field.
+    }
     assertQuery("hashtag:foo");
     assertQuery("hashtag:bar");
     assertQuery("hashtag:\" bar \"");
@@ -1120,31 +1121,29 @@
   public void byDefault() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
 
-    Change change1 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
 
     RevCommit commit2 = repo.parseBody(
         repo.commit().message("foosubject").create());
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     RevCommit commit3 = repo.parseBody(
         repo.commit()
         .add("Foo.java", "foo contents")
         .create());
-    Change change3 = insert(newChange(repo, commit3, null, null, null));
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
 
-    ChangeInserter ins4 = newChange(repo, null, null, null, null);
-    Change change4 = insert(ins4);
+    ChangeInserter ins4 = newChange(repo);
+    Change change4 = insert(repo, ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.<String, Short> of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
-    ChangeInserter ins5 = newChange(repo, null, null, null, null);
-    Change change5 = ins5.getChange();
-    change5.setTopic("feature5");
-    insert(ins5);
+    ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
+    Change change5 = insert(repo, ins5);
 
-    Change change6 = insert(newChange(repo, null, null, null, "branch6"));
+    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
 
     assertQuery(change1.getId().get(), change1);
     assertQuery(ChangeTriplet.format(change1), change1);
@@ -1165,11 +1164,9 @@
   @Test
   public void implicitVisibleTo() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
-    ChangeInserter ins2 = newChange(repo, null, null, userId.get(), null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.DRAFT);
-    insert(ins2);
+    Change change1 = insert(repo, newChange(repo), userId);
+    Change change2 =
+        insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
 
     String q = "project:repo";
     assertQuery(q, change2, change1);
@@ -1183,11 +1180,9 @@
   @Test
   public void explicitVisibleTo() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, userId.get(), null));
-    ChangeInserter ins2 = newChange(repo, null, null, userId.get(), null);
-    Change change2 = ins2.getChange();
-    change2.setStatus(Change.Status.DRAFT);
-    insert(ins2);
+    Change change1 = insert(repo, newChange(repo), userId);
+    Change change2 =
+        insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
 
     String q = "project:repo";
     assertQuery(q, change2, change1);
@@ -1202,8 +1197,8 @@
   @Test
   public void byCommentBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
 
     int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
         .getAccountId().get();
@@ -1226,14 +1221,84 @@
   }
 
   @Test
-  public void byFrom() throws Exception {
+  public void byDraftBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(change1.getId().get()).current().createDraft(in);
+
+    in = new DraftInput();
+    in.line = 2;
+    in.message = "nit: point in the end of the statement";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(change2.getId().get()).current().createDraft(in);
 
     int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
         .getAccountId().get();
-    ChangeInserter ins2 = newChange(repo, null, null, user2, null);
-    Change change2 = insert(ins2);
+
+    assertQuery("draftby:" + userId.get(), change2, change1);
+    assertQuery("draftby:" + user2);
+  }
+
+  @Test
+  public void byStarredBy() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.accounts().self().starChange(change2.getId().toString());
+
+    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
+        .getAccountId().get();
+
+    assertQuery("starredby:self", change2, change1);
+    assertQuery("starredby:" + user2);
+  }
+
+  @Test
+  public void byStar() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+
+    gApi.accounts()
+        .self()
+        .setStars(change1.getId().toString(),
+            new StarsInput(new HashSet<>(Arrays.asList("red", "blue"))));
+    gApi.accounts()
+        .self()
+        .setStars(change2.getId().toString(),
+            new StarsInput(new HashSet<>(Arrays.asList(
+                StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
+
+    // check labeled stars
+    assertQuery("star:red", change1);
+    assertQuery("star:blue", change2, change1);
+    assertQuery("has:stars", change2, change1);
+
+    // check default star
+    assertQuery("has:star", change2);
+    assertQuery("is:starred", change2);
+    assertQuery("starredby:self", change2);
+    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+  }
+
+  @Test
+  public void byFrom() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+
+    Account.Id user2 = accountManager.authenticate(
+        AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -1269,10 +1334,10 @@
         repo.commit()
             .add("file4", "contents4")
             .create());
-    Change change1 = insert(newChange(repo, commit1, null, null, null));
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
-    Change change3 = insert(newChange(repo, commit3, null, null, null));
-    Change change4 = insert(newChange(repo, commit4, null, null, null));
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
 
     assertQuery("conflicts:" + change1.getId().get(), change3);
     assertQuery("conflicts:" + change2.getId().get());
@@ -1282,11 +1347,11 @@
 
   @Test
   public void reviewedBy() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, MINUTES);
+    resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
-    Change change3 = insert(newChange(repo, null, null, null, null));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
 
     gApi.changes()
       .id(change1.getId().get())
@@ -1334,6 +1399,26 @@
   }
 
   @Test
+  public void reviewer() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    gApi.changes()
+      .id(change1.getId().get())
+      .current()
+      .review(ReviewInput.approve());
+    gApi.changes()
+      .id(change2.getId().get())
+      .current()
+      .review(ReviewInput.approve());
+
+    Account.Id id = user.getAccountId();
+    assertQuery("reviewer:" + id, change2, change1);
+  }
+
+  @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     int n = 10;
@@ -1341,12 +1426,12 @@
     List<Integer> expectedIds = new ArrayList<>(n);
     Branch.NameKey dest = null;
     for (int i = 0; i < n; i++) {
-      ChangeInserter ins = newChange(repo, null, null, null, null);
-      insert(ins);
+      ChangeInserter ins = newChange(repo);
+      insert(repo, ins);
       if (dest == null) {
         dest = ins.getChange().getDest();
       }
-      shas.add(ins.getPatchSet().getRevision().get());
+      shas.add(ins.getCommit().name());
       expectedIds.add(ins.getChange().getId().get());
     }
 
@@ -1369,16 +1454,16 @@
 
   @Test
   public void prepopulatedFields() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
+    assume().that(notesMigration.readChanges()).isFalse();
     TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(newChange(repo, null, null, null, null));
+    Change change = insert(repo, newChange(repo));
 
     db = new DisabledReviewDb();
     requestContext.setContext(newRequestContext(userId));
     // Use QueryProcessor directly instead of API so we get ChangeDatas back.
     List<ChangeData> cds = queryProcessor
-        .queryChanges(queryBuilder.parse(change.getId().toString()))
-        .changes();
+        .query(queryBuilder.parse(change.getId().toString()))
+        .entities();
     assertThat(cds).hasSize(1);
 
     ChangeData cd = cds.get(0);
@@ -1387,56 +1472,99 @@
     cd.currentApprovals();
     cd.changedLines();
     cd.reviewedBy();
+    cd.reviewers();
 
     // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
-    // necessary for notedb anyway.
+    // necessary for NoteDb anyway.
     cd.isMergeable();
 
     exception.expect(DisabledReviewDb.Disabled.class);
     cd.messages();
   }
 
+  @Test
+  public void prepopulateOnlyRequestedFields() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
 
-  protected ChangeInserter newChange(
-      TestRepository<Repo> repo,
-      @Nullable RevCommit commit, @Nullable String key, @Nullable Integer owner,
-      @Nullable String branch) throws Exception {
+    db = new DisabledReviewDb();
+    requestContext.setContext(newRequestContext(userId));
+    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
+    List<ChangeData> cds = queryProcessor
+        .setRequestedFields(ImmutableSet.of(
+            ChangeField.PATCH_SET.getName(),
+            ChangeField.CHANGE.getName()))
+        .query(queryBuilder.parse(change.getId().toString()))
+        .entities();
+    assertThat(cds).hasSize(1);
+
+    ChangeData cd = cds.get(0);
+    cd.change();
+    cd.patchSets();
+
+    exception.expect(DisabledReviewDb.Disabled.class);
+    cd.currentApprovals();
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repo> repo)
+      throws Exception {
+    return newChange(repo, null, null, null, null);
+  }
+
+  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo,
+      RevCommit commit) throws Exception {
+    return newChange(repo, commit, null, null, null);
+  }
+
+  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo,
+      String branch) throws Exception {
+    return newChange(repo, null, branch, null, null);
+  }
+
+  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo,
+      Change.Status status) throws Exception {
+    return newChange(repo, null, null, status, null);
+  }
+
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo,
+      String topic) throws Exception {
+    return newChange(repo, null, null, null, topic);
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repo> repo,
+      @Nullable RevCommit commit, @Nullable String branch,
+      @Nullable Change.Status status, @Nullable String topic) throws Exception {
     if (commit == null) {
       commit = repo.parseBody(repo.commit().message("message").create());
     }
-    Account.Id ownerId = owner != null ? new Account.Id(owner) : userId;
+
     branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
     if (!branch.startsWith("refs/heads/")) {
       branch = "refs/heads/" + branch;
     }
-    Project.NameKey project = new Project.NameKey(
-        repo.getRepository().getDescription().getRepositoryName());
 
-    Change.Id id = new Change.Id(db.nextChangeId());
-    if (key == null) {
-      key = "I" + Hashing.sha1().newHasher()
-          .putInt(id.get())
-          .putString(project.get(), UTF_8)
-          .putString(commit.name(), UTF_8)
-          .putInt(ownerId.get())
-          .putString(branch, UTF_8)
-          .hash()
-          .toString();
-    }
-
-    Change change = new Change(new Change.Key(key), id, ownerId,
-        new Branch.NameKey(project, branch), TimeUtil.nowTs());
-    IdentifiedUser user = userFactory.create(Providers.of(db), ownerId);
-    RefControl refControl = projectControlFactory.controlFor(project, user)
-        .controlForRef(change.getDest());
-    return changeFactory.create(refControl, change, commit)
-        .setValidatePolicy(CommitValidators.Policy.NONE);
+    Change.Id id = new Change.Id(seq.nextChangeId());
+    ChangeInserter ins = changeFactory.create(
+        id, commit, branch)
+        .setValidatePolicy(CommitValidators.Policy.NONE)
+        .setStatus(status)
+        .setTopic(topic);
+    return ins;
   }
 
-  protected Change insert(ChangeInserter ins) throws Exception {
-    try (BatchUpdate bu = updateFactory.create(
-        db, ins.getChange().getProject(), ins.getUser(),
-        ins.getChange().getCreatedOn())) {
+  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
+    return insert(repo, ins, null);
+  }
+
+  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins,
+      @Nullable Account.Id owner) throws Exception {
+    Project.NameKey project = new Project.NameKey(
+        repo.getRepository().getDescription().getRepositoryName());
+    Account.Id ownerId = owner != null ? owner : userId;
+    IdentifiedUser user = userFactory.create(ownerId);
+    try (BatchUpdate bu =
+        updateFactory.create(db, project, user, TimeUtil.nowTs())) {
       bu.insertChange(ins);
       bu.execute();
       return ins.getChange();
@@ -1453,13 +1581,12 @@
             .message("message")
             .add("file" + n, "contents " + n)
             .create());
-    RefControl ctl = projectControlFactory.controlFor(c.getProject(), user)
-        .controlForRef(c.getDest());
+    ChangeControl ctl = changeControlFactory.controlFor(db, c, user);
 
     PatchSetInserter inserter = patchSetFactory.create(
           ctl, new PatchSet.Id(c.getId(), n), commit)
         .setSendMail(false)
-        .setRunHooks(false)
+        .setFireRevisionCreated(false)
         .setValidatePolicy(CommitValidators.Policy.NONE);
     try (BatchUpdate bu = updateFactory.create(
         db, c.getProject(), user, TimeUtil.nowTs());
@@ -1477,8 +1604,12 @@
   }
 
   protected void assertBadQuery(QueryRequest query) throws Exception {
-    exception.expect(BadRequestException.class);
-    query.get();
+    try {
+      query.get();
+      fail("expected BadRequestException for query: " + query);
+    } catch (BadRequestException e) {
+      // Expected.
+    }
   }
 
   protected TestRepository<Repo> createProject(String name) throws Exception {
@@ -1500,11 +1631,50 @@
       throws Exception {
     List<ChangeInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids).named(query.getQuery())
+    assertThat(ids).named(format(query, ids, changes))
         .containsExactlyElementsIn(ids(changes)).inOrder();
     return result;
   }
 
+  private String format(QueryRequest query, Iterable<Integer> actualIds,
+      Change... expectedChanges) throws RestApiException {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery())
+     .append("' with expected changes ");
+    b.append(format(Iterables.transform(Arrays.asList(expectedChanges),
+        new Function<Change, Integer>() {
+          @Override
+          public Integer apply(Change change) {
+            return change.getChangeId();
+          }
+        })));
+    b.append(" and result ");
+    b.append(format(actualIds));
+    return b.toString();
+  }
+
+  private String format(Iterable<Integer> changeIds) throws RestApiException {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<Integer> it = changeIds.iterator();
+    while (it.hasNext()) {
+      int id = it.next();
+      ChangeInfo c = gApi.changes().id(id).get();
+      b.append("{").append(id).append(" (").append(c.changeId)
+          .append("), ").append("dest=").append(
+              new Branch.NameKey(
+                  new Project.NameKey(c.project), c.branch)).append(", ")
+          .append("status=").append(c.status).append(", ")
+          .append("lastUpdated=").append(c.updated.getTime())
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
   protected static Iterable<Integer> ids(Change... changes) {
     return FluentIterable.from(Arrays.asList(changes)).transform(
         new Function<Change, Integer>() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
index ca1e2b1..eb19ebe 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -28,9 +28,9 @@
 public class ChangeDataTest {
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
-    ChangeData cd = ChangeData.createForTest(new Change.Id(1), 1);
-    cd.setChange(TestChanges.newChange(
-          new Project.NameKey("project"), new Account.Id(1000)));
+    Project.NameKey project = new Project.NameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, new Change.Id(1), 1);
+    cd.setChange(TestChanges.newChange(project, new Account.Id(1000)));
     PatchSet curr1 = cd.currentPatchSet();
     int currId = curr1.getId().get();
     PatchSet ps1 = new PatchSet(new PatchSet.Id(cd.getId(), currId + 1));
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index b503a13..038abda 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -30,7 +30,8 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(new InMemoryModule(luceneConfig));
+    return Guice.createInjector(
+        new InMemoryModule(luceneConfig, notesMigration));
   }
 
   @Test
@@ -38,10 +39,10 @@
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert(newChange(repo, commit1, null, null, null));
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert(newChange(repo, commit2, null, null, null));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
     assertQuery("message:bar", change1);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java
deleted file mode 100644
index 8b22ff5..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.MINUTES;
-
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
-import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Ignore;
-import org.junit.Test;
-
-public class LuceneQueryChangesV14Test extends LuceneQueryChangesTest {
-  @Override
-  protected Injector createInjector() {
-    Config luceneConfig = new Config(config);
-    InMemoryModule.setDefaults(luceneConfig);
-    // Latest version with a Lucene 4 index.
-    luceneConfig.setInt("index", "lucene", "testVersion", 14);
-    return Guice.createInjector(new InMemoryModule(luceneConfig));
-  }
-
-  @Override
-  @Ignore
-  @Test
-  public void byCommentBy() {
-    // Ignore.
-  }
-
-  @Override
-  @Ignore
-  @Test
-  public void byFrom() {
-    // Ignore.
-  }
-
-  @Override
-  @Ignore
-  @Test
-  public void byTopic() {
-    // Ignore.
-  }
-
-  @Override
-  @Ignore
-  @Test
-  public void reviewedBy() throws Exception {
-    // Ignore.
-  }
-
-  @Override
-  @Ignore
-  @Test
-  public void prepopulatedFields() throws Exception {
-    // Ignore.
-  }
-
-  @Test
-  public void isReviewed() throws Exception {
-    TestTimeUtil.resetWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(newChange(repo, null, null, null, null));
-    Change change2 = insert(newChange(repo, null, null, null, null));
-    Change change3 = insert(newChange(repo, null, null, null, null));
-
-    gApi.changes()
-      .id(change1.getId().get())
-      .current()
-      .review(new ReviewInput().message("comment"));
-
-    Account.Id user2 = accountManager
-        .authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId();
-    requestContext.setContext(newRequestContext(user2));
-
-    gApi.changes()
-        .id(change2.getId().get())
-        .current()
-        .review(ReviewInput.recommend());
-
-    PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3);
-    assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
-    // Nonzero score on previous patch set does not count.
-    gApi.changes()
-        .id(change3.getId().get())
-        .revision(ps3_1.get())
-        .review(ReviewInput.recommend());
-
-    assertQuery("is:reviewed", change2);
-    assertQuery("-is:reviewed", change3, change1);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index 55d0f38..5532108 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
 
 import org.junit.Test;
@@ -84,7 +85,8 @@
 
   private static ChangeData change(String... files) throws OrmException {
     Arrays.sort(files);
-    ChangeData cd = ChangeData.createForTest(new Change.Id(1), 1);
+    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"),
+        new Change.Id(1), 1);
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
new file mode 100644
index 0000000..cb0ab11
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class HANATest {
+
+  private HANA hana;
+  private Config config;
+
+  @Before
+  public void setup() {
+    config = new Config();
+    config.setString("database", null, "hostname", "my.host");
+    hana = new HANA(config);
+  }
+
+  @Test
+  public void testGetUrl() throws Exception {
+    config.setString("database", null, "instance", "3");
+    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:30315");
+
+    config.setString("database", null, "instance", "77");
+    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:37715");
+  }
+
+  @Test
+  public void testGetIndexScript() throws Exception {
+    assertThat(hana.getIndexScript()).isSameAs(ScriptRunner.NOOP);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
index 87b5322..cd6e825 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
@@ -44,6 +43,7 @@
 import java.io.IOException;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
@@ -111,7 +111,7 @@
 
   @Test
   public void testCreateSchema_LabelTypes() throws Exception {
-    List<String> labels = Lists.newArrayList();
+    List<String> labels = new ArrayList<>();
     for (LabelType label : getLabelTypes().getLabelTypes()) {
       labels.add(label.getName());
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
index f915d05..69a8487 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
@@ -52,27 +52,29 @@
 
 import static com.google.common.truth.Truth.assert_;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.io.ByteStreams;
 
 import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Ignore;
 
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 
+@Ignore
 public abstract class HookTestCase extends LocalDiskRepositoryTestCase {
   protected Repository repository;
-  private final Map<String, File> hooks = Maps.newTreeMap();
-  private final List<File> cleanup = Lists.newArrayList();
+  private final Map<String, File> hooks = new TreeMap<>();
+  private final List<File> cleanup = new ArrayList<>();
 
   @Override
   @Before
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
index c060aaf..3e3c13e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
@@ -24,9 +24,9 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import org.junit.Rule;
+import com.google.gerrit.testutil.GerritBaseTests;
+
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -34,10 +34,7 @@
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 
-public class SocketUtilTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class SocketUtilTest extends GerritBaseTests {
   @Test
   public void testIsIPv6() throws UnknownHostException {
     final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java
deleted file mode 100644
index 3945da7..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java
+++ /dev/null
@@ -1,290 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-import static org.junit.Assert.assertEquals;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.eclipse.jgit.lib.BlobBasedConfig;
-import org.eclipse.jgit.lib.Constants;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.net.URI;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-
-public class SubmoduleSectionParserTest extends LocalDiskRepositoryTestCase {
-  private static final String THIS_SERVER = "localhost";
-  private ProjectCache projectCache;
-  private BlobBasedConfig bbc;
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-
-    projectCache = createStrictMock(ProjectCache.class);
-    bbc = createStrictMock(BlobBasedConfig.class);
-  }
-
-  private void doReplay() {
-    replay(projectCache, bbc);
-  }
-
-  private void doVerify() {
-    verify(projectCache, bbc);
-  }
-
-  @Test
-  public void testSubmodulesParseWithCorrectSections() throws Exception {
-    final Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-    sectionsToReturn.put("b", new SubmoduleSection("ssh://localhost/b", "b",
-        "."));
-    sectionsToReturn.put("c", new SubmoduleSection("ssh://localhost/test/c",
-        "c-path", "refs/heads/master"));
-    sectionsToReturn.put("d", new SubmoduleSection("ssh://localhost/d",
-        "d-parent/the-d-folder", "refs/heads/test"));
-    sectionsToReturn.put("e", new SubmoduleSection("ssh://localhost/e.git", "e",
-        "."));
-
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a", "a");
-    reposToBeFound.put("b", "b");
-    reposToBeFound.put("c", "test/c");
-    reposToBeFound.put("d", "d");
-    reposToBeFound.put("e", "e");
-
-    final Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Sets.newHashSet();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a"), "refs/heads/master"), "a"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("b"), "refs/heads/master"), "b"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("test/c"), "refs/heads/master"),
-        "c-path"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("d"), "refs/heads/test"),
-        "d-parent/the-d-folder"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("e"), "refs/heads/master"), "e"));
-
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmodulesParseWithAnInvalidSection() throws Exception {
-    final Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-    // This one is invalid since "b" is not a recognized project
-    sectionsToReturn.put("b", new SubmoduleSection("ssh://localhost/b", "b",
-        "."));
-    sectionsToReturn.put("c", new SubmoduleSection("ssh://localhost/test/c",
-        "c-path", "refs/heads/master"));
-    sectionsToReturn.put("d", new SubmoduleSection("ssh://localhost/d",
-        "d-parent/the-d-folder", "refs/heads/test"));
-    sectionsToReturn.put("e", new SubmoduleSection("ssh://localhost/e.git", "e",
-        "."));
-
-    // "b" will not be in this list
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a", "a");
-    reposToBeFound.put("c", "test/c");
-    reposToBeFound.put("d", "d");
-    reposToBeFound.put("e", "e");
-
-    final Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Sets.newHashSet();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a"), "refs/heads/master"), "a"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("test/c"), "refs/heads/master"),
-        "c-path"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("d"), "refs/heads/test"),
-        "d-parent/the-d-folder"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("e"), "refs/heads/master"), "e"));
-
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmoduleSectionToOtherServer() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    // The url is not to this server.
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://review.source.com/a",
-        "a", "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testProjectNotFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testProjectWithSlashesNotFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    sectionsToReturn.put("project", new SubmoduleSection(
-        "ssh://localhost/company/tools/project", "project", "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmodulesParseWithSubProjectFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a/b", new SubmoduleSection(
-        "ssh://localhost/a/b", "a/b", "."));
-
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a/b", "a/b");
-    reposToBeFound.put("b", "b");
-
-    Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Sets.newHashSet();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a/b"), "refs/heads/master"), "a/b"));
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  private void execute(final Branch.NameKey superProjectBranch,
-      final Map<String, SubmoduleSection> sectionsToReturn,
-      final Map<String, String> reposToBeFound,
-      final Set<SubmoduleSubscription> expectedSubscriptions) throws Exception {
-    expect(bbc.getSubsections("submodule"))
-        .andReturn(sectionsToReturn.keySet());
-
-    for (Map.Entry<String, SubmoduleSection> entry : sectionsToReturn.entrySet()) {
-      String id = entry.getKey();
-      final SubmoduleSection section = entry.getValue();
-      expect(bbc.getString("submodule", id, "url")).andReturn(section.getUrl());
-      expect(bbc.getString("submodule", id, "path")).andReturn(
-          section.getPath());
-      expect(bbc.getString("submodule", id, "branch")).andReturn(
-          section.getBranch());
-
-      if (THIS_SERVER.equals(new URI(section.getUrl()).getHost())) {
-        String projectNameCandidate;
-        final String urlExtractedPath = new URI(section.getUrl()).getPath();
-        int fromIndex = urlExtractedPath.length() - 1;
-        while (fromIndex > 0) {
-          fromIndex = urlExtractedPath.lastIndexOf('/', fromIndex - 1);
-          projectNameCandidate = urlExtractedPath.substring(fromIndex + 1);
-          if (projectNameCandidate.endsWith(Constants.DOT_GIT_EXT)) {
-            projectNameCandidate = projectNameCandidate.substring(0, //
-                projectNameCandidate.length() - Constants.DOT_GIT_EXT.length());
-          }
-          if (reposToBeFound.containsValue(projectNameCandidate)) {
-            expect(projectCache.get(new Project.NameKey(projectNameCandidate)))
-                .andReturn(createNiceMock(ProjectState.class));
-          } else {
-            expect(projectCache.get(new Project.NameKey(projectNameCandidate)))
-                .andReturn(null);
-          }
-        }
-      }
-    }
-
-    doReplay();
-
-    final SubmoduleSectionParser ssp =
-        new SubmoduleSectionParser(projectCache, bbc, THIS_SERVER,
-            superProjectBranch);
-
-    Set<SubmoduleSubscription> returnedSubscriptions = ssp.parseAllSections();
-
-    doVerify();
-
-    assertEquals(expectedSubscriptions, returnedSubscriptions);
-  }
-
-  private static final class SubmoduleSection {
-    private final String url;
-    private final String path;
-    private final String branch;
-
-    public SubmoduleSection(final String url, final String path,
-        final String branch) {
-      this.url = url;
-      this.path = path;
-      this.branch = branch;
-    }
-
-    public String getUrl() {
-      return url;
-    }
-
-    public String getPath() {
-      return path;
-    }
-
-    public String getBranch() {
-      return branch;
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
index c526cea..11d7ad0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
@@ -22,9 +22,7 @@
 import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
-import com.google.gerrit.reviewdb.server.AccountPatchReviewAccess;
 import com.google.gerrit.reviewdb.server.AccountProjectWatchAccess;
-import com.google.gerrit.reviewdb.server.AccountSshKeyAccess;
 import com.google.gerrit.reviewdb.server.ChangeAccess;
 import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
 import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
@@ -32,8 +30,6 @@
 import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
-import com.google.gerrit.reviewdb.server.StarredChangeAccess;
-import com.google.gerrit.reviewdb.server.SubmoduleSubscriptionAccess;
 import com.google.gerrit.reviewdb.server.SystemConfigAccess;
 import com.google.gwtorm.server.Access;
 import com.google.gwtorm.server.StatementExecutor;
@@ -99,11 +95,6 @@
   }
 
   @Override
-  public AccountSshKeyAccess accountSshKeys() {
-    throw new Disabled();
-  }
-
-  @Override
   public AccountGroupAccess accountGroups() {
     throw new Disabled();
   }
@@ -124,21 +115,11 @@
   }
 
   @Override
-  public StarredChangeAccess starredChanges() {
-    throw new Disabled();
-  }
-
-  @Override
   public AccountProjectWatchAccess accountProjectWatches() {
     throw new Disabled();
   }
 
   @Override
-  public AccountPatchReviewAccess accountPatchReviews() {
-    throw new Disabled();
-  }
-
-  @Override
   public ChangeAccess changes() {
     throw new Disabled();
   }
@@ -164,11 +145,6 @@
   }
 
   @Override
-  public SubmoduleSubscriptionAccess submoduleSubscriptions() {
-    throw new Disabled();
-  }
-
-  @Override
   public AccountGroupByIdAccess accountGroupById() {
     throw new Disabled();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
index 011d69d..07cd63e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -15,15 +15,18 @@
 package com.google.gerrit.testutil;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 
+import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -31,8 +34,8 @@
   private final Map<String, AccountState> byUsername;
 
   public FakeAccountCache() {
-    byId = Maps.newHashMap();
-    byUsername = Maps.newHashMap();
+    byId = new HashMap<>();
+    byUsername = new HashMap<>();
   }
 
   @Override
@@ -64,6 +67,12 @@
     byUsername.remove(username);
   }
 
+  @Override
+  public synchronized void evictAll() {
+    byId.clear();
+    byUsername.clear();
+  }
+
   public synchronized void put(Account account) {
     AccountState state = newState(account);
     byId.put(account.getId(), state);
@@ -73,8 +82,8 @@
   }
 
   private static AccountState newState(Account account) {
-    return new AccountState(
-        account, ImmutableSet.<AccountGroup.UUID> of(),
-        ImmutableSet.<AccountExternalId> of());
+    return new AccountState(account, ImmutableSet.<AccountGroup.UUID> of(),
+        ImmutableSet.<AccountExternalId> of(),
+        new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
index 7adf721..f2d563e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
@@ -97,6 +97,13 @@
     messages.add(Message.create(from, rcpt, headers, body));
   }
 
+  public void clear() {
+    waitForEmails();
+    synchronized (messages) {
+      messages.clear();
+    }
+  }
+
   public ImmutableList<Message> getMessages() {
     waitForEmails();
     synchronized (messages) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java
index 7f8fc32..bbcb6a9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.testutil;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 
 import org.eclipse.jgit.util.FileUtils;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 
 public abstract class FilesystemLoggingMockingTestCase extends LoggingMockingTestCase {
 
-  private Collection<File> toCleanup = Lists.newArrayList();
+  private Collection<File> toCleanup = new ArrayList<>();
 
   /**
    * Asserts that a given file exists.
@@ -118,7 +118,7 @@
    * @throws IOException If a file could not be created.
    */
   private File createTempFile(String suffix) throws IOException {
-    String prefix ="gerrit_test_";
+    String prefix = "gerrit_test_";
     if (!Strings.isNullOrEmpty(getName())) {
       prefix += getName() + "_";
     }
@@ -175,4 +175,4 @@
     cleanupCreatedFiles();
     super.tearDown();
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
new file mode 100644
index 0000000..967e3f9
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+
+@Ignore
+public abstract class GerritBaseTests {
+  @Rule
+  public ExpectedException exception = ExpectedException.none();
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
new file mode 100644
index 0000000..797f1cb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+@RunWith(ConfigSuite.class)
+public class GerritServerTests extends GerritBaseTests {
+  @ConfigSuite.Parameter
+  public Config config;
+
+  @ConfigSuite.Name
+  private String configName;
+
+  protected TestNotesMigration notesMigration;
+
+  @Rule
+  public TestRule testRunner = new TestRule() {
+    @Override
+    public Statement apply(final Statement base, final Description description) {
+      return new Statement() {
+        @Override
+        public void evaluate() throws Throwable {
+          beforeTest();
+          try {
+            base.evaluate();
+          } finally {
+            afterTest();
+          }
+        }
+      };
+    }
+  };
+
+  public void beforeTest() throws Exception {
+    notesMigration = new TestNotesMigration().setFromEnv();
+  }
+
+  public void afterTest() {
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java
index 25a6534..7edfa1a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryH2Type.java
@@ -27,4 +27,4 @@
     // not used
     throw new UnsupportedOperationException();
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index d18712e..9e5b776 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.testutil;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.common.DisabledChangeHooks;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -37,19 +37,23 @@
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
+import com.google.gerrit.server.git.ChangeUpdateExecutor;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.SendEmailExecutor;
-import com.google.gerrit.server.index.ChangeSchemas;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.schema.DataSourceType;
+import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -70,10 +74,12 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
-import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 
 public class InMemoryModule extends FactoryModule {
@@ -93,21 +99,21 @@
     cfg.unset("cache", null, "directory");
     cfg.setString("index", null, "type", "lucene");
     cfg.setBoolean("index", "lucene", "testInmemory", true);
-    cfg.setInt("index", "lucene", "testVersion",
-        ChangeSchemas.getLatest().getVersion());
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
     cfg.setBoolean("receive", null, "enableSignedPush", false);
     cfg.setString("receive", null, "certNonceSeed", "sekret");
   }
 
   private final Config cfg;
+  private final TestNotesMigration notesMigration;
 
   public InMemoryModule() {
-    this(newDefaultConfig());
+    this(newDefaultConfig(), new TestNotesMigration());
   }
 
-  public InMemoryModule(Config cfg) {
+  public InMemoryModule(Config cfg, TestNotesMigration notesMigration) {
     this.cfg = cfg;
+    this.notesMigration = notesMigration;
   }
 
   public void inject(Object instance) {
@@ -132,8 +138,9 @@
             .toInstance(cfg);
       }
     });
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
-    install(new ChangeCacheImplModule(false));
+    install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
@@ -145,8 +152,11 @@
         .annotatedWith(GerritPersonIdent.class)
         .toProvider(GerritPersonIdentProvider.class);
     bind(String.class)
-      .annotatedWith(AnonymousCowardName.class)
-      .toProvider(AnonymousCowardNameProvider.class);
+        .annotatedWith(AnonymousCowardName.class)
+        .toProvider(AnonymousCowardNameProvider.class);
+    bind(String.class)
+        .annotatedWith(GerritServerId.class)
+        .toInstance("gerrit");
     bind(AllProjectsName.class)
         .toProvider(AllProjectsNameProvider.class);
     bind(AllUsersName.class)
@@ -156,6 +166,10 @@
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class)
         .in(SINGLETON);
+    bind(NotesMigration.class).toInstance(notesMigration);
+    bind(ListeningExecutorService.class)
+        .annotatedWith(ChangeUpdateExecutor.class)
+        .toInstance(MoreExecutors.newDirectExecutorService());
 
     bind(DataSourceType.class)
       .to(InMemoryH2Type.class);
@@ -164,7 +178,6 @@
 
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
-    bind(ChangeHooks.class).to(DisabledChangeHooks.class);
     install(NoSshKeyCache.module());
     install(new CanonicalWebUrlModule() {
       @Override
@@ -188,6 +201,7 @@
     install(new FakeEmailSender.Module());
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
+    install(new H2AccountPatchReviewStore.InMemoryModule());
 
     IndexType indexType = null;
     try {
@@ -223,17 +237,20 @@
 
   private Module luceneIndexModule() {
     try {
+      Map<String, Integer> singleVersions = new HashMap<>();
       int version = cfg.getInt("index", "lucene", "testVersion", -1);
-      checkState(ChangeSchemas.ALL.containsKey(version),
-          "invalid index.lucene.testVersion %s", version);
+      if (version > 0) {
+        singleVersions.put(ChangeSchemaDefinitions.INSTANCE.getName(), version);
+      }
       Class<?> clazz =
           Class.forName("com.google.gerrit.lucene.LuceneIndexModule");
-      Constructor<?> c =
-          clazz.getConstructor(Integer.class, int.class, String.class);
-      return (Module) c.newInstance(version, 0, null);
+      Method m = clazz.getMethod(
+          "singleVersionWithExplicitVersions", Map.class, int.class);
+      return (Module) m.invoke(null, singleVersions, 0);
     } catch (ClassNotFoundException | SecurityException | NoSuchMethodException
-        | IllegalArgumentException | InstantiationException
-        | IllegalAccessException | InvocationTargetException e) {
+        | IllegalArgumentException | IllegalAccessException
+        | InvocationTargetException e) {
+      e.printStackTrace();
       ProvisionException pe = new ProvisionException(e.getMessage());
       pe.initCause(e);
       throw pe;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index ec53b29..e7bd8f8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.testutil;
 
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -26,6 +25,7 @@
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 
+import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedSet;
 
@@ -53,6 +53,9 @@
   public static class Repo extends InMemoryRepository {
     private Repo(Project.NameKey name) {
       super(new Description(name));
+      // TODO(dborowitz): Allow atomic transactions when this is supported:
+      // https://git.eclipse.org/r/#/c/61841/2/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/dfs/InMemoryRepository.java@313
+      setPerformsAtomicTransactions(false);
     }
 
     @Override
@@ -61,7 +64,7 @@
     }
   }
 
-  private Map<String, Repo> repos = Maps.newHashMap();
+  private Map<String, Repo> repos = new HashMap<>();
 
   @Override
   public synchronized Repo openRepository(Project.NameKey name)
@@ -80,18 +83,12 @@
       }
     } catch (RepositoryNotFoundException e) {
       repo = new Repo(name);
-      repos.put(name.get().toLowerCase(), repo);
+      repos.put(normalize(name), repo);
     }
     return repo;
   }
 
   @Override
-  public synchronized Repo openMetadataRepository(
-      Project.NameKey name) throws RepositoryNotFoundException {
-    return openRepository(name);
-  }
-
-  @Override
   public synchronized SortedSet<Project.NameKey> list() {
     SortedSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
@@ -116,13 +113,21 @@
     }
   }
 
+  public synchronized void deleteRepository(Project.NameKey name) {
+    repos.remove(normalize(name));
+  }
+
   private synchronized Repo get(Project.NameKey name)
       throws RepositoryNotFoundException {
-    Repo repo = repos.get(name.get().toLowerCase());
+    Repo repo = repos.get(normalize(name));
     if (repo != null) {
+      repo.incrementOpen();
       return repo;
-    } else {
-      throw new RepositoryNotFoundException(name.get());
     }
+    throw new RepositoryNotFoundException(name.get());
+  }
+
+  private static String normalize(Project.NameKey name) {
+    return name.get().toLowerCase();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java
index 218fa0a..d7140ec 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.testutil;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.testutil.log.LogUtil;
 
 import org.apache.log4j.LogManager;
@@ -22,6 +21,7 @@
 import org.apache.log4j.spi.LoggingEvent;
 import org.junit.After;
 
+import java.util.ArrayList;
 import java.util.Iterator;
 
 /**
@@ -102,14 +102,14 @@
   @Override
   public void setUp() throws Exception {
     super.setUp();
-    loggedEvents = Lists.newArrayList();
+    loggedEvents = new ArrayList<>();
 
     // The logger we're interested is class name without the trailing "Test".
     // While this is not the most general approach it is sufficient for now,
     // and we can improve later to allow tests to specify which loggers are
     // to check.
     loggerName = this.getClass().getCanonicalName();
-    loggerName = loggerName.substring(0, loggerName.length()-4);
+    loggerName = loggerName.substring(0, loggerName.length() - 4);
     loggerSettings = LogUtil.logToCollection(loggerName, loggedEvents);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
new file mode 100644
index 0000000..61bfe78
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -0,0 +1,185 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@Singleton
+public class NoteDbChecker {
+  static final Logger log = LoggerFactory.getLogger(NoteDbChecker.class);
+
+  private final Provider<ReviewDb> dbProvider;
+  private final GitRepositoryManager repoManager;
+  private final TestNotesMigration notesMigration;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeRebuilder changeRebuilder;
+  private final PatchLineCommentsUtil plcUtil;
+
+  @Inject
+  NoteDbChecker(Provider<ReviewDb> dbProvider,
+      GitRepositoryManager repoManager,
+      TestNotesMigration notesMigration,
+      ChangeNotes.Factory notesFactory,
+      ChangeRebuilder changeRebuilder,
+      PatchLineCommentsUtil plcUtil) {
+    this.dbProvider = dbProvider;
+    this.repoManager = repoManager;
+    this.notesMigration = notesMigration;
+    this.notesFactory = notesFactory;
+    this.changeRebuilder = changeRebuilder;
+    this.plcUtil = plcUtil;
+  }
+
+  public void rebuildAndCheckAllChanges() throws Exception {
+    rebuildAndCheckChanges(
+        Iterables.transform(
+            getUnwrappedDb().changes().all(),
+            ReviewDbUtil.changeIdFunction()));
+  }
+
+  public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
+    rebuildAndCheckChanges(Arrays.asList(changeIds));
+  }
+
+  public void rebuildAndCheckChanges(Iterable<Change.Id> changeIds)
+      throws Exception {
+    ReviewDb db = getUnwrappedDb();
+
+    List<ChangeBundle> allExpected = readExpected(changeIds);
+
+    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldRead = notesMigration.readChanges();
+    try {
+      notesMigration.setWriteChanges(true);
+      notesMigration.setReadChanges(true);
+      List<String> msgs = new ArrayList<>();
+      for (ChangeBundle expected : allExpected) {
+        Change c = expected.getChange();
+        try {
+          changeRebuilder.rebuild(db, c.getId());
+        } catch (RepositoryNotFoundException e) {
+          msgs.add("Repository not found for change, cannot convert: " + c);
+        }
+      }
+
+      checkActual(allExpected, msgs);
+    } finally {
+      notesMigration.setReadChanges(oldRead);
+      notesMigration.setWriteChanges(oldWrite);
+    }
+  }
+
+  public void checkChanges(Change.Id... changeIds) throws Exception {
+    checkChanges(Arrays.asList(changeIds));
+  }
+
+  public void checkChanges(Iterable<Change.Id> changeIds) throws Exception {
+    checkActual(readExpected(changeIds), new ArrayList<String>());
+  }
+
+  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNull();
+    }
+  }
+
+  private List<ChangeBundle> readExpected(Iterable<Change.Id> changeIds)
+      throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    boolean old = notesMigration.readChanges();
+    try {
+      notesMigration.setReadChanges(false);
+      List<Change.Id> sortedIds =
+          ReviewDbUtil.intKeyOrdering().sortedCopy(changeIds);
+      List<ChangeBundle> expected = new ArrayList<>(sortedIds.size());
+      for (Change.Id id : sortedIds) {
+        expected.add(ChangeBundle.fromReviewDb(db, id));
+      }
+      return expected;
+    } finally {
+      notesMigration.setReadChanges(old);
+    }
+  }
+
+  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs)
+      throws Exception {
+    ReviewDb db = getUnwrappedDb();
+    boolean oldRead = notesMigration.readChanges();
+    boolean oldWrite = notesMigration.writeChanges();
+    try {
+      notesMigration.setWriteChanges(true);
+      notesMigration.setReadChanges(true);
+      for (ChangeBundle expected : allExpected) {
+        Change c = expected.getChange();
+        ChangeBundle actual;
+        try {
+          actual = ChangeBundle.fromNotes(
+              plcUtil, notesFactory.create(db, c.getProject(), c.getId()));
+        } catch (Throwable t) {
+          String msg = "Error converting change: " + c;
+          msgs.add(msg);
+          log.error(msg, t);
+          continue;
+        }
+        List<String> diff = expected.differencesFrom(actual);
+        if (!diff.isEmpty()) {
+          msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
+          msgs.addAll(diff);
+          msgs.add("");
+        } else {
+          System.err.println(
+              "NoteDb conversion of change " + c.getId() + " successful");
+        }
+      }
+    } finally {
+      notesMigration.setReadChanges(oldRead);
+      notesMigration.setWriteChanges(oldWrite);
+    }
+    if (!msgs.isEmpty()) {
+      throw new AssertionError(Joiner.on('\n').join(msgs));
+    }
+  }
+
+  private ReviewDb getUnwrappedDb() {
+    ReviewDb db = dbProvider.get();
+    return  ReviewDbUtil.unwrapDb(db);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
new file mode 100644
index 0000000..103fee3
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+
+public enum NoteDbMode {
+  /** NoteDb is disabled. */
+  OFF,
+
+  /** Writing data to NoteDb is enabled. */
+  WRITE,
+
+  /** Reading and writing all data to NoteDb is enabled. */
+  READ_WRITE,
+
+  /**
+   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check
+   * that the results match.
+   */
+  CHECK;
+
+  private static final String VAR = "GERRIT_NOTEDB";
+
+  public static NoteDbMode get() {
+    if (isEnvVarTrue("GERRIT_ENABLE_NOTEDB")) {
+      // TODO(dborowitz): Remove once GerritForge CI is migrated.
+      return READ_WRITE;
+    }
+    String value = System.getenv(VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      return OFF;
+    }
+    value = value.toUpperCase().replace("-", "_");
+    Optional<NoteDbMode> mode = Enums.getIfPresent(NoteDbMode.class, value);
+    if (!mode.isPresent()) {
+      throw new IllegalArgumentException(
+          "Invalid value for " + VAR + ": " + System.getenv(VAR));
+    }
+    return mode.get();
+  }
+
+  public static boolean readWrite() {
+    return get() == READ_WRITE;
+  }
+
+  private static boolean isEnvVarTrue(String name) {
+    String value = Strings.nullToEmpty(System.getenv(name)).toLowerCase();
+    return ImmutableList.of("yes", "y", "true", "1").contains(value);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
index 8b5e85a..594ce82 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testutil;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static org.easymock.EasyMock.expect;
 
 import com.google.common.collect.Ordering;
@@ -26,11 +27,9 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -39,8 +38,13 @@
 import com.google.inject.Injector;
 
 import org.easymock.EasyMock;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
+import java.util.TimeZone;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -82,32 +86,62 @@
   }
 
   public static ChangeUpdate newUpdate(Injector injector,
-      GitRepositoryManager repoManager, NotesMigration migration, Change c,
-      final AllUsersNameProvider allUsers, final IdentifiedUser user)
-      throws OrmException {
-    return injector.createChildInjector(new FactoryModule() {
+      Change c, final CurrentUser user) throws Exception  {
+    injector = injector.createChildInjector(new FactoryModule() {
       @Override
       public void configure() {
-        factory(ChangeUpdate.Factory.class);
-        factory(ChangeDraftUpdate.Factory.class);
-        bind(IdentifiedUser.class).toInstance(user);
-        bind(AllUsersName.class).toProvider(allUsers);
+        bind(CurrentUser.class).toInstance(user);
       }
-    }).getInstance(ChangeUpdate.Factory.class).create(
-        stubChangeControl(repoManager, migration, c, allUsers, user),
-        TimeUtil.nowTs(), Ordering.<String> natural());
+    });
+    ChangeUpdate update = injector.getInstance(ChangeUpdate.Factory.class)
+        .create(
+            stubChangeControl(
+                injector.getInstance(AbstractChangeNotes.Args.class),
+                c,
+                user),
+            TimeUtil.nowTs(), Ordering.<String> natural());
+
+    ChangeNotes notes = update.getNotes();
+    boolean hasPatchSets = notes.getPatchSets() != null
+        && !notes.getPatchSets().isEmpty();
+    NotesMigration migration = injector.getInstance(NotesMigration.class);
+    if (hasPatchSets || !migration.readChanges()) {
+      return update;
+    }
+
+    // Change doesn't exist yet. NoteDb requires that there be a commit for the
+    // first patch set, so create one.
+    GitRepositoryManager repoManager =
+        injector.getInstance(GitRepositoryManager.class);
+    try (Repository repo = repoManager.openRepository(c.getProject())) {
+      TestRepository<Repository> tr = new TestRepository<>(repo);
+      PersonIdent ident = user.asIdentifiedUser()
+          .newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+      TestRepository<Repository>.CommitBuilder cb = tr.commit()
+          .author(ident)
+          .committer(ident)
+          .message(firstNonNull(c.getSubject(), "Test change"));
+      Ref parent = repo.exactRef(c.getDest().get());
+      if (parent != null) {
+        cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
+      }
+      update.setBranch(c.getDest().get());
+      update.setChangeId(c.getKey().get());
+      update.setCommit(tr.getRevWalk(), cb.create());
+      return update;
+    }
   }
 
-  public static ChangeControl stubChangeControl(
-      GitRepositoryManager repoManager, NotesMigration migration,
-      Change c, AllUsersNameProvider allUsers,
-      IdentifiedUser user) throws OrmException {
-    ChangeControl ctl = EasyMock.createNiceMock(ChangeControl.class);
+  private static ChangeControl stubChangeControl(
+      AbstractChangeNotes.Args args,
+      Change c, CurrentUser user) throws OrmException {
+    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
     expect(ctl.getChange()).andStubReturn(c);
+    expect(ctl.getProject()).andStubReturn(new Project(c.getProject()));
     expect(ctl.getUser()).andStubReturn(user);
-    ChangeNotes notes = new ChangeNotes(repoManager, migration, allUsers, c)
-        .load();
+    ChangeNotes notes = new ChangeNotes(args, c).load();
     expect(ctl.getNotes()).andStubReturn(notes);
+    expect(ctl.getId()).andStubReturn(c.getId());
     EasyMock.replay(ctl);
     return ctl;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
new file mode 100644
index 0000000..2f9d67f
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Singleton;
+
+/** {@link NotesMigration} with bits that can be flipped live for testing. */
+@Singleton
+public class TestNotesMigration extends NotesMigration {
+  private volatile boolean readChanges;
+  private volatile boolean writeChanges;
+  private volatile boolean failOnLoad;
+
+  @Override
+  public boolean readChanges() {
+    return readChanges;
+  }
+
+  @Override
+  public boolean readChangeSequence() {
+    // Unlike ConfigNotesMigration, read change numbers from NoteDb by default
+    // when reads are enabled, to improve test coverage.
+    return readChanges;
+  }
+
+  // Increase visbility from superclass, as tests may want to check whether
+  // NoteDb data is written in specific migration scenarios.
+  @Override
+  public boolean writeChanges() {
+    return writeChanges;
+  }
+
+  @Override
+  public boolean readAccounts() {
+    return false;
+  }
+
+  @Override
+  public boolean writeAccounts() {
+    return false;
+  }
+
+  @Override
+  public boolean failOnLoad() {
+    return failOnLoad;
+  }
+
+  public TestNotesMigration setReadChanges(boolean readChanges) {
+    this.readChanges = readChanges;
+    return this;
+  }
+
+  public TestNotesMigration setWriteChanges(boolean writeChanges) {
+    this.writeChanges = writeChanges;
+    return this;
+  }
+
+  public TestNotesMigration setFailOnLoad(boolean failOnLoad) {
+    this.failOnLoad = failOnLoad;
+    return this;
+  }
+
+  public TestNotesMigration setAllEnabled(boolean enabled) {
+    return setReadChanges(enabled).setWriteChanges(enabled);
+  }
+
+  public TestNotesMigration setFromEnv() {
+    switch (NoteDbMode.get()) {
+      case READ_WRITE:
+        setWriteChanges(true);
+        setReadChanges(true);
+        break;
+      case WRITE:
+        setWriteChanges(true);
+        setReadChanges(false);
+        break;
+      case CHECK:
+      case OFF:
+      default:
+        setWriteChanges(false);
+        setReadChanges(false);
+        break;
+    }
+    return this;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
index 4c71c57..eae5ed9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -17,6 +17,10 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
 import org.joda.time.DateTime;
 import org.joda.time.DateTimeUtils;
 import org.joda.time.DateTimeUtils.MillisProvider;
@@ -63,11 +67,57 @@
         return clockMs.getAndAdd(clockStepMs);
       }
     });
+
+    SystemReader.setInstance(null);
+    final SystemReader defaultReader = SystemReader.getInstance();
+    SystemReader r = new SystemReader() {
+      @Override
+      public String getHostname() {
+        return defaultReader.getHostname();
+      }
+
+      @Override
+      public String getenv(String variable) {
+        return defaultReader.getenv(variable);
+      }
+
+      @Override
+      public String getProperty(String key) {
+        return defaultReader.getProperty(key);
+      }
+
+      @Override
+      public FileBasedConfig openUserConfig(Config parent, FS fs) {
+        return defaultReader.openUserConfig(parent, fs);
+      }
+
+      @Override
+      public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+        return defaultReader.openSystemConfig(parent, fs);
+      }
+
+      @Override
+      public long getCurrentTime() {
+        return clockMs.getAndAdd(clockStepMs);
+      }
+
+      @Override
+      public int getTimezone(long when) {
+        return defaultReader.getTimezone(when);
+      }
+    };
+    SystemReader.setInstance(r);
   }
 
-  /** Reset the clock to use the actual system clock. */
+  /**
+   * Reset the clock to use the actual system clock.
+   * <p>
+   * As a side effect, resets the {@link SystemReader} to the original default
+   * instance.
+   */
   public static synchronized void useSystemTime() {
     DateTimeUtils.setCurrentMillisSystem();
+    SystemReader.setInstance(null);
   }
 
   private TestTimeUtil() {
diff --git a/gerrit-sshd/BUCK b/gerrit-sshd/BUCK
index 5a7b539..54b83e2 100644
--- a/gerrit-sshd/BUCK
+++ b/gerrit-sshd/BUCK
@@ -20,18 +20,20 @@
     '//lib:jsch',
     '//lib/auto:auto-value',
     '//lib/commons:codec',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',  # SSH should not depend on servlet
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
     '//lib/log:api',
     '//lib/log:log4j',
     '//lib/mina:core',
     '//lib/mina:sshd',
-    '//lib/jgit:jgit',
-    '//lib/jgit:jgit-archive',
   ],
   provided_deps = [
     '//lib/bouncycastle:bcprov',
+    '//lib:servlet-api-3_1',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-sshd/BUILD b/gerrit-sshd/BUILD
new file mode 100644
index 0000000..be49c73
--- /dev/null
+++ b/gerrit-sshd/BUILD
@@ -0,0 +1,53 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+SRCS = glob(['src/main/java/**/*.java'])
+
+java_library(
+  name = 'sshd',
+  srcs = SRCS,
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-cache-h2:cache-h2',
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//gerrit-lucene:lucene',
+    '//gerrit-patch-jgit:server',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-util-cli:cli',
+    '//lib:args4j',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:jsch',
+    '//lib:servlet-api-3_1',
+    '//lib/auto:auto-value',
+    '//lib/bouncycastle:bcprov',
+    '//lib/commons:codec',
+    '//lib/dropwizard:dropwizard-core',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:guice-servlet',  # SSH should not depend on servlet
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
+    '//lib/log:api',
+    '//lib/log:log4j',
+    '//lib/mina:core',
+    '//lib/mina:sshd',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'sshd_tests',
+  srcs = glob(
+    ['src/test/java/**/*.java'],
+  ),
+  deps = [
+    ':sshd',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//lib:truth',
+    '//lib/mina:sshd',
+  ],
+)
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index b46fced..fde3a66 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.collect.Lists;
+import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityControl;
-import com.google.inject.Provider;
 
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -32,12 +31,12 @@
 /** Command that executes some other command. */
 public class AliasCommand extends BaseCommand {
   private final DispatchCommandProvider root;
-  private final Provider<CurrentUser> currentUser;
+  private final CurrentUser currentUser;
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
 
   AliasCommand(@CommandName(Commands.ROOT) DispatchCommandProvider root,
-      Provider<CurrentUser> currentUser, CommandName command) {
+      CurrentUser currentUser, CommandName command) {
     this.root = root;
     this.currentUser = currentUser;
     this.command = command;
@@ -64,19 +63,19 @@
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
       if (p == null) {
-        throw new UnloggedFailure(1, getName() + ": not found");
+        throw die(getName() + ": not found");
       }
 
       Command cmd = p.getProvider().get();
       if (!(cmd instanceof DispatchCommand)) {
-        throw new UnloggedFailure(1, getName() + ": not found");
+        throw die(getName() + ": not found");
       }
       map = ((DispatchCommand) cmd).getMap();
     }
 
     CommandProvider p = map.get(command.value());
     if (p == null) {
-      throw new UnloggedFailure(1, getName() + ": not found");
+      throw die(getName() + ": not found");
     }
 
     Command cmd = p.getProvider().get();
@@ -95,26 +94,30 @@
   public void destroy() {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
+      try {
         cmd.destroy();
+      } catch (Exception e) {
+        Throwables.propagateIfPossible(e);
+        throw new RuntimeException(e);
+      }
     }
   }
 
   private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
     RequiresCapability rc = cmd.getClass().getAnnotation(RequiresCapability.class);
     if (rc != null) {
-      CurrentUser user = currentUser.get();
-      CapabilityControl ctl = user.getCapabilities();
+      CapabilityControl ctl = currentUser.getCapabilities();
       if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
         String msg = String.format(
             "fatal: %s does not have \"%s\" capability.",
-            user.getUserName(), rc.value());
+            currentUser.getUserName(), rc.value());
         throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
       }
     }
   }
 
   private static LinkedList<String> chain(CommandName command) {
-    LinkedList<String> chain = Lists.newLinkedList();
+    LinkedList<String> chain = new LinkedList<>();
     while (command != null) {
       chain.addFirst(command.value());
       command = Commands.parentOf(command);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
index ee28e03..432844c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -29,7 +29,7 @@
   private DispatchCommandProvider root;
 
   @Inject
-  private Provider<CurrentUser> currentUser;
+  private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
     this.command = command;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index 5c897e4..25fb7a7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.server.Command;
@@ -89,10 +88,10 @@
   private WorkQueue.Executor executor;
 
   @Inject
-  private Provider<CurrentUser> user;
+  private CurrentUser user;
 
   @Inject
-  private Provider<SshScope.Context> contextProvider;
+  private SshScope.Context context;
 
   /** Commands declared by a plugin can be scoped by the plugin name. */
   @Inject(optional = true)
@@ -278,7 +277,7 @@
     final TaskThunk tt = new TaskThunk(thunk);
 
     if (isAdminHighPriorityCommand()
-        && user.get().getCapabilities().canAdministrateServer()) {
+        && user.getCapabilities().canAdministrateServer()) {
       // Admin commands should not block the main work threads (there
       // might be an interactive shell there), nor should they wait
       // for the main work threads.
@@ -289,7 +288,7 @@
     }
   }
 
-  private final boolean isAdminHighPriorityCommand() {
+  private boolean isAdminHighPriorityCommand() {
     return getClass().getAnnotation(AdminHighPriorityCommand.class) != null;
   }
 
@@ -332,8 +331,8 @@
     if (!(e instanceof UnloggedFailure)) {
       final StringBuilder m = new StringBuilder();
       m.append("Internal server error");
-      if (user.get().isIdentifiedUser()) {
-        final IdentifiedUser u = user.get().asIdentifiedUser();
+      if (user.isIdentifiedUser()) {
+        final IdentifiedUser u = user.asIdentifiedUser();
         m.append(" (user ");
         m.append(u.getAccount().getUserName());
         m.append(" account ");
@@ -341,7 +340,7 @@
         m.append(")");
       }
       m.append(" during ");
-      m.append(contextProvider.get().getCommandLine());
+      m.append(context.getCommandLine());
       log.error(m.toString(), e);
     }
 
@@ -357,17 +356,17 @@
       }
       return f.exitCode;
 
-    } else {
-      try {
-        err.write("fatal: internal server error\n".getBytes(ENC));
-        err.flush();
-      } catch (IOException e2) {
-        // Ignored
-      } catch (Throwable e2) {
-        log.warn("Cannot send internal server error message to client", e2);
-      }
-      return 128;
     }
+
+    try {
+      err.write("fatal: internal server error\n".getBytes(ENC));
+      err.flush();
+    } catch (IOException e2) {
+      // Ignored
+    } catch (Throwable e2) {
+      log.warn("Cannot send internal server error message to client", e2);
+    }
+    return 128;
   }
 
   protected UnloggedFailure die(String msg) {
@@ -378,6 +377,14 @@
     return new UnloggedFailure(1, "fatal: " + why.getMessage(), why);
   }
 
+  protected void writeError(String type, String msg) {
+    try {
+      err.write((type + ": " + msg + "\n").getBytes(ENC));
+    } catch (IOException e) {
+      // Ignored
+    }
+  }
+
   public void checkExclusivity(final Object arg1, final String arg1name,
       final Object arg2, final String arg2name) throws UnloggedFailure {
     if (arg1 != null && arg2 != null) {
@@ -388,18 +395,16 @@
 
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
-    private final Context context;
     private final String taskName;
     private Project.NameKey projectName;
 
     private TaskThunk(final CommandRunnable thunk) {
       this.thunk = thunk;
-      this.context = contextProvider.get();
 
       StringBuilder m = new StringBuilder();
       m.append(context.getCommandLine());
-      if (user.get().isIdentifiedUser()) {
-        IdentifiedUser u = user.get().asIdentifiedUser();
+      if (user.isIdentifiedUser()) {
+        IdentifiedUser u = user.asIdentifiedUser();
         m.append(" (").append(u.getAccount().getUserName()).append(")");
       }
       this.taskName = m.toString();
@@ -488,17 +493,17 @@
   }
 
   /** Runnable function which can throw an exception. */
-  public static interface CommandRunnable {
-    public void run() throws Exception;
+  public interface CommandRunnable {
+    void run() throws Exception;
   }
 
   /** Runnable function which can retrieve a project name related to the task */
-  public static interface ProjectCommandRunnable extends CommandRunnable {
+  public interface ProjectCommandRunnable extends CommandRunnable {
     // execute parser command before running, in order to be able to retrieve
     // project name
-    public void executeParseCommand() throws Exception;
+    void executeParseCommand() throws Exception;
 
-    public Project.NameKey getProjectName();
+    Project.NameKey getProjectName();
   }
 
   /** Thrown from {@link CommandRunnable#run()} with client message and code. */
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
index 0471af8..7623e50 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CachingPublicKeyAuthenticator.java
@@ -19,7 +19,7 @@
 
 @Singleton
 public class CachingPublicKeyAuthenticator
-    extends org.apache.sshd.server.auth.CachingPublicKeyAuthenticator {
+    extends org.apache.sshd.server.auth.pubkey.CachingPublicKeyAuthenticator {
 
   @Inject
   public CachingPublicKeyAuthenticator(DatabasePubKeyAuth authenticator) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
new file mode 100644
index 0000000..7986d76
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.base.Function;
+import com.google.common.base.Predicates;
+import com.google.common.collect.FluentIterable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class ChangeArgumentParser {
+  private final CurrentUser currentUser;
+  private final ChangesCollection changesCollection;
+  private final ChangeFinder changeFinder;
+  private final ReviewDb db;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+
+  @Inject
+  ChangeArgumentParser(CurrentUser currentUser,
+      ChangesCollection changesCollection,
+      ChangeFinder changeFinder,
+      ReviewDb db,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.GenericFactory changeControlFactory) {
+    this.currentUser = currentUser;
+    this.changesCollection = changesCollection;
+    this.changeFinder = changeFinder;
+    this.db = db;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeControlFactory = changeControlFactory;
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes)
+      throws UnloggedFailure, OrmException {
+    addChange(id, changes, null);
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes,
+      ProjectControl projectControl) throws UnloggedFailure, OrmException {
+    addChange(id, changes, projectControl, true);
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes,
+      ProjectControl projectControl, boolean useIndex) throws UnloggedFailure,
+      OrmException {
+    List<ChangeControl> matched =
+        useIndex ?
+            changeFinder.find(id, currentUser) :
+            changeFromNotesFactory(id, currentUser);
+    List<ChangeControl> toAdd = new ArrayList<>(changes.size());
+    boolean canMaintainServer =
+        currentUser.isIdentifiedUser()
+            && currentUser.asIdentifiedUser().getCapabilities().canMaintainServer();
+    for (ChangeControl ctl : matched) {
+      if (!changes.containsKey(ctl.getId())
+          && inProject(projectControl, ctl.getProject())
+          && (canMaintainServer || ctl.isVisible(db))) {
+        toAdd.add(ctl);
+      }
+    }
+
+    if (toAdd.isEmpty()) {
+      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
+    } else if (toAdd.size() > 1) {
+      throw new UnloggedFailure(1, "\"" + id + "\" matches multiple changes");
+    }
+    ChangeControl ctl = toAdd.get(0);
+    changes.put(ctl.getId(), changesCollection.parse(ctl));
+  }
+
+  private List<ChangeControl> changeFromNotesFactory(String id,
+      final CurrentUser currentUser) throws OrmException, UnloggedFailure {
+    List<ChangeNotes> changes =
+        changeNotesFactory.create(db, parseId(id));
+    return FluentIterable.from(changes)
+        .transform(new Function<ChangeNotes, ChangeControl>() {
+          @Override
+          public ChangeControl apply(ChangeNotes changeNote) {
+            return controlForChange(changeNote, currentUser);
+          }
+        }).filter(Predicates.notNull()).toList();
+  }
+
+  private List<Change.Id> parseId(String id) throws UnloggedFailure {
+    try {
+      return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+    } catch (NumberFormatException e) {
+      throw new UnloggedFailure(2, "Invalid change ID " + id, e);
+    }
+  }
+
+  private ChangeControl controlForChange(ChangeNotes change, CurrentUser user) {
+    try {
+      return changeControlFactory.controlFor(change, user);
+    } catch (NoSuchChangeException e) {
+      return null;
+    }
+  }
+
+  private boolean inProject(ProjectControl projectControl, Project project) {
+    if (projectControl != null) {
+      return projectControl.getProject().getNameKey().equals(project.getNameKey());
+    }
+
+    // No --project option, so they want every project.
+    return true;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
index f78aba4..88e1142 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -31,11 +32,13 @@
   private final WorkQueue.Executor batchExecutor;
 
   @Inject
-  public CommandExecutorQueueProvider(@GerritServerConfig final Config config,
-      final WorkQueue queues) {
-    final int cores = Runtime.getRuntime().availableProcessors();
-    poolSize = config.getInt("sshd", "threads", 3 * cores / 2);
-    batchThreads = config.getInt("sshd", "batchThreads", cores == 1 ? 1 : 2);
+  public CommandExecutorQueueProvider(
+      @GerritServerConfig Config config,
+      ThreadSettingsConfig threadsSettingsConfig,
+      WorkQueue queues) {
+    poolSize = threadsSettingsConfig.getSshdThreads();
+    batchThreads = config.getInt("sshd", "batchThreads",
+        threadsSettingsConfig.getSshdBatchTreads());
     if (batchThreads > poolSize) {
       poolSize += batchThreads;
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 6bd9c4c..ee4984b79 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -217,7 +217,7 @@
 
     private void log(final int rc) {
       if (logged.compareAndSet(false, true)) {
-        log.onExecute(cmd, rc);
+        log.onExecute(cmd, rc, ctx.getSession());
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
index 7fb9226..5b8f5fa 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
@@ -27,7 +27,7 @@
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
 public @interface CommandMetaData {
-  public enum Mode {
+  enum Mode {
     MASTER, MASTER_OR_SLAVE;
     public boolean isSupported(boolean slaveMode) {
       return this == MASTER_OR_SLAVE || !slaveMode;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 3ce4545..3adc8d1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -27,10 +27,10 @@
 import com.google.inject.Inject;
 
 import org.apache.commons.codec.binary.Base64;
-import org.apache.sshd.common.KeyPairProvider;
 import org.apache.sshd.common.SshException;
-import org.apache.sshd.common.util.Buffer;
-import org.apache.sshd.server.PublickeyAuthenticator;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
 import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
@@ -106,10 +106,9 @@
         PeerDaemonUser user = peerFactory.create(sd.getRemoteAddress());
         return SshUtil.success(username, session, sshScope, sshLog, sd, user);
 
-      } else {
-        sd.authenticationError(username, "no-matching-key");
-        return false;
       }
+      sd.authenticationError(username, "no-matching-key");
+      return false;
     }
 
     if (config.getBoolean("auth", "userNameToLowerCase", false)) {
@@ -195,7 +194,7 @@
 
           try {
             byte[] bin = Base64.decodeBase64(line.getBytes(ISO_8859_1));
-            keys.add(new Buffer(bin).getRawPublicKey());
+            keys.add(new ByteArrayBuffer(bin).getRawPublicKey());
           } catch (RuntimeException | SshException e) {
             logBadKey(path, line, e);
           }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index 09222b7..f2911dc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.apache.sshd.server.Command;
@@ -44,7 +44,7 @@
     DispatchCommand create(Map<String, CommandProvider> map);
   }
 
-  private final Provider<CurrentUser> currentUser;
+  private final CurrentUser currentUser;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
 
@@ -55,7 +55,7 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(final Provider<CurrentUser> cu,
+  DispatchCommand(CurrentUser cu,
       @Assisted final Map<String, CommandProvider> all) {
     currentUser = cu;
     commands = all;
@@ -73,7 +73,7 @@
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
         msg.write(usage());
-        throw new UnloggedFailure(1, msg.toString());
+        throw die(msg.toString());
       }
 
       final CommandProvider p = commands.get(commandName);
@@ -81,7 +81,7 @@
         String msg =
             (getName().isEmpty() ? "Gerrit Code Review" : getName()) + ": "
                 + commandName + ": not found";
-        throw new UnloggedFailure(1, msg);
+        throw die(msg);
       }
 
       final Command cmd = p.getProvider().get();
@@ -96,7 +96,7 @@
         bc.setArguments(args.toArray(new String[args.size()]));
 
       } else if (!args.isEmpty()) {
-        throw new UnloggedFailure(1, commandName + " does not take arguments");
+        throw die(commandName + " does not take arguments");
       }
 
       provideStateTo(cmd);
@@ -133,7 +133,12 @@
   public void destroy() {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
+      try {
         cmd.destroy();
+      } catch (Exception e) {
+        Throwables.propagateIfPossible(e);
+        throw new RuntimeException(e);
+      }
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
index 17db6b9..b331555 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 Goldman Sachs
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -19,11 +19,15 @@
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.session.ServerSession;
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Locale;
 
 /**
  * Authenticates users with kerberos (gssapi-with-mic).
@@ -34,14 +38,19 @@
   private final SshScope sshScope;
   private final SshLog sshLog;
   private final GenericFactory userFactory;
+  private final Config config;
 
   @Inject
-  GerritGSSAuthenticator(final AccountCache accounts, final SshScope sshScope,
-      final SshLog sshLog, final IdentifiedUser.GenericFactory userFactory) {
+  GerritGSSAuthenticator(AccountCache accounts,
+      SshScope sshScope,
+      SshLog sshLog,
+      IdentifiedUser.GenericFactory userFactory,
+      @GerritServerConfig Config config) {
     this.accounts = accounts;
     this.sshScope = sshScope;
     this.sshLog = sshLog;
     this.userFactory = userFactory;
+    this.config = config;
   }
 
   @Override
@@ -55,14 +64,16 @@
     } else {
       username = identity.substring(0, at);
     }
+    if (config.getBoolean("auth", "userNameToLowerCase", false)) {
+      username = username.toLowerCase(Locale.US);
+    }
     AccountState state = accounts.getByUsername(username);
     Account account = state == null ? null : state.getAccount();
     boolean active = account != null && account.isActive();
     if (active) {
       return SshUtil.success(username, session, sshScope, sshLog, sd,
           SshUtil.createUser(sd, userFactory, account.getId()));
-    } else {
-      return false;
     }
+    return false;
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritServerSession.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritServerSession.java
deleted file mode 100644
index b7f7c22..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritServerSession.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.future.SshFutureListener;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.server.ServerFactoryManager;
-import org.apache.sshd.server.session.ServerSession;
-
-/* Expose addition of close session listeners */
-class GerritServerSession extends ServerSession {
-
-  GerritServerSession(ServerFactoryManager server,
-      IoSession ioSession) throws Exception {
-    super(server, ioSession);
-  }
-
-  void addCloseSessionListener(SshFutureListener<CloseFuture> l) {
-    closeFuture.addListener(l);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
index 3e6e2f5..8190836 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -19,11 +19,12 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 
-import org.apache.sshd.common.KeyPairProvider;
-import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
+import org.apache.sshd.common.keyprovider.AbstractFileKeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.util.SecurityUtils;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
+import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -43,38 +44,37 @@
     Path rsaKey = site.ssh_rsa;
     Path dsaKey = site.ssh_dsa;
 
-    final List<String> stdKeys = new ArrayList<>(2);
+    final List<File> stdKeys = new ArrayList<>(2);
     if (Files.exists(rsaKey)) {
-      stdKeys.add(rsaKey.toAbsolutePath().toString());
+      stdKeys.add(rsaKey.toAbsolutePath().toFile());
     }
     if (Files.exists(dsaKey)) {
-      stdKeys.add(dsaKey.toAbsolutePath().toString());
+      stdKeys.add(dsaKey.toAbsolutePath().toFile());
     }
 
     if (Files.exists(objKey)) {
       if (stdKeys.isEmpty()) {
         SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
-        p.setPath(objKey.toAbsolutePath().toString());
+        p.setPath(objKey.toAbsolutePath());
         return p;
 
-      } else {
-        // Both formats of host key exist, we don't know which format
-        // should be authoritative. Complain and abort.
-        //
-        stdKeys.add(objKey.toAbsolutePath().toString());
-        throw new ProvisionException("Multiple host keys exist: " + stdKeys);
       }
+      // Both formats of host key exist, we don't know which format
+      // should be authoritative. Complain and abort.
+      //
+      stdKeys.add(objKey.toAbsolutePath().toFile());
+      throw new ProvisionException("Multiple host keys exist: " + stdKeys);
 
-    } else {
-      if (stdKeys.isEmpty()) {
-        throw new ProvisionException("No SSH keys under " + site.etc_dir);
-      }
-      if (!SecurityUtils.isBouncyCastleRegistered()) {
-        throw new ProvisionException("Bouncy Castle Crypto not installed;"
-            + " needed to read server host keys: " + stdKeys + "");
-      }
-      return new FileKeyPairProvider(stdKeys
-          .toArray(new String[stdKeys.size()]));
     }
+    if (stdKeys.isEmpty()) {
+      throw new ProvisionException("No SSH keys under " + site.etc_dir);
+    }
+    if (!SecurityUtils.isBouncyCastleRegistered()) {
+      throw new ProvisionException("Bouncy Castle Crypto not installed;"
+          + " needed to read server host keys: " + stdKeys + "");
+    }
+    AbstractFileKeyPairProvider kp = SecurityUtils.createFileKeyPairProvider();
+    kp.setFiles(stdKeys);
+    return kp;
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 8c43438a..8b468a7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.InvalidPluginException;
@@ -30,12 +29,13 @@
 import org.apache.sshd.server.Command;
 
 import java.lang.annotation.Annotation;
+import java.util.HashMap;
 import java.util.Map;
 
 class SshAutoRegisterModuleGenerator
     extends AbstractModule
     implements ModuleGenerator {
-  private final Map<String, Class<Command>> commands = Maps.newHashMap();
+  private final Map<String, Class<Command>> commands = new HashMap<>();
   private final Multimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
   private CommandName command;
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
index a8bc8dc..d0f54e6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
@@ -17,12 +17,16 @@
 import static com.google.gerrit.server.ssh.SshAddressesModule.IANA_SSH_PORT;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.sshd.common.channel.ChannelOutputStream.WAIT_FOR_SPACE_TIMEOUT;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
@@ -37,36 +41,14 @@
 import com.jcraft.jsch.JSchException;
 
 import org.apache.mina.transport.socket.SocketSessionConfig;
-import org.apache.sshd.SshServer;
-import org.apache.sshd.common.Channel;
-import org.apache.sshd.common.Cipher;
-import org.apache.sshd.common.Compression;
-import org.apache.sshd.common.ForwardingFilter;
-import org.apache.sshd.common.KeyExchange;
-import org.apache.sshd.common.KeyPairProvider;
+import org.apache.sshd.common.BaseBuilder;
 import org.apache.sshd.common.NamedFactory;
-import org.apache.sshd.common.Random;
-import org.apache.sshd.common.RequestHandler;
-import org.apache.sshd.common.Session;
-import org.apache.sshd.common.Signature;
-import org.apache.sshd.common.SshdSocketAddress;
-import org.apache.sshd.common.cipher.AES128CBC;
-import org.apache.sshd.common.cipher.AES128CTR;
-import org.apache.sshd.common.cipher.AES192CBC;
-import org.apache.sshd.common.cipher.AES256CBC;
-import org.apache.sshd.common.cipher.AES256CTR;
-import org.apache.sshd.common.cipher.ARCFOUR128;
-import org.apache.sshd.common.cipher.ARCFOUR256;
-import org.apache.sshd.common.cipher.BlowfishCBC;
-import org.apache.sshd.common.cipher.CipherNone;
-import org.apache.sshd.common.cipher.TripleDESCBC;
-import org.apache.sshd.common.compression.CompressionNone;
-import org.apache.sshd.common.compression.CompressionZlib;
+import org.apache.sshd.common.channel.RequestHandler;
+import org.apache.sshd.common.cipher.Cipher;
+import org.apache.sshd.common.compression.BuiltinCompressions;
+import org.apache.sshd.common.compression.Compression;
 import org.apache.sshd.common.file.FileSystemFactory;
-import org.apache.sshd.common.file.FileSystemView;
-import org.apache.sshd.common.file.SshFile;
 import org.apache.sshd.common.forward.DefaultTcpipForwarderFactory;
-import org.apache.sshd.common.forward.TcpipServerChannel;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.io.IoAcceptor;
@@ -75,36 +57,33 @@
 import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
 import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
-import org.apache.sshd.common.mac.HMACMD5;
-import org.apache.sshd.common.mac.HMACMD596;
-import org.apache.sshd.common.mac.HMACSHA1;
-import org.apache.sshd.common.mac.HMACSHA196;
-import org.apache.sshd.common.mac.HMACSHA256;
-import org.apache.sshd.common.mac.HMACSHA512;
-import org.apache.sshd.common.random.BouncyCastleRandom;
-import org.apache.sshd.common.random.JceRandom;
+import org.apache.sshd.common.kex.KeyExchange;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.mac.Mac;
+import org.apache.sshd.common.random.JceRandomFactory;
+import org.apache.sshd.common.random.Random;
 import org.apache.sshd.common.random.SingletonRandomFactory;
-import org.apache.sshd.common.session.AbstractSession;
 import org.apache.sshd.common.session.ConnectionService;
-import org.apache.sshd.common.signature.SignatureDSA;
-import org.apache.sshd.common.signature.SignatureECDSA;
-import org.apache.sshd.common.signature.SignatureRSA;
-import org.apache.sshd.common.util.Buffer;
+import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.SecurityUtils;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.CommandFactory;
-import org.apache.sshd.server.PublickeyAuthenticator;
-import org.apache.sshd.server.UserAuth;
-import org.apache.sshd.server.auth.UserAuthPublicKey;
+import org.apache.sshd.server.ServerBuilder;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.auth.UserAuth;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
-import org.apache.sshd.server.auth.gss.UserAuthGSS;
-import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
+import org.apache.sshd.server.auth.pubkey.UserAuthPublicKeyFactory;
+import org.apache.sshd.server.forward.ForwardingFilter;
 import org.apache.sshd.server.global.CancelTcpipForwardHandler;
 import org.apache.sshd.server.global.KeepAliveHandler;
 import org.apache.sshd.server.global.NoMoreSessionsHandler;
 import org.apache.sshd.server.global.TcpipForwardHandler;
-import org.apache.sshd.server.kex.DHG1;
-import org.apache.sshd.server.kex.DHG14;
+import org.apache.sshd.server.session.ServerSessionImpl;
 import org.apache.sshd.server.session.SessionFactory;
 import org.bouncycastle.crypto.prng.RandomGenerator;
 import org.bouncycastle.crypto.prng.VMPCRandomGenerator;
@@ -118,6 +97,13 @@
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.net.UnknownHostException;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.UserPrincipalLookupService;
+import java.nio.file.spi.FileSystemProvider;
 import java.security.InvalidKeyException;
 import java.security.KeyPair;
 import java.security.PublicKey;
@@ -126,8 +112,9 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * SSH daemon to communicate with Gerrit.
@@ -150,10 +137,10 @@
  */
 @Singleton
 public class SshDaemon extends SshServer implements SshInfo, LifecycleListener {
-  @SuppressWarnings("hiding") // Don't use AbstractCloseable's logger.
-  private static final Logger log = LoggerFactory.getLogger(SshDaemon.class);
+  private static final Logger sshDaemonLog =
+      LoggerFactory.getLogger(SshDaemon.class);
 
-  public static enum SshSessionBackend {
+  public enum SshSessionBackend {
     MINA,
     NIO2
   }
@@ -172,7 +159,8 @@
       final KeyPairProvider hostKeyProvider, final IdGenerator idGenerator,
       @GerritServerConfig final Config cfg, final SshLog sshLog,
       @SshListenAddresses final List<SocketAddress> listen,
-      @SshAdvertisedAddresses final List<String> advertised) {
+      @SshAdvertisedAddresses final List<String> advertised,
+      MetricMaker metricMaker) {
     setPort(IANA_SSH_PORT /* never used */);
 
     this.cfg = cfg;
@@ -207,6 +195,10 @@
     getProperties().put(REKEY_BYTES_LIMIT,
         String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
 
+    long waitTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "waitTimeout", 30, SECONDS);
+    getProperties()
+        .put(WAIT_FOR_SPACE_TIMEOUT, String.valueOf(SECONDS.toMillis(waitTimeoutSeconds)));
+
     final int maxConnectionsPerUser =
         cfg.getInt("sshd", "maxConnectionsPerUser", 64);
     if (0 < maxConnectionsPerUser) {
@@ -223,7 +215,7 @@
         "sshd", "enableCompression", false);
 
     SshSessionBackend backend = cfg.getEnum(
-        "sshd", null, "backend", SshSessionBackend.MINA);
+        "sshd", null, "backend", SshSessionBackend.NIO2);
 
     System.setProperty(IoServiceFactoryFactory.class.getName(),
         backend == SshSessionBackend.MINA
@@ -236,6 +228,7 @@
       initProviderJce();
     }
     initCiphers(cfg);
+    initKeyExchanges(cfg);
     initMacs(cfg);
     initSignatures();
     initChannels();
@@ -247,10 +240,39 @@
     setKeyPairProvider(hostKeyProvider);
     setCommandFactory(commandFactory);
     setShellFactory(noShell);
-    setSessionFactory(new SessionFactory() {
+
+    final AtomicInteger connected = new AtomicInteger();
+    metricMaker.newCallbackMetric(
+        "sshd/sessions/connected",
+        Integer.class,
+        new Description("Currently connected SSH sessions")
+          .setGauge()
+          .setUnit("sessions"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return connected.get();
+          }
+        });
+
+    final Counter0 sessionsCreated = metricMaker.newCounter(
+        "sshd/sessions/created",
+        new Description("Rate of new SSH sessions")
+          .setRate()
+          .setUnit("sessions"));
+
+    final Counter0 authFailures = metricMaker.newCounter(
+        "sshd/sessions/authentication_failures",
+        new Description("Rate of SSH authentication failures")
+          .setRate()
+          .setUnit("failures"));
+
+    setSessionFactory(new SessionFactory(this) {
       @Override
-      protected AbstractSession createSession(final IoSession io)
+      protected ServerSessionImpl createSession(final IoSession io)
           throws Exception {
+        connected.incrementAndGet();
+        sessionsCreated.increment();
         if (io instanceof MinaSession) {
           if (((MinaSession) io).getSession()
               .getConfig() instanceof SocketSessionConfig) {
@@ -260,7 +282,7 @@
           }
         }
 
-        GerritServerSession s = (GerritServerSession)super.createSession(io);
+        ServerSessionImpl s = super.createSession(io);
         int id = idGenerator.next();
         SocketAddress peer = io.getRemoteAddress();
         final SshSession sd = new SshSession(id, peer);
@@ -268,10 +290,12 @@
 
         // Log a session close without authentication as a failure.
         //
-        s.addCloseSessionListener(new SshFutureListener<CloseFuture>() {
+        s.addCloseFutureListener(new SshFutureListener<CloseFuture>() {
           @Override
           public void operationComplete(CloseFuture future) {
+            connected.decrementAndGet();
             if (sd.isAuthenticationError()) {
+              authFailures.increment();
               sshLog.onAuthFail(sd);
             }
           }
@@ -280,9 +304,9 @@
       }
 
       @Override
-      protected AbstractSession doCreateSession(IoSession ioSession)
+      protected ServerSessionImpl doCreateSession(IoSession ioSession)
           throws Exception {
-        return new GerritServerSession(server, ioSession);
+        return new ServerSessionImpl(getServer(), ioSession);
       }
     });
     setGlobalRequestHandlers(Arrays.<RequestHandler<ConnectionService>> asList(
@@ -308,11 +332,10 @@
   public synchronized void start() {
     if (daemonAcceptor == null && !listen.isEmpty()) {
       checkConfig();
-      if (sessionFactory == null) {
-        sessionFactory = createSessionFactory();
+      if (getSessionFactory() == null) {
+        setSessionFactory(createSessionFactory());
       }
-      sessionFactory.setServer(this);
-      setupSessionTimeout(sessionFactory);
+      setupSessionTimeout(getSessionFactory());
       daemonAcceptor = createAcceptor();
 
       try {
@@ -328,8 +351,8 @@
         throw new IllegalStateException("Cannot bind to " + addressList(), e);
       }
 
-      log.info(String.format("Started Gerrit %s on %s",
-          version, addressList()));
+      sshDaemonLog.info(String.format("Started Gerrit %s on %s",
+          getVersion(), addressList()));
     }
   }
 
@@ -342,9 +365,9 @@
     if (daemonAcceptor != null) {
       try {
         daemonAcceptor.close(true).await();
-        log.info("Stopped Gerrit SSHD");
-      } catch (InterruptedException e) {
-        log.warn("Exception caught while closing", e);
+        sshDaemonLog.info("Stopped Gerrit SSHD");
+      } catch (IOException e) {
+        sshDaemonLog.warn("Exception caught while closing", e);
       } finally {
         daemonAcceptor = null;
       }
@@ -367,7 +390,7 @@
     final List<PublicKey> keys = myHostKeys();
     final List<HostKey> r = new ArrayList<>();
     for (final PublicKey pub : keys) {
-      final Buffer buf = new Buffer();
+      final Buffer buf = new ByteArrayBuffer();
       buf.putRawPublicKey(pub);
       final byte[] keyBin = buf.getCompactData();
 
@@ -375,7 +398,7 @@
         try {
           r.add(new HostKey(addr, keyBin));
         } catch (JSchException e) {
-          log.warn("Cannot format SSHD host key", e);
+          sshDaemonLog.warn("Cannot format SSHD host key", e);
         }
       }
     }
@@ -409,14 +432,20 @@
     return r.toString();
   }
 
+  @SuppressWarnings("unchecked")
+  private void initKeyExchanges(Config cfg) {
+    List<NamedFactory<KeyExchange>> a =
+        ServerBuilder.setUpDefaultKeyExchanges(true);
+    setKeyExchangeFactories(filter(cfg, "kex",
+        (NamedFactory<KeyExchange>[])a.toArray(new NamedFactory[a.size()])));
+  }
+
   private void initProviderBouncyCastle(Config cfg) {
-    setKeyExchangeFactories(Arrays.<NamedFactory<KeyExchange>> asList(
-        new DHG14.Factory(), new DHG1.Factory()));
     NamedFactory<Random> factory;
     if (cfg.getBoolean("sshd", null, "testUseInsecureRandom", false)) {
       factory = new InsecureBouncyCastleRandom.Factory();
     } else {
-      factory = new BouncyCastleRandom.Factory();
+      factory = SecurityUtils.getRandomFactory();
     }
     setRandomFactory(new SingletonRandomFactory(factory));
   }
@@ -442,11 +471,21 @@
     }
 
     @Override
+    public String getName() {
+      return "InsecureBouncyCastleRandom";
+    }
+
+    @Override
     public void fill(byte[] bytes, int start, int len) {
       random.nextBytes(bytes, start, len);
     }
 
     @Override
+    public void fill(byte[] bytes) {
+      random.nextBytes(bytes);
+    }
+
+    @Override
     public int random(int n) {
       if (n > 0) {
         if ((n & -n) == n) {
@@ -457,42 +496,31 @@
         do {
           bits = next(31);
           val = bits % n;
-        } while (bits - val + (n-1) < 0);
+        } while (bits - val + (n - 1) < 0);
         return val;
       }
       throw new IllegalArgumentException();
     }
 
     protected final int next(int numBits) {
-      int bytes = (numBits+7)/8;
+      int bytes = (numBits + 7) / 8;
       byte[] next = new byte[bytes];
       int ret = 0;
       random.nextBytes(next);
       for (int i = 0; i < bytes; i++) {
         ret = (next[i] & 0xFF) | (ret << 8);
       }
-      return ret >>> (bytes*8 - numBits);
+      return ret >>> (bytes * 8 - numBits);
     }
   }
 
   private void initProviderJce() {
-    setKeyExchangeFactories(Arrays
-        .<NamedFactory<KeyExchange>> asList(new DHG1.Factory()));
-    setRandomFactory(new SingletonRandomFactory(new JceRandom.Factory()));
+    setRandomFactory(new SingletonRandomFactory(JceRandomFactory.INSTANCE));
   }
 
   @SuppressWarnings("unchecked")
   private void initCiphers(final Config cfg) {
-    final List<NamedFactory<Cipher>> a = new LinkedList<>();
-    a.add(new AES128CBC.Factory());
-    a.add(new TripleDESCBC.Factory());
-    a.add(new BlowfishCBC.Factory());
-    a.add(new AES192CBC.Factory());
-    a.add(new AES256CBC.Factory());
-    a.add(new AES128CTR.Factory());
-    a.add(new AES256CTR.Factory());
-    a.add(new ARCFOUR256.Factory());
-    a.add(new ARCFOUR128.Factory());
+    final List<NamedFactory<Cipher>> a = BaseBuilder.setUpDefaultCiphers(true);
 
     for (Iterator<NamedFactory<Cipher>> i = a.iterator(); i.hasNext();) {
       final NamedFactory<Cipher> f = i.next();
@@ -502,29 +530,25 @@
         final byte[] iv = new byte[c.getIVSize()];
         c.init(Cipher.Mode.Encrypt, key, iv);
       } catch (InvalidKeyException e) {
-        log.warn("Disabling cipher " + f.getName() + ": " + e.getMessage()
+        sshDaemonLog.warn("Disabling cipher " + f.getName() + ": " + e.getMessage()
             + "; try installing unlimited cryptography extension");
         i.remove();
       } catch (Exception e) {
-        log.warn("Disabling cipher " + f.getName() + ": " + e.getMessage());
+        sshDaemonLog.warn("Disabling cipher " + f.getName() + ": " + e.getMessage());
         i.remove();
       }
     }
 
     a.add(null);
-    a.add(new CipherNone.Factory());
     setCipherFactories(filter(cfg, "cipher",
         (NamedFactory<Cipher>[])a.toArray(new NamedFactory[a.size()])));
   }
 
-  private void initMacs(final Config cfg) {
+  @SuppressWarnings("unchecked")
+  private void initMacs(Config cfg) {
+    List<NamedFactory<Mac>> m = BaseBuilder.setUpDefaultMacs(true);
     setMacFactories(filter(cfg, "mac",
-        new HMACMD5.Factory(),
-        new HMACSHA1.Factory(),
-        new HMACMD596.Factory(),
-        new HMACSHA196.Factory(),
-        new HMACSHA256.Factory(),
-        new HMACSHA512.Factory()));
+       (NamedFactory<Mac>[]) m.toArray(new NamedFactory[m.size()])));
   }
 
   @SafeVarargs
@@ -572,7 +596,7 @@
           msg.append(avail[i].getName());
         }
         msg.append(" is supported");
-        log.error(msg.toString());
+        sshDaemonLog.error(msg.toString());
       } else if (add) {
         if (!def.contains(n)) {
           def.add(n);
@@ -597,20 +621,14 @@
   }
 
   private void initSignatures() {
-    setSignatureFactories(Arrays.<NamedFactory<Signature>> asList(
-        new SignatureDSA.Factory(),
-        new SignatureRSA.Factory(),
-        new SignatureECDSA.NISTP256Factory(),
-        new SignatureECDSA.NISTP384Factory(),
-        new SignatureECDSA.NISTP521Factory()));
+    setSignatureFactories(BaseBuilder.setUpDefaultSignatures(true));
   }
 
   private void initCompression(boolean enableCompression) {
-    List<NamedFactory<Compression>> compressionFactories =
-        Lists.newArrayList();
+    List<NamedFactory<Compression>> compressionFactories = new ArrayList<>();
 
     // Always support no compression over SSHD.
-    compressionFactories.add(new CompressionNone.Factory());
+    compressionFactories.add(BuiltinCompressions.none);
 
     // In the general case, we want to disable transparent compression, since
     // the majority of our data transfer is highly compressed Git pack files
@@ -625,17 +643,14 @@
     // receive-packs.
 
     if (enableCompression) {
-      compressionFactories.add(new CompressionZlib.Factory());
+      compressionFactories.add(BuiltinCompressions.zlib);
     }
 
     setCompressionFactories(compressionFactories);
   }
 
   private void initChannels() {
-    setChannelFactories(Arrays.<NamedFactory<Channel>> asList(
-        new ChannelSession.Factory(), //
-        new TcpipServerChannel.DirectTcpipFactory() //
-        ));
+    setChannelFactories(ServerBuilder.DEFAULT_CHANNEL_FACTORIES);
   }
 
   private void initSubsystems() {
@@ -645,12 +660,12 @@
   private void initUserAuth(final PublickeyAuthenticator pubkey,
       final GSSAuthenticator kerberosAuthenticator,
       String kerberosKeytab, String kerberosPrincipal) {
-    List<NamedFactory<UserAuth>> authFactories = Lists.newArrayList();
+    List<NamedFactory<UserAuth>> authFactories = new ArrayList<>();
     if (kerberosKeytab != null) {
-      authFactories.add(new UserAuthGSS.Factory());
+      authFactories.add(UserAuthGSSFactory.INSTANCE);
       log.info("Enabling kerberos with keytab " + kerberosKeytab);
       if (!new File(kerberosKeytab).canRead()) {
-        log.error("Keytab " + kerberosKeytab +
+        sshDaemonLog.error("Keytab " + kerberosKeytab +
             " does not exist or is not readable; further errors are possible");
       }
       kerberosAuthenticator.setKeytabFile(kerberosKeytab);
@@ -658,19 +673,19 @@
         try {
           kerberosPrincipal = "host/" +
               InetAddress.getLocalHost().getCanonicalHostName();
-        } catch(UnknownHostException e) {
+        } catch (UnknownHostException e) {
           kerberosPrincipal = "host/localhost";
         }
       }
-      log.info("Using kerberos principal " + kerberosPrincipal);
+      sshDaemonLog.info("Using kerberos principal " + kerberosPrincipal);
       if (!kerberosPrincipal.startsWith("host/")) {
-        log.warn("Host principal does not start with host/ " +
+        sshDaemonLog.warn("Host principal does not start with host/ " +
             "which most SSH clients will supply automatically");
       }
       kerberosAuthenticator.setServicePrincipalName(kerberosPrincipal);
       setGSSAuthenticator(kerberosAuthenticator);
     }
-    authFactories.add(new UserAuthPublicKey.Factory());
+    authFactories.add(UserAuthPublicKeyFactory.INSTANCE);
     setUserAuthFactories(authFactories);
     setPublickeyAuthenticator(pubkey);
   }
@@ -693,7 +708,7 @@
       }
 
       @Override
-      public boolean canConnect(SshdSocketAddress address, Session session) {
+      public boolean canConnect(Type type, SshdSocketAddress address, Session session) {
           return false;
       }
     });
@@ -703,22 +718,66 @@
   private void initFileSystemFactory() {
     setFileSystemFactory(new FileSystemFactory() {
       @Override
-      public FileSystemView createFileSystemView(Session session)
+      public FileSystem createFileSystem(Session session)
           throws IOException {
-        return new FileSystemView() {
+        return new FileSystem() {
           @Override
-          public SshFile getFile(SshFile baseDir, String file) {
+          public void close() throws IOException {
+          }
+
+          @Override
+          public Iterable<FileStore> getFileStores() {
             return null;
           }
 
           @Override
-          public SshFile getFile(String file) {
+          public Path getPath(String arg0, String... arg1) {
             return null;
           }
 
           @Override
-          public FileSystemView getNormalizedView() {
-            return this;
+          public PathMatcher getPathMatcher(String arg0) {
+            return null;
+          }
+
+          @Override
+          public Iterable<Path> getRootDirectories() {
+            return null;
+          }
+
+          @Override
+          public String getSeparator() {
+            return null;
+          }
+
+          @Override
+          public UserPrincipalLookupService getUserPrincipalLookupService() {
+            return null;
+          }
+
+          @Override
+          public boolean isOpen() {
+            return false;
+          }
+
+          @Override
+          public boolean isReadOnly() {
+            return false;
+          }
+
+          @Override
+          public WatchService newWatchService() throws IOException {
+            return null;
+          }
+
+          @Override
+          public FileSystemProvider provider() {
+            return null;
+          }
+
+          @Override
+          public Set<String> supportedFileAttributeViews() {
+            return null;
           }
         };
       }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
index fdf34a2..2f88fa9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
@@ -18,7 +18,7 @@
 
 import com.google.inject.AbstractModule;
 
-import org.apache.sshd.common.KeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
 
 public class SshHostKeyModule extends AbstractModule {
   @Override
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 4654069..bf3e6bc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -18,13 +18,13 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.ssh.SshKeyCreator;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -32,12 +32,11 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.spec.InvalidKeySpecException;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -64,6 +63,7 @@
           .loader(Loader.class);
         bind(SshKeyCacheImpl.class);
         bind(SshKeyCache.class).to(SshKeyCacheImpl.class);
+        bind(SshKeyCreator.class).to(SshKeyCreatorImpl.class);
       }
     };
   }
@@ -97,48 +97,34 @@
     }
   }
 
-  @Override
-  public AccountSshKey create(AccountSshKey.Id id, String encoded)
-      throws InvalidSshKeyException {
-    try {
-      final AccountSshKey key =
-          new AccountSshKey(id, SshUtil.toOpenSshPublicKey(encoded));
-      SshUtil.parse(key);
-      return key;
-    } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
-      throw new InvalidSshKeyException();
-
-    } catch (NoSuchProviderException e) {
-      log.error("Cannot parse SSH key", e);
-      throw new InvalidSshKeyException();
-    }
-  }
-
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema) {
+    Loader(SchemaFactory<ReviewDb> schema,
+        VersionedAuthorizedKeys.Accessor authorizedKeys) {
       this.schema = schema;
+      this.authorizedKeys = authorizedKeys;
     }
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        final AccountExternalId.Key key =
+        AccountExternalId.Key key =
             new AccountExternalId.Key(SCHEME_USERNAME, username);
-        final AccountExternalId user = db.accountExternalIds().get(key);
+        AccountExternalId user = db.accountExternalIds().get(key);
         if (user == null) {
           return NO_SUCH_USER;
         }
 
-        final List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-        for (AccountSshKey k : db.accountSshKeys().byAccount(
-            user.getAccountId())) {
+        List<SshKeyCacheEntry> kl = new ArrayList<>(4);
+        for (AccountSshKey k : authorizedKeys.getKeys(user.getAccountId())) {
           if (k.isValid()) {
-            add(db, kl, k);
+            add(kl, k);
           }
         }
+
         if (kl.isEmpty()) {
           return NO_KEYS;
         }
@@ -146,7 +132,7 @@
       }
     }
 
-    private void add(ReviewDb db, List<SshKeyCacheEntry> kl, AccountSshKey k) {
+    private void add(List<SshKeyCacheEntry> kl, AccountSshKey k) {
       try {
         kl.add(new SshKeyCacheEntry(k.getKey(), SshUtil.parse(k)));
       } catch (OutOfMemoryError e) {
@@ -155,16 +141,16 @@
         //
         throw e;
       } catch (Throwable e) {
-        markInvalid(db, k);
+        markInvalid(k);
       }
     }
 
-    private void markInvalid(final ReviewDb db, final AccountSshKey k) {
+    private void markInvalid(AccountSshKey k) {
       try {
         log.info("Flagging SSH key " + k.getKey() + " invalid");
+        authorizedKeys.markKeyInvalid(k.getAccount(), k.getKey().get());
         k.setInvalid();
-        db.accountSshKeys().update(Collections.singleton(k));
-      } catch (OrmException e) {
+      } catch (IOException | ConfigInvalidException e) {
         log.error("Failed to mark SSH key" + k.getKey() + " invalid", e);
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
new file mode 100644
index 0000000..0fd6db4
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.ssh.SshKeyCreator;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.spec.InvalidKeySpecException;
+
+public class SshKeyCreatorImpl implements SshKeyCreator {
+  private static final Logger log =
+      LoggerFactory.getLogger(SshKeyCreatorImpl.class);
+
+  @Override
+  public AccountSshKey create(AccountSshKey.Id id, String encoded)
+      throws InvalidSshKeyException {
+    try {
+      AccountSshKey key =
+          new AccountSshKey(id, SshUtil.toOpenSshPublicKey(encoded));
+      SshUtil.parse(key);
+      return key;
+    } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+      throw new InvalidSshKeyException();
+
+    } catch (NoSuchProviderException e) {
+      log.error("Cannot parse SSH key", e);
+      throw new InvalidSshKeyException();
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
index c2b6a16..fed8226 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
@@ -47,6 +47,7 @@
   private static final String P_WAIT = "queueWaitTime";
   private static final String P_EXEC = "executionTime";
   private static final String P_STATUS = "status";
+  private static final String P_AGENT = "agent";
 
   private final Provider<SshSession> session;
   private final Provider<Context> context;
@@ -115,7 +116,7 @@
     audit(null, "FAIL", "AUTH");
   }
 
-  void onExecute(DispatchCommand dcmd, int exitValue) {
+  void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession) {
     final Context ctx = context.get();
     ctx.finished = TimeUtil.nowMs();
 
@@ -144,6 +145,10 @@
         break;
     }
     event.setProperty(P_STATUS, status);
+    String peerAgent = sshSession.getPeerAgent();
+    if (peerAgent != null) {
+      event.setProperty(P_AGENT, peerAgent);
+    }
 
     if (async != null) {
       async.append(event);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
index 2622fbd..541081e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -30,6 +30,7 @@
   private static final String P_WAIT = "queueWaitTime";
   private static final String P_EXEC = "executionTime";
   private static final String P_STATUS = "status";
+  private static final String P_AGENT = "agent";
 
   private final Calendar calendar;
   private long lastTimeMillis;
@@ -63,6 +64,7 @@
     opt(P_WAIT, buf, event);
     opt(P_EXEC, buf, event);
     opt(P_STATUS, buf, event);
+    opt(P_AGENT, buf, event);
 
     buf.append('\n');
     return buf.toString();
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index 7dd12b0..3429587 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 import static com.google.inject.Scopes.SINGLETON;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
@@ -37,11 +36,12 @@
 import com.google.inject.servlet.RequestScoped;
 
 import org.apache.sshd.server.CommandFactory;
-import org.apache.sshd.server.PublickeyAuthenticator;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
 import org.eclipse.jgit.lib.Config;
 
 import java.net.SocketAddress;
+import java.util.HashMap;
 import java.util.Map;
 
 /** Configures standard dependencies for {@link SshDaemon}. */
@@ -50,7 +50,7 @@
 
   @Inject
   SshModule(@GerritServerConfig Config cfg) {
-    aliases = Maps.newHashMap();
+    aliases = new HashMap<>();
     for (String name : cfg.getNames("ssh-alias", true)) {
       aliases.put(name, cfg.getString("ssh-alias", null, name));
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
index e3455e3..9616aec 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -32,6 +31,7 @@
 import com.google.inject.Scope;
 import com.google.inject.util.Providers;
 
+import java.util.HashMap;
 import java.util.Map;
 
 /** Guice scopes for state during an SSH connection. */
@@ -44,7 +44,7 @@
 
   class Context implements RequestContext {
     private final RequestCleanup cleanup = new RequestCleanup();
-    private final Map<Key<?>, Object> map = Maps.newHashMap();
+    private final Map<Key<?>, Object> map = new HashMap<>();
     private final SchemaFactory<ReviewDb> schemaFactory;
     private final SshSession session;
     private final String commandLine;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
index ff160e0..17c330f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 
-import org.apache.sshd.common.Session.AttributeKey;
+import org.apache.sshd.common.AttributeStore.AttributeKey;
 
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -35,6 +35,7 @@
   private volatile CurrentUser identity;
   private volatile String username;
   private volatile String authError;
+  private volatile String peerAgent;
 
   SshSession(final int sessionId, SocketAddress peer) {
     this.sessionId = sessionId;
@@ -72,6 +73,14 @@
     return remoteAsString;
   }
 
+  public String getPeerAgent() {
+    return peerAgent;
+  }
+
+  public void setPeerAgent(String agent) {
+    peerAgent = agent;
+  }
+
   String getUsername() {
     return username;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
index cca426d..c2c07e1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
@@ -21,11 +21,11 @@
 import com.google.gerrit.sshd.SshScope.Context;
 
 import org.apache.commons.codec.binary.Base64;
-import org.apache.sshd.common.KeyPairProvider;
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
-import org.apache.sshd.common.util.Buffer;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.lib.Constants;
 
@@ -59,7 +59,7 @@
         throw new InvalidKeySpecException("No key string");
       }
       final byte[] bin = Base64.decodeBase64(Constants.encodeASCII(s));
-      return new Buffer(bin).getRawPublicKey();
+      return new ByteArrayBuffer(bin).getRawPublicKey();
     } catch (RuntimeException | SshException e) {
       throw new InvalidKeySpecException("Cannot parse key", e);
     }
@@ -96,7 +96,7 @@
       }
 
       final PublicKey key =
-          new Buffer(Base64.decodeBase64(Constants.encodeASCII(strBuf
+          new ByteArrayBuffer(Base64.decodeBase64(Constants.encodeASCII(strBuf
               .toString()))).getRawPublicKey();
       if (key instanceof RSAPublicKey) {
         strBuf.insert(0, KeyPairProvider.SSH_RSA + " ");
@@ -136,8 +136,7 @@
         sshScope.set(old);
       }
 
-      GerritServerSession s = (GerritServerSession) session;
-      s.addCloseSessionListener(
+      session.addCloseFutureListener(
           new SshFutureListener<CloseFuture>() {
             @Override
             public void operationComplete(CloseFuture future) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index c417f0a..24bd8c2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
@@ -24,7 +25,6 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -49,8 +49,8 @@
   private final DispatchCommandProvider dispatcher;
 
   private boolean enableRunAs;
-  private Provider<CurrentUser> caller;
-  private Provider<SshSession> session;
+  private CurrentUser caller;
+  private SshSession session;
   private IdentifiedUser.GenericFactory userFactory;
   private SshScope.Context callingContext;
 
@@ -68,7 +68,8 @@
   @Inject
   SuExec(final SshScope sshScope,
       @CommandName(Commands.ROOT) final DispatchCommandProvider dispatcher,
-      final Provider<CurrentUser> caller, final Provider<SshSession> session,
+      final CurrentUser caller,
+      final SshSession session,
       final IdentifiedUser.GenericFactory userFactory,
       final SshScope.Context callingContext,
       AuthConfig config) {
@@ -111,29 +112,27 @@
   }
 
   private void checkCanRunAs() throws UnloggedFailure {
-    if (caller.get() instanceof PeerDaemonUser) {
+    if (caller instanceof PeerDaemonUser) {
       // OK.
     } else if (!enableRunAs) {
-      throw new UnloggedFailure(1,
-          "fatal: suexec disabled by auth.enableRunAs = false");
-    } else if (!caller.get().getCapabilities().canRunAs()) {
-      throw new UnloggedFailure(1, "fatal: suexec not permitted");
+      throw die("suexec disabled by auth.enableRunAs = false");
+    } else if (!caller.getCapabilities().canRunAs()) {
+      throw die("suexec not permitted");
     }
   }
 
   private SshSession newSession() {
     final SocketAddress peer;
     if (peerAddress == null) {
-      peer = session.get().getRemoteAddress();
+      peer = session.getRemoteAddress();
     } else {
       peer = peerAddress;
     }
-    CurrentUser self = caller.get();
-    if (self instanceof PeerDaemonUser) {
-      self = null;
+    if (caller instanceof PeerDaemonUser) {
+      caller = null;
     }
-    return new SshSession(session.get(), peer,
-        userFactory.runAs(peer, accountId, self));
+    return new SshSession(session, peer,
+        userFactory.runAs(peer, accountId, caller));
   }
 
   private static String join(List<String> args) {
@@ -151,7 +150,12 @@
   public void destroy() {
     Command cmd = atomicCmd.getAndSet(null);
     if (cmd != null) {
+      try {
         cmd.destroy();
+      } catch (Exception e) {
+        Throwables.propagateIfPossible(e);
+        throw new RuntimeException(e);
+      }
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index 0ff3a01..237d844 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -46,16 +46,16 @@
   protected void run() throws Failure {
     try {
       checkPermission();
-
-      final QueryShell shell = factory.create(in, out);
-      shell.setOutputFormat(format);
-      if (query != null) {
-        shell.execute(query);
-      } else {
-        shell.run();
-      }
     } catch (PermissionDeniedException err) {
-      throw new UnloggedFailure("fatal: " + err.getMessage());
+      throw die(err.getMessage());
+    }
+
+    QueryShell shell = factory.create(in, out);
+    shell.setOutputFormat(format);
+    if (query != null) {
+      shell.execute(query);
+    } else {
+      shell.run();
     }
   }
 
@@ -65,7 +65,8 @@
    * As the @RequireCapability guards at various entry points of internal
    * commands implicitly add administrators (which we want to avoid), we also
    * check permissions within QueryShell and grant access only to those who
-   * canPerformRawQuery, regardless of whether they are administrators or not.
+   * can access the database, regardless of whether they are administrators or
+   * not.
    *
    * @throws PermissionDeniedException
    */
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index 6ed70a2..eb0d7b2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -33,7 +32,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -79,19 +77,18 @@
   private AllProjectsName allProjectsName;
 
   @Inject
-  private Provider<ListChildProjects> listChildProjects;
+  private ListChildProjects listChildProjects;
 
-  private Project.NameKey newParentKey = null;
+  private Project.NameKey newParentKey;
 
   @Override
   protected void run() throws Failure {
     if (oldParent == null && children.isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: child projects have to be specified as " +
-                                   "arguments or the --children-of option has to be set");
+      throw die("child projects have to be specified as " +
+          "arguments or the --children-of option has to be set");
     }
     if (oldParent == null && !excludedChildren.isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: --exclude can only be used together " +
-                                   "with --children-of");
+      throw die("--exclude can only be used together with --children-of");
     }
 
     final StringBuilder err = new StringBuilder();
@@ -116,7 +113,7 @@
       }
     }
 
-    final List<Project.NameKey> childProjects = Lists.newArrayList();
+    final List<Project.NameKey> childProjects = new ArrayList<>();
     for (final ProjectControl pc : children) {
       childProjects.add(pc.getProject().getNameKey());
     }
@@ -145,17 +142,12 @@
         continue;
       }
 
-      try {
-        MetaDataUpdate md = metaDataUpdateFactory.create(nameKey);
-        try {
-          ProjectConfig config = ProjectConfig.read(md);
-          config.getProject().setParentName(newParentKey);
-          md.setMessage("Inherit access from "
-              + (newParentKey != null ? newParentKey.get() : allProjectsName.get()) + "\n");
-          config.commit(md);
-        } finally {
-          md.close();
-        }
+      try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
+        ProjectConfig config = ProjectConfig.read(md);
+        config.getProject().setParentName(newParentKey);
+        md.setMessage("Inherit access from "
+            + (newParentKey != null ? newParentKey.get() : allProjectsName.get()) + "\n");
+        config.commit(md);
       } catch (RepositoryNotFoundException notFound) {
         err.append("error: Project ").append(name).append(" not found\n");
       } catch (IOException | ConfigInvalidException e) {
@@ -171,7 +163,7 @@
       while (err.charAt(err.length() - 1) == '\n') {
         err.setLength(err.length() - 1);
       }
-      throw new UnloggedFailure(1, err.toString());
+      throw die(err.toString());
     }
   }
 
@@ -181,7 +173,7 @@
    * that were specified to be excluded from reparenting.
    */
   private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent) {
-    final List<Project.NameKey> childProjects = Lists.newArrayList();
+    final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded =
         new ArrayList<>(excludedChildren.size());
     for (final ProjectControl excludedChild : excludedChildren) {
@@ -192,7 +184,7 @@
     if (newParentKey != null) {
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
-    for (final ProjectInfo child : listChildProjects.get().apply(
+    for (final ProjectInfo child : listChildProjects.apply(
         new ProjectResource(parent))) {
       final Project.NameKey childName = new Project.NameKey(child.name);
       if (!excluded.contains(childName)) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
index 59892a3..fb961d8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
@@ -123,6 +123,7 @@
   public static class Handler extends OneArgumentOptionHandler<Short> {
     private final ApproveOption cmdOption;
 
+    // CS IGNORE RedundantModifier FOR NEXT 1 LINES. REASON: needed by org.kohsuke.args4j.Option
     public Handler(final CmdLineParser parser, final OptionDef option,
         final Setter<Short> setter) {
       super(parser, option, setter);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
index 0aa12c4..12f69ed 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -36,7 +36,7 @@
   @Inject
   @CanonicalWebUrl String url;
 
-  @Argument(index=0, required = true, metaVar = "QUERY")
+  @Argument(index = 0, required = true, metaVar = "QUERY")
   private String q;
 
   @Override
@@ -48,7 +48,7 @@
             docResult.url));
       }
     } catch (DocQueryException dqe) {
-      throw new UnloggedFailure(1, "fatal: " + dqe.getMessage());
+      throw die(dqe);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index 5eda57c..9f31ddc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
+import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -21,8 +23,6 @@
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Revisions;
-import com.google.gerrit.server.change.TestSubmitRule.Filters;
-import com.google.gerrit.server.change.TestSubmitRule.Input;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 
@@ -34,7 +34,7 @@
 import java.nio.ByteBuffer;
 
 abstract class BaseTestPrologCommand extends SshCommand {
-  private Input input = new Input();
+  private TestSubmitRuleInput input = new TestSubmitRuleInput();
 
   @Inject
   private ChangesCollection changes;
@@ -55,7 +55,7 @@
     input.filters = no ? Filters.SKIP : Filters.RUN;
   }
 
-  protected abstract RestModifyView<RevisionResource, Input> createView();
+  protected abstract RestModifyView<RevisionResource, TestSubmitRuleInput> createView();
 
   @Override
   protected final void run() throws UnloggedFailure {
@@ -76,7 +76,7 @@
       OutputFormat.JSON.newGson().toJson(result, stdout);
       stdout.print('\n');
     } catch (Exception e) {
-      throw new UnloggedFailure("Processing of prolog script failed: " + e);
+      throw die("Processing of prolog script failed: " + e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
index af772f8..83b9745 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -28,12 +28,13 @@
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -67,8 +68,7 @@
       boolean connectionFound = false;
       int id = (int) Long.parseLong(sessionId, 16);
       for (IoSession io : acceptor.getManagedSessions().values()) {
-        ServerSession serverSession =
-            (ServerSession) ServerSession.getSession(io, true);
+        AbstractSession serverSession = AbstractSession.getSession(io, true);
         SshSession sshSession =
             serverSession != null
                 ? serverSession.getAttribute(SshSession.KEY)
@@ -81,7 +81,7 @@
             try {
               future.await();
               stdout.println("closed connection " + sessionId);
-            } catch (InterruptedException e) {
+            } catch (IOException e) {
               log.warn("Wait for connection to close interrupted: "
                   + e.getMessage());
             }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CommandUtils.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CommandUtils.java
deleted file mode 100644
index 1e89986..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CommandUtils.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.HashSet;
-import java.util.Set;
-
-public class CommandUtils {
-  public static PatchSet parsePatchSet(final String patchIdentity, ReviewDb db,
-      ProjectControl projectControl, String branch)
-      throws UnloggedFailure, OrmException {
-    // By commit?
-    //
-    if (patchIdentity.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
-      final RevId id = new RevId(patchIdentity);
-      final ResultSet<PatchSet> patches;
-      if (id.isComplete()) {
-        patches = db.patchSets().byRevision(id);
-      } else {
-        patches = db.patchSets().byRevisionRange(id, id.max());
-      }
-
-      final Set<PatchSet> matches = new HashSet<>();
-      for (final PatchSet ps : patches) {
-        final Change change = db.changes().get(ps.getId().getParentKey());
-        if (inProject(change, projectControl) && inBranch(change, branch)) {
-          matches.add(ps);
-        }
-      }
-
-      switch (matches.size()) {
-        case 1:
-          return matches.iterator().next();
-        case 0:
-          throw error("\"" + patchIdentity + "\" no such patch set");
-        default:
-          throw error("\"" + patchIdentity + "\" matches multiple patch sets");
-      }
-    }
-
-    // By older style change,patchset?
-    //
-    if (patchIdentity.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
-      final PatchSet.Id patchSetId;
-      try {
-        patchSetId = PatchSet.Id.parse(patchIdentity);
-      } catch (IllegalArgumentException e) {
-        throw error("\"" + patchIdentity + "\" is not a valid patch set");
-      }
-      final PatchSet patchSet = db.patchSets().get(patchSetId);
-      if (patchSet == null) {
-        throw error("\"" + patchIdentity + "\" no such patch set");
-      }
-      if (projectControl != null || branch != null) {
-        final Change change = db.changes().get(patchSetId.getParentKey());
-        if (!inProject(change, projectControl)) {
-          throw error("change " + change.getId() + " not in project "
-              + projectControl.getProject().getName());
-        }
-        if (!inBranch(change, branch)) {
-          throw error("change " + change.getId() + " not in branch "
-              + change.getDest().get());
-        }
-      }
-      return patchSet;
-    }
-
-    throw error("\"" + patchIdentity + "\" is not a valid patch set");
-  }
-
-  private static boolean inProject(final Change change,
-      ProjectControl projectControl) {
-    if (projectControl == null) {
-      // No --project option, so they want every project.
-      return true;
-    }
-    return projectControl.getProject().getNameKey().equals(change.getProject());
-  }
-
-  private static boolean inBranch(final Change change, String branch) {
-    if (branch == null) {
-      // No --branch option, so they want every branch.
-      return true;
-    }
-    return change.getDest().get().equals(branch);
-  }
-
-  public static UnloggedFailure error(final String msg) {
-    return new UnloggedFailure(1, msg);
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index acbc50e..d3ff06f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -29,6 +30,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -64,8 +66,9 @@
   private CreateAccount.Factory createAccountFactory;
 
   @Override
-  protected void run() throws OrmException, IOException, UnloggedFailure {
-    CreateAccount.Input input = new CreateAccount.Input();
+  protected void run() throws OrmException, IOException, ConfigInvalidException,
+      UnloggedFailure {
+    AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
     input.name = fullName;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index b1d09e9..d3ec69c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -48,7 +48,7 @@
       gApi.projects().name(project.getProject().getNameKey().get())
           .branch(name).create(in);
     } catch (RestApiException e) {
-      throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e);
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 155c2eb..22f9683 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -38,6 +38,7 @@
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
+import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -88,7 +89,7 @@
   private AddIncludedGroups addIncludedGroups;
 
   @Override
-  protected void run() throws Failure, OrmException {
+  protected void run() throws Failure, OrmException, IOException {
     try {
       GroupResource rsrc = createGroup();
 
@@ -104,7 +105,8 @@
     }
   }
 
-  private GroupResource createGroup() throws RestApiException, OrmException {
+  private GroupResource createGroup()
+      throws RestApiException, OrmException, IOException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -120,7 +122,7 @@
   }
 
   private void addMembers(GroupResource rsrc) throws RestApiException,
-      OrmException {
+      OrmException, IOException {
     AddMembers.Input input =
         AddMembers.Input.fromMembers(FluentIterable
             .from(initialMembers)
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index 3ad5156..db4f313 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -134,7 +134,7 @@
     try {
       if (!suggestParent) {
         if (projectName == null) {
-          throw new UnloggedFailure(1, "fatal: Project name is required.");
+          throw die("Project name is required.");
         }
 
         ProjectInput input = new ProjectInput();
@@ -176,7 +176,7 @@
         }
       }
     } catch (RestApiException | NoSuchProjectException err) {
-      throw new UnloggedFailure(1, "fatal: " + err.getMessage(), err);
+      throw die(err);
     }
   }
 
@@ -188,7 +188,7 @@
       String[] s = pluginConfigValue.split("=");
       String[] s2 = s[0].split("\\.");
       if (s.length != 2 || s2.length != 2) {
-        throw new UnloggedFailure(1, "Invalid plugin config value '"
+        throw die("Invalid plugin config value '"
             + pluginConfigValue
             + "', expected format '<plugin-name>.<parameter-name>=<value>'"
             + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index c6eaebb..c9cdbe0 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -21,15 +21,19 @@
 import com.google.gerrit.sshd.Commands;
 import com.google.gerrit.sshd.DispatchCommandProvider;
 import com.google.gerrit.sshd.SuExec;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 
 
 /** Register the commands a Gerrit server supports. */
 public class DefaultCommandModule extends CommandModule {
   private final DownloadConfig downloadConfig;
+  private final LfsPluginAuthCommand.Module lfsPluginAuthModule;
 
-  public DefaultCommandModule(boolean slave, DownloadConfig downloadCfg) {
+  public DefaultCommandModule(boolean slave, DownloadConfig downloadCfg,
+      LfsPluginAuthCommand.Module module) {
     slaveMode = slave;
     downloadConfig = downloadCfg;
+    lfsPluginAuthModule = module;
   }
 
   @Override
@@ -122,6 +126,8 @@
     command(logging, ListLoggingLevelCommand.class);
     alias(logging, "ls", ListLoggingLevelCommand.class);
     alias(logging, "set", SetLoggingLevelCommand.class);
+
+    install(lfsPluginAuthModule);
   }
 
   private boolean sshEnabled() {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 2bcd4cf..1f03225 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.kohsuke.args4j.Option;
 
@@ -51,7 +50,7 @@
   private boolean list;
 
   @Inject
-  private Provider<ListCaches> listCaches;
+  private ListCaches listCaches;
 
   @Inject
   private PostCaches postCaches;
@@ -61,14 +60,14 @@
     try {
       if (list) {
         if (all || caches.size() > 0) {
-          throw error("error: cannot use --list with --all or --cache");
+          throw die("cannot use --list with --all or --cache");
         }
         doList();
         return;
       }
 
       if (all && caches.size() > 0) {
-        throw error("error: cannot combine --all and --cache");
+        throw die("cannot combine --all and --cache");
       } else if (!all && caches.size() == 1 && caches.contains("all")) {
         caches.clear();
         all = true;
@@ -88,13 +87,9 @@
     }
   }
 
-  private static UnloggedFailure error(String msg) {
-    return new UnloggedFailure(1, msg);
-  }
-
   @SuppressWarnings("unchecked")
   private void doList() {
-    for (String name : (List<String>) listCaches.get()
+    for (String name : (List<String>) listCaches
         .setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
       stderr.print(name);
       stderr.print('\n');
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index a3fbcb2..520d194 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -68,11 +68,10 @@
 
   private void verifyCommandLine() throws UnloggedFailure {
     if (!all && projects.isEmpty()) {
-      throw new UnloggedFailure(1,
-          "needs projects as command arguments or --all option");
+      throw die("needs projects as command arguments or --all option");
     }
     if (all && !projects.isEmpty()) {
-      throw new UnloggedFailure(1,
+      throw die(
           "either specify projects as command arguments or use --all option");
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index dc67ac3..4991700 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
-
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.lucene.LuceneVersionManager;
@@ -24,26 +22,30 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 
+import org.kohsuke.args4j.Argument;
+
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "activate",
-  description = "Activate the latest index version available",
-  runsAt = MASTER)
+  description = "Activate the latest index version available")
 public class IndexActivateCommand extends SshCommand {
 
+  @Argument(index = 0, required = true, metaVar = "INDEX",
+      usage = "index name to activate")
+  private String name;
+
   @Inject
   private LuceneVersionManager luceneVersionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.activateLatestIndex()) {
+      if (luceneVersionManager.activateLatestIndex(name)) {
         stdout.println("Activated latest index version");
       } else {
         stdout.println("Not activating index, already using latest version");
       }
     } catch (ReindexerAlreadyRunningException e) {
-      throw new UnloggedFailure("Failed to activate latest index: "
-          + e.getMessage());
+      throw die("Failed to activate latest index: " + e.getMessage());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
new file mode 100644
index 0000000..f7d5c87
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.Index;
+import com.google.gerrit.sshd.ChangeArgumentParser;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.kohsuke.args4j.Argument;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@CommandMetaData(name = "changes", description = "Index changes")
+final class IndexChangesCommand extends SshCommand {
+  @Inject
+  private Index index;
+
+  @Inject
+  private ChangeArgumentParser changeArgumentParser;
+
+  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE",
+      usage = "changes to index")
+  void addChange(String token) {
+    try {
+      changeArgumentParser.addChange(token, changes, null, false);
+    } catch (UnloggedFailure | OrmException e) {
+      writeError("warning", e.getMessage());
+    }
+  }
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    boolean ok = true;
+    for (ChangeResource rsrc : changes.values()) {
+      try {
+        index.apply(rsrc, new Index.Input());
+      } catch (IOException | RestApiException | OrmException e) {
+        ok = false;
+        writeError("error", String.format(
+            "failed to index change %s: %s", rsrc.getId(), e.getMessage()));
+      }
+    }
+    if (!ok) {
+      throw die("failed to index one or more changes");
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 3e7b293..633bca8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -28,5 +28,6 @@
     command(index).toProvider(new DispatchCommandProvider(index));
     command(index, IndexActivateCommand.class);
     command(index, IndexStartCommand.class);
+    command(index, IndexChangesCommand.class);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index 1b3b819..6629e3c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
-
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.lucene.LuceneVersionManager;
@@ -24,24 +22,33 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "start", description = "Start the online reindexer",
-  runsAt = MASTER)
+@CommandMetaData(name = "start", description = "Start the online reindexer")
 public class IndexStartCommand extends SshCommand {
 
+  @Option(name = "--force", usage = "force a re-index")
+  private boolean force;
+
+  @Argument(index = 0, required = true, metaVar = "INDEX",
+      usage = "index name to start")
+  private String name;
+
   @Inject
   private LuceneVersionManager luceneVersionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.startReindexer()) {
+      if (luceneVersionManager.startReindexer(name, force)) {
         stdout.println("Reindexer started");
       } else {
         stdout.println("Nothing to reindex, index is already the latest version");
       }
     } catch (ReindexerAlreadyRunningException e) {
-      throw new UnloggedFailure("Failed to start reindexer: " + e.getMessage());
+      throw die("Failed to start reindexer: " + e.getMessage());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index 75072e8..2e11ef9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -49,7 +49,7 @@
   @Override
   public void run() throws Exception {
     if (impl.getUser() != null && !impl.getProjects().isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: --user and --project options are not compatible.");
+      throw die("--user and --project options are not compatible.");
     }
     impl.display(stdout);
   }
@@ -72,7 +72,7 @@
         final GroupControl.GenericFactory genericGroupControlFactory,
         final Provider<IdentifiedUser> identifiedUser,
         final IdentifiedUser.GenericFactory userFactory,
-        final Provider<GetGroups> accountGetGroups,
+        final GetGroups accountGetGroups,
         final GroupJson json,
         GroupBackend groupBackend) {
       super(groupCache, groupControlFactory, genericGroupControlFactory,
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 134a719..d81c153 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -34,10 +34,10 @@
     if (!impl.getFormat().isJson()) {
       List<String> showBranch = impl.getShowBranch();
       if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
-        throw new UnloggedFailure(1, "fatal: --tree and --show-branch options are not compatible.");
+        throw die("--tree and --show-branch options are not compatible.");
       }
       if (impl.isShowTree() && impl.isShowDescription()) {
-        throw new UnloggedFailure(1, "fatal: --tree and --description options are not compatible.");
+        throw die("--tree and --description options are not compatible.");
       }
     }
     impl.display(out);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index a70d581..2173652 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.reviewdb.client.Account;
@@ -24,10 +25,11 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -59,7 +61,11 @@
   private TagCache tagCache;
 
   @Inject
-  private ChangeCache changeCache;
+  private ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  @Nullable
+  private SearchingChangeCacheImpl changeCache;
 
   @Option(name = "--project", aliases = {"-p"}, metaVar = "PROJECT",
       required = true, usage = "project for which the refs should be listed")
@@ -79,7 +85,7 @@
   protected void run() throws Failure {
     Account userAccount;
     try {
-      userAccount = accountResolver.find(userName);
+      userAccount = accountResolver.find(db, userName);
     } catch (OrmException e) {
       throw die(e);
     }
@@ -95,9 +101,10 @@
     try (Repository repo = repoManager.openRepository(
         userProjectControl.getProject().getNameKey())) {
       try {
-        Map<String, Ref> refsMap =
-            new VisibleRefFilter(tagCache, changeCache, repo, userProjectControl,
-                db, true).filter(repo.getRefDatabase().getRefs(ALL), false);
+        Map<String, Ref> refsMap = new VisibleRefFilter(
+                tagCache, changeNotesFactory, changeCache, repo,
+                userProjectControl, db, true)
+            .filter(repo.getRefDatabase().getRefs(ALL), false);
 
         for (final String ref : refsMap.keySet()) {
           if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
@@ -109,11 +116,10 @@
             + projectControl.getProject().getNameKey(), e);
       }
     } catch (RepositoryNotFoundException e) {
-      throw new UnloggedFailure("fatal: '"
-          + projectControl.getProject().getNameKey() + "': not a git archive");
+      throw die("'" + projectControl.getProject().getNameKey()
+          + "': not a git archive");
     } catch (IOException e) {
-      throw new UnloggedFailure("fatal: Error opening: '"
-          + projectControl.getProject().getNameKey());
+      throw die("Error opening: '" + projectControl.getProject().getNameKey());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
new file mode 100644
index 0000000..a51876d
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class PatchSetParser {
+  private final Provider<ReviewDb> db;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory notesFactory;
+  private final PatchSetUtil psUtil;
+  private final ChangeFinder changeFinder;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  PatchSetParser(Provider<ReviewDb> db,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      PatchSetUtil psUtil,
+      ChangeFinder changeFinder,
+      Provider<CurrentUser> self) {
+    this.db = db;
+    this.queryProvider = queryProvider;
+    this.notesFactory = notesFactory;
+    this.psUtil = psUtil;
+    this.changeFinder = changeFinder;
+    this.self = self;
+  }
+
+  public PatchSet parsePatchSet(String token, ProjectControl projectControl,
+      String branch) throws UnloggedFailure, OrmException {
+    // By commit?
+    //
+    if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
+      InternalChangeQuery query = queryProvider.get();
+      List<ChangeData> cds;
+      if (projectControl != null) {
+        Project.NameKey p = projectControl.getProject().getNameKey();
+        if (branch != null) {
+          cds = query.byBranchCommit(p.get(), branch, token);
+        } else {
+          cds = query.byProjectCommit(p, token);
+        }
+      } else {
+        cds = query.byCommit(token);
+      }
+      List<PatchSet> matches = new ArrayList<>(cds.size());
+      for (ChangeData cd : cds) {
+        Change c = cd.change();
+        if (!(inProject(c, projectControl) && inBranch(c, branch))) {
+          continue;
+        }
+        for (PatchSet ps : cd.patchSets()) {
+          if (ps.getRevision().matches(token)) {
+            matches.add(ps);
+          }
+        }
+      }
+
+      switch (matches.size()) {
+        case 1:
+          return matches.iterator().next();
+        case 0:
+          throw error("\"" + token + "\" no such patch set");
+        default:
+          throw error("\"" + token + "\" matches multiple patch sets");
+      }
+    }
+
+    // By older style change,patchset?
+    //
+    if (token.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
+      PatchSet.Id patchSetId;
+      try {
+        patchSetId = PatchSet.Id.parse(token);
+      } catch (IllegalArgumentException e) {
+        throw error("\"" + token + "\" is not a valid patch set");
+      }
+      ChangeNotes notes = getNotes(projectControl, patchSetId.getParentKey());
+      PatchSet patchSet = psUtil.get(db.get(), notes, patchSetId);
+      if (patchSet == null) {
+        throw error("\"" + token + "\" no such patch set");
+      }
+      if (projectControl != null || branch != null) {
+        Change change = notes.getChange();
+        if (!inProject(change, projectControl)) {
+          throw error("change " + change.getId() + " not in project "
+              + projectControl.getProject().getName());
+        }
+        if (!inBranch(change, branch)) {
+          throw error("change " + change.getId() + " not in branch " + branch);
+        }
+      }
+      return patchSet;
+    }
+
+    throw error("\"" + token + "\" is not a valid patch set");
+  }
+
+  private ChangeNotes getNotes(@Nullable ProjectControl projectControl,
+      Change.Id changeId) throws OrmException, UnloggedFailure {
+    if (projectControl != null) {
+      return notesFactory.create(db.get(), projectControl.getProject().getNameKey(),
+          changeId);
+    }
+    try {
+      ChangeControl ctl = changeFinder.findOne(changeId, self.get());
+      return notesFactory.create(db.get(), ctl.getProject().getNameKey(),
+          changeId);
+    } catch (NoSuchChangeException e) {
+      throw error("\"" + changeId + "\" no such change");
+    }
+  }
+
+  private static boolean inProject(Change change,
+      ProjectControl projectControl) {
+    if (projectControl == null) {
+      // No --project option, so they want every project.
+      return true;
+    }
+    return projectControl.getProject().getNameKey().equals(change.getProject());
+  }
+
+  private static boolean inBranch(Change change, String branch) {
+    if (branch == null) {
+      // No --branch option, so they want every branch.
+      return true;
+    }
+    return change.getDest().get().equals(branch);
+  }
+
+  public static UnloggedFailure error(String msg) {
+    return new UnloggedFailure(1, msg);
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
index f65a0c9..8bde743 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
@@ -102,7 +102,7 @@
     super.parseCommandLine();
     if (processor.getIncludeFiles() &&
         !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
-      throw new UnloggedFailure(1, "--files option needs --patch-sets or --current-patch-set");
+      throw die("--files option needs --patch-sets or --current-patch-set");
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
index 6c07fae..25e49b2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gwtorm.jdbc.JdbcSchema;
@@ -51,7 +52,7 @@
     QueryShell create(@Assisted InputStream in, @Assisted OutputStream out);
   }
 
-  public static enum OutputFormat {
+  public enum OutputFormat {
     PRETTY, JSON, JSON_SINGLE
   }
 
@@ -78,7 +79,7 @@
 
   public void run() {
     try {
-      db = dbFactory.open();
+      db = ReviewDbUtil.unwrapDb(dbFactory.open());
       try {
         connection = ((JdbcSchema) db).getConnection();
         connection.setAutoCommit(true);
@@ -450,6 +451,7 @@
         case JSON_SINGLE:
           collector.add(row);
           break;
+        case PRETTY:
         default:
           final JsonObject obj = new JsonObject();
           obj.addProperty("type", "error");
@@ -482,6 +484,7 @@
         }
         println(collector.toString());
         break;
+      case PRETTY:
       default:
         final JsonObject obj = new JsonObject();
         obj.addProperty("type", "error");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
index abb788d..011cb91 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
@@ -14,18 +14,15 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.git.ReceiveCommits;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
@@ -33,8 +30,6 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.PostReceiveHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
@@ -62,13 +57,7 @@
   private IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
-  private TransferConfig config;
-
-  @Inject
-  private DynamicSet<ReceivePackInitializer> receivePackInitializers;
-
-  @Inject
-  private DynamicSet<PostReceiveHook> postReceiveHooks;
+  private SshSession session;
 
   private final Set<Account.Id> reviewerId = new HashSet<>();
   private final Set<Account.Id> ccId = new HashSet<>();
@@ -94,25 +83,19 @@
 
     Capable r = receive.canUpload();
     if (r != Capable.OK) {
-      throw new UnloggedFailure(1, "\nfatal: " + r.getMessage());
+      throw die(r.getMessage());
     }
 
     verifyProjectVisible("reviewer", reviewerId);
     verifyProjectVisible("CC", ccId);
 
+    receive.init();
     receive.addReviewers(reviewerId);
     receive.addExtraCC(ccId);
-
-    final ReceivePack rp = receive.getReceivePack();
-    rp.setRefLogIdent(currentUser.newRefLogIdent());
-    rp.setTimeout(config.getTimeout());
-    rp.setMaxObjectSizeLimit(config.getEffectiveMaxObjectSizeLimit(
-        projectControl.getProjectState()));
-    init(rp);
-    rp.setPostReceiveHook(PostReceiveHookChain.newChain(
-        Lists.newArrayList(postReceiveHooks)));
+    ReceivePack rp = receive.getReceivePack();
     try {
       rp.receive(in, out, err);
+      session.setPeerAgent(rp.getPeerUserAgent());
     } catch (UnpackException badStream) {
       // In case this was caused by the user pushing an object whose size
       // is larger than the receive.maxObjectSizeLimit gerrit.config parameter
@@ -177,18 +160,12 @@
     }
   }
 
-  private void init(ReceivePack rp) {
-    for (ReceivePackInitializer initializer : receivePackInitializers) {
-      initializer.init(projectControl.getProject().getNameKey(), rp);
-    }
-  }
-
   private void verifyProjectVisible(final String type, final Set<Account.Id> who)
       throws UnloggedFailure {
     for (final Account.Id id : who) {
       final IdentifiedUser user = identifiedUserFactory.create(id);
       if (!projectControl.forUser(user).isVisible()) {
-        throw new UnloggedFailure(1, type + " "
+        throw die(type + " "
             + user.getAccount().getFullName() + " cannot access the project");
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 1e3cf67..02aab64 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -17,20 +17,19 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Maps;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -43,7 +42,6 @@
 import com.google.gson.JsonSyntaxException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
@@ -53,10 +51,12 @@
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeMap;
 
 @CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
 public class ReviewCommand extends SshCommand {
@@ -79,8 +79,7 @@
       usage = "list of commits or patch sets to review")
   void addPatchSetId(final String token) {
     try {
-      PatchSet ps = CommandUtils.parsePatchSet(token, db, projectControl,
-          branch);
+      PatchSet ps = psParser.parsePatchSet(token, projectControl, branch);
       patchSets.add(ps);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
@@ -110,6 +109,9 @@
   @Option(name = "--rebase", usage = "rebase the specified change(s)")
   private boolean rebaseChange;
 
+  @Option(name = "--move", usage = "move the specified change(s)", metaVar = "BRANCH")
+  private String moveToBranch;
+
   @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
   private boolean submitChange;
 
@@ -126,6 +128,9 @@
       + "specified can be applied to the given patch set(s)")
   private boolean strictLabels;
 
+  @Option(name = "--tag", aliases = "-t", usage = "applies a tag to the given review", metaVar = "TAG")
+  private String changeTag;
+
   @Option(name = "--label", aliases = "-l", usage = "custom label(s) to assign", metaVar = "LABEL=VALUE")
   void addLabel(final String token) {
     LabelVote v = LabelVote.parseWithEquals(token);
@@ -134,16 +139,16 @@
   }
 
   @Inject
-  private ReviewDb db;
-
-  @Inject
   private ProjectControl.Factory projectControlFactory;
 
   @Inject
   private AllProjectsName allProjects;
 
   @Inject
-  private Provider<GerritApi> gApi;
+  private GerritApi gApi;
+
+  @Inject
+  private PatchSetParser psParser;
 
   private List<ApproveOption> optionList;
   private Map<String, Short> customLabels;
@@ -152,65 +157,74 @@
   protected void run() throws UnloggedFailure {
     if (abandonChange) {
       if (restoreChange) {
-        throw error("abandon and restore actions are mutually exclusive");
+        throw die("abandon and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("abandon and submit actions are mutually exclusive");
+        throw die("abandon and submit actions are mutually exclusive");
       }
       if (publishPatchSet) {
-        throw error("abandon and publish actions are mutually exclusive");
+        throw die("abandon and publish actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("abandon and delete actions are mutually exclusive");
+        throw die("abandon and delete actions are mutually exclusive");
       }
       if (rebaseChange) {
-        throw error("abandon and rebase actions are mutually exclusive");
+        throw die("abandon and rebase actions are mutually exclusive");
+      }
+      if (moveToBranch != null) {
+        throw die("abandon and move actions are mutually exclusive");
       }
     }
     if (publishPatchSet) {
       if (restoreChange) {
-        throw error("publish and restore actions are mutually exclusive");
+        throw die("publish and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("publish and submit actions are mutually exclusive");
+        throw die("publish and submit actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("publish and delete actions are mutually exclusive");
+        throw die("publish and delete actions are mutually exclusive");
       }
     }
     if (json) {
       if (restoreChange) {
-        throw error("json and restore actions are mutually exclusive");
+        throw die("json and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("json and submit actions are mutually exclusive");
+        throw die("json and submit actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("json and delete actions are mutually exclusive");
+        throw die("json and delete actions are mutually exclusive");
       }
       if (publishPatchSet) {
-        throw error("json and publish actions are mutually exclusive");
+        throw die("json and publish actions are mutually exclusive");
       }
       if (abandonChange) {
-        throw error("json and abandon actions are mutually exclusive");
+        throw die("json and abandon actions are mutually exclusive");
       }
       if (changeComment != null) {
-        throw error("json and message are mutually exclusive");
+        throw die("json and message are mutually exclusive");
       }
       if (rebaseChange) {
-        throw error("json and rebase actions are mutually exclusive");
+        throw die("json and rebase actions are mutually exclusive");
+      }
+      if (moveToBranch != null) {
+        throw die("json and move actions are mutually exclusive");
+      }
+      if (changeTag != null) {
+        throw die("json and tag actions are mutually exclusive");
       }
     }
     if (rebaseChange) {
       if (deleteDraftPatchSet) {
-        throw error("rebase and delete actions are mutually exclusive");
+        throw die("rebase and delete actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("rebase and submit actions are mutually exclusive");
+        throw die("rebase and submit actions are mutually exclusive");
       }
     }
     if (deleteDraftPatchSet && submitChange) {
-      throw error("delete and submit actions are mutually exclusive");
+      throw die("delete and submit actions are mutually exclusive");
     }
 
     boolean ok = true;
@@ -228,26 +242,27 @@
         }
       } catch (RestApiException | UnloggedFailure e) {
         ok = false;
-        writeError("error: " + e.getMessage() + "\n");
+        writeError("error", e.getMessage() + "\n");
       } catch (NoSuchChangeException e) {
         ok = false;
-        writeError("no such change " + patchSet.getId().getParentKey().get());
+        writeError("error",
+            "no such change " + patchSet.getId().getParentKey().get());
       } catch (Exception e) {
         ok = false;
-        writeError("fatal: internal server error while reviewing "
+        writeError("fatal", "internal server error while reviewing "
             + patchSet.getId() + "\n");
         log.error("internal error while reviewing " + patchSet.getId(), e);
       }
     }
 
     if (!ok) {
-      throw error("one or more reviews failed; review output above");
+      throw die("one or more reviews failed; review output above");
     }
   }
 
   private void applyReview(PatchSet patchSet,
       final ReviewInput review) throws RestApiException {
-    gApi.get().changes()
+    gApi.changes()
         .id(patchSet.getId().getParentKey().get())
         .revision(patchSet.getRevision().get())
         .review(review);
@@ -258,8 +273,8 @@
       return OutputFormat.JSON.newGson().
           fromJson(CharStreams.toString(r), ReviewInput.class);
     } catch (IOException | JsonSyntaxException e) {
-      writeError(e.getMessage() + '\n');
-      throw error("internal error while reading review input");
+      writeError("error", e.getMessage() + '\n');
+      throw die("internal error while reading review input");
     }
   }
 
@@ -270,8 +285,9 @@
 
     ReviewInput review = new ReviewInput();
     review.message = Strings.emptyToNull(changeComment);
+    review.tag = Strings.emptyToNull(changeTag);
     review.notify = notify;
-    review.labels = Maps.newTreeMap();
+    review.labels = new TreeMap<>();
     review.drafts = ReviewInput.DraftHandling.PUBLISH;
     review.strictLabels = strictLabels;
     for (ApproveOption ao : optionList) {
@@ -283,7 +299,7 @@
     review.labels.putAll(customLabels);
 
     // We don't need to add the review comment when abandoning/restoring.
-    if (abandonChange || restoreChange) {
+    if (abandonChange || restoreChange || moveToBranch != null) {
       review.message = null;
     }
 
@@ -302,7 +318,14 @@
         applyReview(patchSet, review);
       }
 
-      if (rebaseChange){
+      if (moveToBranch != null) {
+        MoveInput moveInput = new MoveInput();
+        moveInput.destinationBranch = moveToBranch;
+        moveInput.message = Strings.emptyToNull(changeComment);
+        changeApi(patchSet).move(moveInput);
+      }
+
+      if (rebaseChange) {
         revisionApi(patchSet).rebase();
       }
 
@@ -316,12 +339,12 @@
         revisionApi(patchSet).delete();
       }
     } catch (IllegalStateException | RestApiException e) {
-      throw error(e.getMessage());
+      throw die(e);
     }
   }
 
   private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.get().changes().id(patchSet.getId().getParentKey().get());
+    return gApi.changes().id(patchSet.getId().getParentKey().get());
   }
 
   private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
@@ -331,13 +354,13 @@
   @Override
   protected void parseCommandLine() throws UnloggedFailure {
     optionList = new ArrayList<>();
-    customLabels = Maps.newHashMap();
+    customLabels = new HashMap<>();
 
     ProjectControl allProjectsControl;
     try {
       allProjectsControl = projectControlFactory.controlFor(allProjects);
     } catch (NoSuchProjectException e) {
-      throw new UnloggedFailure("missing " + allProjects.get());
+      throw die("missing " + allProjects.get());
     }
 
     for (LabelType type : allProjectsControl.getLabelTypes().getLabelTypes()) {
@@ -355,16 +378,4 @@
 
     super.parseCommandLine();
   }
-
-  private void writeError(final String msg) {
-    try {
-      err.write(msg.getBytes(ENC));
-    } catch (IOException e) {
-      // Ignored
-    }
-  }
-
-  private static UnloggedFailure error(final String msg) {
-    return new UnloggedFailure(1, msg);
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 194e65f9..5fc877c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -198,7 +198,7 @@
   private void header(final Entry dir, final int len) throws IOException,
       UnsupportedEncodingException {
     final StringBuilder buf = new StringBuilder();
-    switch(dir.getType()){
+    switch (dir.getType()) {
       case DIR:
         buf.append(TYPE_DIR);
         break;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index a6b2810..082395c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -17,12 +17,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -37,7 +38,6 @@
 import com.google.gerrit.server.account.GetEmails;
 import com.google.gerrit.server.account.GetEmails.EmailInfo;
 import com.google.gerrit.server.account.GetSshKeys;
-import com.google.gerrit.server.account.GetSshKeys.SshKeyInfo;
 import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutHttpPassword;
 import com.google.gerrit.server.account.PutName;
@@ -47,13 +47,13 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
 import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
@@ -145,16 +145,14 @@
 
   private void validate() throws UnloggedFailure {
     if (active && inactive) {
-      throw new UnloggedFailure(1,
-          "--active and --inactive options are mutually exclusive.");
+      throw die("--active and --inactive options are mutually exclusive.");
     }
     if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw new UnloggedFailure(1,
-          "--http-password and --clear-http-password options are mutually " +
-          "exclusive.");
+      throw die("--http-password and --clear-http-password options are "
+          + "mutually exclusive.");
     }
     if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
-      throw new UnloggedFailure(1, "Only one option may use the stdin");
+      throw die("Only one option may use the stdin");
     }
     if (deleteSshKeys.contains("ALL")) {
       deleteSshKeys = Collections.singletonList("ALL");
@@ -163,13 +161,13 @@
       deleteEmails = Collections.singletonList("ALL");
     }
     if (deleteEmails.contains(preferredEmail)) {
-      throw new UnloggedFailure(1,
-          "--preferred-email and --delete-email options are mutually " +
+      throw die("--preferred-email and --delete-email options are mutually " +
           "exclusive for the same email address.");
     }
   }
 
-  private void setAccount() throws OrmException, IOException, UnloggedFailure {
+  private void setAccount() throws OrmException, IOException, UnloggedFailure,
+      ConfigInvalidException {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user);
     try {
@@ -222,31 +220,17 @@
   }
 
   private void addSshKeys(List<String> sshKeys) throws RestApiException,
-      OrmException, IOException {
+      OrmException, IOException, ConfigInvalidException {
     for (final String sshKey : sshKeys) {
       AddSshKey.Input in = new AddSshKey.Input();
-      in.raw = new RawInput() {
-        @Override
-        public InputStream getInputStream() throws IOException {
-          return new ByteArrayInputStream(sshKey.getBytes(UTF_8));
-        }
-
-        @Override
-        public String getContentType() {
-          return "plain/text";
-        }
-
-        @Override
-        public long getContentLength() {
-          return sshKey.length();
-        }
-      };
+      in.raw = RawInputUtil.create(sshKey.getBytes(), "plain/text");
       addSshKey.apply(rsrc, in);
     }
   }
 
-  private void deleteSshKeys(List<String> sshKeys) throws RestApiException,
-      OrmException {
+  private void deleteSshKeys(List<String> sshKeys)
+      throws RestApiException, OrmException, RepositoryNotFoundException,
+      IOException, ConfigInvalidException {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -264,15 +248,16 @@
     }
   }
 
-  private void deleteSshKey(SshKeyInfo i) throws AuthException, OrmException {
+  private void deleteSshKey(SshKeyInfo i) throws AuthException, OrmException,
+      RepositoryNotFoundException, IOException, ConfigInvalidException {
     AccountSshKey sshKey = new AccountSshKey(
         new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
     deleteSshKey.apply(
         new AccountResource.SshKey(user, sshKey), null);
   }
 
-  private void addEmail(String email) throws UnloggedFailure, RestApiException,
-      OrmException {
+  private void addEmail(String email)
+      throws UnloggedFailure, RestApiException, OrmException, IOException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -283,7 +268,8 @@
     }
   }
 
-  private void deleteEmail(String email) throws RestApiException, OrmException {
+  private void deleteEmail(String email)
+      throws RestApiException, OrmException, IOException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
@@ -296,8 +282,8 @@
     }
   }
 
-  private void putPreferred(String email) throws RestApiException,
-      OrmException {
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index b1d1605..4fef018 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -49,7 +49,7 @@
     try {
       setHead.apply(new ProjectResource(project), input);
     } catch (UnprocessableEntityException e) {
-      throw new UnloggedFailure("fatal: " + e.getMessage());
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 49edf14..6277eb4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -40,7 +40,7 @@
   private static final String LOG_CONFIGURATION = "log4j.properties";
   private static final String JAVA_OPTIONS_LOG_CONFIG = "log4j.configuration";
 
-  private static enum LevelOption {
+  private enum LevelOption {
     ALL,
     TRACE,
     DEBUG,
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 923865a..79e74d7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -34,44 +35,44 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
 import java.util.List;
 
 @CommandMetaData(name = "set-members", description = "Modify members of specific group or number of groups")
 public class SetMembersCommand extends SshCommand {
 
   @Option(name = "--add", aliases = {"-a"}, metaVar = "USER", usage = "users that should be added as group member")
-  private List<Account.Id> accountsToAdd = Lists.newArrayList();
+  private List<Account.Id> accountsToAdd = new ArrayList<>();
 
   @Option(name = "--remove", aliases = {"-r"}, metaVar = "USER", usage = "users that should be removed from the group")
-  private List<Account.Id> accountsToRemove = Lists.newArrayList();
+  private List<Account.Id> accountsToRemove = new ArrayList<>();
 
   @Option(name = "--include", aliases = {"-i"}, metaVar = "GROUP", usage = "group that should be included as group member")
-  private List<AccountGroup.UUID> groupsToInclude = Lists.newArrayList();
+  private List<AccountGroup.UUID> groupsToInclude = new ArrayList<>();
 
   @Option(name = "--exclude", aliases = {"-e"}, metaVar = "GROUP", usage = "group that should be excluded from the group")
-  private List<AccountGroup.UUID> groupsToRemove = Lists.newArrayList();
+  private List<AccountGroup.UUID> groupsToRemove = new ArrayList<>();
 
   @Argument(index = 0, required = true, multiValued = true, metaVar = "GROUP", usage = "groups to modify")
-  private List<AccountGroup.UUID> groups = Lists.newArrayList();
+  private List<AccountGroup.UUID> groups = new ArrayList<>();
 
   @Inject
-  private Provider<AddMembers> addMembers;
+  private AddMembers addMembers;
 
   @Inject
-  private Provider<DeleteMembers> deleteMembers;
+  private DeleteMembers deleteMembers;
 
   @Inject
-  private Provider<AddIncludedGroups> addIncludedGroups;
+  private AddIncludedGroups addIncludedGroups;
 
   @Inject
-  private Provider<DeleteIncludedGroups> deleteIncludedGroups;
+  private DeleteIncludedGroups deleteIncludedGroups;
 
   @Inject
   private GroupsCollection groupsCollection;
@@ -84,26 +85,30 @@
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
-    for (AccountGroup.UUID groupUuid : groups) {
-      GroupResource resource =
-          groupsCollection.parse(TopLevelResource.INSTANCE,
-              IdString.fromUrl(groupUuid.get()));
-      if (!accountsToRemove.isEmpty()) {
-        deleteMembers.get().apply(resource, fromMembers(accountsToRemove));
-        reportMembersAction("removed from", resource, accountsToRemove);
+    try {
+      for (AccountGroup.UUID groupUuid : groups) {
+        GroupResource resource =
+            groupsCollection.parse(TopLevelResource.INSTANCE,
+                IdString.fromUrl(groupUuid.get()));
+        if (!accountsToRemove.isEmpty()) {
+          deleteMembers.apply(resource, fromMembers(accountsToRemove));
+          reportMembersAction("removed from", resource, accountsToRemove);
+        }
+        if (!groupsToRemove.isEmpty()) {
+          deleteIncludedGroups.apply(resource, fromGroups(groupsToRemove));
+          reportGroupsAction("excluded from", resource, groupsToRemove);
+        }
+        if (!accountsToAdd.isEmpty()) {
+          addMembers.apply(resource, fromMembers(accountsToAdd));
+          reportMembersAction("added to", resource, accountsToAdd);
+        }
+        if (!groupsToInclude.isEmpty()) {
+          addIncludedGroups.apply(resource, fromGroups(groupsToInclude));
+          reportGroupsAction("included to", resource, groupsToInclude);
+        }
       }
-      if (!groupsToRemove.isEmpty()) {
-        deleteIncludedGroups.get().apply(resource, fromGroups(groupsToRemove));
-        reportGroupsAction("excluded from", resource, groupsToRemove);
-      }
-      if (!accountsToAdd.isEmpty()) {
-        addMembers.get().apply(resource, fromMembers(accountsToAdd));
-        reportMembersAction("added to", resource, accountsToAdd);
-      }
-      if (!groupsToInclude.isEmpty()) {
-        addIncludedGroups.get().apply(resource, fromGroups(groupsToInclude));
-        reportGroupsAction("included to", resource, groupsToInclude);
-      }
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index ab1353b..a4cef13 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -37,7 +35,6 @@
 
 import java.io.IOException;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
   private static final Logger log = LoggerFactory
@@ -119,48 +116,46 @@
 
   @Override
   protected void run() throws Failure {
+    if (!projectControl.isOwner()) {
+      throw new UnloggedFailure(1, "restricted to project owner");
+    }
     Project ctlProject = projectControl.getProject();
     Project.NameKey nameKey = ctlProject.getNameKey();
     String name = ctlProject.getName();
     final StringBuilder err = new StringBuilder();
 
-    try {
-      MetaDataUpdate md = metaDataUpdateFactory.create(nameKey);
-      try {
-        ProjectConfig config = ProjectConfig.read(md);
-        Project project = config.getProject();
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      Project project = config.getProject();
 
-        if (requireChangeID != null) {
-          project.setRequireChangeID(requireChangeID);
-        }
-        if (submitType != null) {
-          project.setSubmitType(submitType);
-        }
-        if (contentMerge != null) {
-          project.setUseContentMerge(contentMerge);
-        }
-        if (contributorAgreements != null) {
-          project.setUseContributorAgreements(contributorAgreements);
-        }
-        if (signedOffBy != null) {
-          project.setUseSignedOffBy(signedOffBy);
-        }
-        if (projectDescription != null) {
-          project.setDescription(projectDescription);
-        }
-        if (state != null) {
-          project.setState(state);
-        }
-        if (maxObjectSizeLimit != null) {
-          project.setMaxObjectSizeLimit(maxObjectSizeLimit);
-        }
-        md.setMessage("Project settings updated");
-        config.commit(md);
-      } finally {
-        md.close();
+      if (requireChangeID != null) {
+        project.setRequireChangeID(requireChangeID);
       }
+      if (submitType != null) {
+        project.setSubmitType(submitType);
+      }
+      if (contentMerge != null) {
+        project.setUseContentMerge(contentMerge);
+      }
+      if (contributorAgreements != null) {
+        project.setUseContributorAgreements(contributorAgreements);
+      }
+      if (signedOffBy != null) {
+        project.setUseSignedOffBy(signedOffBy);
+      }
+      if (projectDescription != null) {
+        project.setDescription(projectDescription);
+      }
+      if (state != null) {
+        project.setState(state);
+      }
+      if (maxObjectSizeLimit != null) {
+        project.setMaxObjectSizeLimit(maxObjectSizeLimit);
+      }
+      md.setMessage("Project settings updated");
+      config.commit(md);
     } catch (RepositoryNotFoundException notFound) {
-      err.append("error: Project ").append(name).append(" not found\n");
+      err.append("Project ").append(name).append(" not found\n");
     } catch (IOException | ConfigInvalidException e) {
       final String msg = "Cannot update project " + name;
       log.error(msg, e);
@@ -172,7 +167,7 @@
       while (err.charAt(err.length() - 1) == '\n') {
         err.setLength(err.length() - 1);
       }
-      throw new UnloggedFailure(1, err.toString());
+      throw die(err.toString());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index e9043f7..ac64803 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -18,36 +18,27 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.DeleteReviewer;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 @CommandMetaData(name = "set-reviewers", description = "Add or remove reviewers on a change")
@@ -66,10 +57,10 @@
     toRemove.add(who);
   }
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "COMMIT", usage = "changes to modify")
+  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE", usage = "changes to modify")
   void addChange(String token) {
     try {
-      changes.addAll(parseChangeId(token));
+      changeArgumentParser.addChange(token, changes, projectControl);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (OrmException e) {
@@ -78,62 +69,49 @@
   }
 
   @Inject
-  private ReviewDb db;
-
-  @Inject
-  private Provider<InternalChangeQuery> queryProvider;
-
-  @Inject
   private ReviewerResource.Factory reviewerFactory;
 
   @Inject
-  private Provider<PostReviewers> postReviewersProvider;
+  private PostReviewers postReviewers;
 
   @Inject
-  private Provider<DeleteReviewer> deleteReviewerProvider;
+  private DeleteReviewer deleteReviewer;
 
   @Inject
-  private Provider<CurrentUser> userProvider;
-
-  @Inject
-  private ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
-  private ChangesCollection changesCollection;
+  private ChangeArgumentParser changeArgumentParser;
 
   private Set<Account.Id> toRemove = new HashSet<>();
-  private Set<Change.Id> changes = new HashSet<>();
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
 
   @Override
   protected void run() throws UnloggedFailure {
     boolean ok = true;
-    for (Change.Id changeId : changes) {
+    for (ChangeResource rsrc : changes.values()) {
       try {
-        ok &= modifyOne(changeId);
+        ok &= modifyOne(rsrc);
       } catch (Exception err) {
         ok = false;
-        log.error("Error updating reviewers on change " + changeId, err);
-        writeError("fatal", "internal error while updating " + changeId);
+        log.error("Error updating reviewers on change " + rsrc.getId(), err);
+        writeError("fatal", "internal error while updating " + rsrc.getId());
       }
     }
 
     if (!ok) {
-      throw error("fatal: one or more updates failed; review output above");
+      throw die("one or more updates failed; review output above");
     }
   }
 
-  private boolean modifyOne(Change.Id changeId) throws Exception {
-    ChangeResource changeRsrc = changesCollection.parse(changeId);
+  private boolean modifyOne(ChangeResource changeRsrc) throws Exception {
     boolean ok = true;
 
     // Remove reviewers
     //
-    DeleteReviewer delete = deleteReviewerProvider.get();
     for (Account.Id reviewer : toRemove) {
       ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
       String error = null;
       try {
-        delete.apply(rsrc, new DeleteReviewer.Input());
+        deleteReviewer.apply(rsrc, new DeleteReviewer.Input());
       } catch (ResourceNotFoundException e) {
         error = String.format("could not remove %s: not found", reviewer);
       } catch (Exception e) {
@@ -148,14 +126,13 @@
 
     // Add reviewers
     //
-    PostReviewers post = postReviewersProvider.get();
     for (String reviewer : toAdd) {
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = reviewer;
       input.confirmed = true;
       String error;
       try {
-        error = post.apply(changeRsrc, input).error;
+        error = postReviewers.apply(changeRsrc, input).error;
       } catch (Exception e) {
         error = String.format("could not add %s: %s", reviewer, e.getMessage());
       }
@@ -167,114 +144,4 @@
 
     return ok;
   }
-
-  private Set<Change.Id> parseChangeId(String idstr)
-      throws UnloggedFailure, OrmException {
-    Set<Change.Id> matched = new HashSet<>(4);
-    boolean isCommit = idstr.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
-
-    // By newer style changeKey?
-    //
-    boolean changeKeyParses = idstr.matches("^I[0-9a-f]*$");
-    if (changeKeyParses) {
-      for (ChangeData cd : queryProvider.get().byKeyPrefix(idstr)) {
-        matchChange(matched, cd.change());
-      }
-    }
-
-    // By commit?
-    //
-    if (isCommit) {
-      RevId id = new RevId(idstr);
-      ResultSet<PatchSet> patches;
-      if (id.isComplete()) {
-        patches = db.patchSets().byRevision(id);
-      } else {
-        patches = db.patchSets().byRevisionRange(id, id.max());
-      }
-
-      for (PatchSet ps : patches) {
-        matchChange(matched, ps.getId().getParentKey());
-      }
-    }
-
-    // By older style changeId?
-    //
-    boolean changeIdParses = false;
-    if (idstr.matches("^[1-9][0-9]*$")) {
-      Change.Id id;
-      try {
-        id = Change.Id.parse(idstr);
-        changeIdParses = true;
-      } catch (IllegalArgumentException e) {
-        id = null;
-        changeIdParses = false;
-      }
-
-      if (changeIdParses) {
-        matchChange(matched, id);
-      }
-    }
-
-    if (!changeKeyParses && !isCommit && !changeIdParses) {
-      throw error("\"" + idstr + "\" is not a valid change");
-    }
-
-    switch (matched.size()) {
-      case 0:
-        throw error("\"" + idstr + "\" no such change");
-
-      case 1:
-        return matched;
-
-      default:
-        throw error("\"" + idstr + "\" matches multiple changes");
-    }
-  }
-
-  private void matchChange(Set<Change.Id> matched, Change.Id changeId) {
-    if (changeId != null && !matched.contains(changeId)) {
-      try {
-        matchChange(matched, db.changes().get(changeId));
-      } catch (OrmException e) {
-        log.warn("Error reading change " + changeId, e);
-      }
-    }
-  }
-
-  private void matchChange(Set<Change.Id> matched, Change change) {
-    try {
-      if (change != null
-          && inProject(change)
-          && changeControlFactory.controlFor(change,
-                userProvider.get()).isVisible(db)) {
-        matched.add(change.getId());
-      }
-    } catch (NoSuchChangeException e) {
-      // Ignore this change.
-    } catch (OrmException e) {
-      log.warn("Error reading change " + change.getId(), e);
-    }
-  }
-
-  private boolean inProject(Change change) {
-    if (projectControl != null) {
-      return projectControl.getProject().getNameKey().equals(change.getProject());
-    } else {
-      // No --project option, so they want every project.
-      return true;
-    }
-  }
-
-  private void writeError(String type, String msg) {
-    try {
-      err.write((type + ": " + msg + "\n").getBytes(ENC));
-    } catch (IOException e) {
-      // Ignored
-    }
-  }
-
-  private static UnloggedFailure error(String msg) {
-    return new UnloggedFailure(1, msg);
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
index b5801bf..3e0cec3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -84,13 +83,13 @@
   private SshDaemon daemon;
 
   @Inject
-  private Provider<ListCaches> listCaches;
+  private ListCaches listCaches;
 
   @Inject
-  private Provider<GetSummary> getSummary;
+  private GetSummary getSummary;
 
   @Inject
-  private Provider<CurrentUser> self;
+  private CurrentUser self;
 
   @Option(name = "--width", aliases = {"-w"}, metaVar = "COLS", usage = "width of output table")
   private int columns = 80;
@@ -125,7 +124,7 @@
     stdout.print('\n');
 
     stdout.print(String.format(//
-        "%1s %-"+nw+"s|%-21s|  %-5s |%-9s|\n" //
+        "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
         , "" //
         , "Name" //
         , "Entries" //
@@ -133,7 +132,7 @@
         , "Hit Ratio" //
     ));
     stdout.print(String.format(//
-        "%1s %-"+nw+"s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
+        "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
         , "" //
         , "" //
         , "Mem" //
@@ -155,11 +154,11 @@
     printDiskCaches(caches);
     stdout.print('\n');
 
-    if (self.get().getCapabilities().canMaintainServer()) {
+    if (self.getCapabilities().canMaintainServer()) {
       sshSummary();
 
       SummaryInfo summary =
-          getSummary.get().setGc(gc).setJvm(showJVM).apply(new ConfigResource());
+          getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
       taskSummary(summary.taskSummary);
       memSummary(summary.memSummary);
       threadSummary(summary.threadSummary);
@@ -175,7 +174,7 @@
   private Collection<CacheInfo> getCaches() {
     @SuppressWarnings("unchecked")
     Map<String, CacheInfo> caches =
-        (Map<String, CacheInfo>) listCaches.get().apply(new ConfigResource());
+        (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
     for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
       CacheInfo cache = entry.getValue();
       cache.name = entry.getKey();
@@ -209,7 +208,7 @@
 
   private void printCache(CacheInfo cache) {
     stdout.print(String.format(
-        "%1s %-"+nw+"s|%6s %6s %7s| %7s |%4s %4s|\n",
+        "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
         CacheType.DISK.equals(cache.type) ? "D" : "",
         cache.name,
         nullToEmpty(cache.entries.mem),
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 8ac9887..5e4568f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.gerrit.common.TimeUtil;
@@ -33,8 +34,8 @@
 import org.apache.sshd.common.io.mina.MinaAcceptor;
 import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2Acceptor;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.session.ServerSession;
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
@@ -107,32 +108,41 @@
 
     hostNameWidth = wide ? Integer.MAX_VALUE : columns - 9 - 9 - 10 - 32;
 
-    final long now = TimeUtil.nowMs();
-    stdout.print(String.format("%-8s %8s %8s   %-15s %s\n", //
-        "Session", "Start", "Idle", "User", "Remote Host"));
-    stdout.print("--------------------------------------------------------------\n");
-    for (final IoSession io : list) {
-      ServerSession s = (ServerSession) ServerSession.getSession(io, true);
-      SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
+    if (getBackend().equals("mina")) {
+      long now = TimeUtil.nowMs();
+      stdout.print(String.format("%-8s %8s %8s   %-15s %s\n",
+          "Session", "Start", "Idle", "User", "Remote Host"));
+      stdout.print("--------------------------------------------------------------\n");
+      for (final IoSession io : list) {
+        checkState(io instanceof MinaSession, "expected MinaSession");
+        MinaSession minaSession = (MinaSession) io;
+        long start = minaSession.getSession().getCreationTime();
+        long idle = now - minaSession.getSession().getLastIoTime();
+        AbstractSession s = AbstractSession.getSession(io, true);
+        SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
 
-      final SocketAddress remoteAddress = io.getRemoteAddress();
-      MinaSession minaSession = io instanceof MinaSession
-          ? (MinaSession) io
-          : null;
-      final long start = minaSession == null
-          ? 0
-          : minaSession.getSession().getCreationTime();
-      final long idle = minaSession == null
-          ? now
-          : now - minaSession.getSession().getLastIoTime();
+        stdout.print(String.format("%8s %8s %8s   %-15.15s %s\n",
+            id(sd),
+            time(now, start),
+            age(idle),
+            username(sd),
+            hostname(io.getRemoteAddress())));
+      }
+    } else {
+      stdout.print(String.format("%-8s   %-15s %s\n",
+          "Session", "User", "Remote Host"));
+      stdout.print("--------------------------------------------------------------\n");
+      for (final IoSession io : list) {
+        AbstractSession s = AbstractSession.getSession(io, true);
+        SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
 
-      stdout.print(String.format("%8s %8s %8s   %-15.15s %s\n", //
-          id(sd), //
-          time(now, start), //
-          age(idle), //
-          username(sd), //
-          hostname(remoteAddress)));
+        stdout.print(String.format("%8s   %-15.15s %s\n",
+            id(sd),
+            username(sd),
+            hostname(io.getRemoteAddress())));
+      }
     }
+
     stdout.print("--\n");
     stdout.print("SSHD Backend: " + getBackend() + "\n");
   }
@@ -192,9 +202,8 @@
 
       return "a/" + u.getAccountId().toString();
 
-    } else {
-      return "";
     }
+    return "";
   }
 
   private String hostname(final SocketAddress remoteAddress) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
index b069cd4..9a20ba8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -17,12 +17,15 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.ListTasks;
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -39,20 +42,29 @@
 
 /** Display the current work queue. */
 @AdminHighPriorityCommand
-@CommandMetaData(name = "show-queue", description = "Display the background work queues",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "show-queue",
+    description = "Display the background work queues",
+    runsAt = MASTER_OR_SLAVE)
 final class ShowQueue extends SshCommand {
-  @Option(name = "--wide", aliases = {"-w"}, usage = "display without line width truncation")
+  @Option(name = "--wide", aliases = {"-w"},
+      usage = "display without line width truncation")
   private boolean wide;
 
+  @Option(name = "--by-queue", aliases = {"-q"},
+      usage = "group tasks by queue and print queue info")
+  private boolean groupByQueue;
+
   @Inject
   private ListTasks listTasks;
 
   @Inject
   private IdentifiedUser currentUser;
 
+  @Inject
+  private WorkQueue workQueue;
+
   private int columns = 80;
-  private int taskNameWidth;
+  private int maxCommandWidth;
 
   @Override
   public void start(Environment env) throws IOException {
@@ -69,54 +81,84 @@
 
   @Override
   protected void run() throws UnloggedFailure {
-    taskNameWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
+    maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(String.format("%-8s %-12s %-12s %-4s %s\n", //
         "Task", "State", "StartTime", "", "Command"));
     stdout.print("----------------------------------------------"
         + "--------------------------------\n");
 
+    List<TaskInfo> tasks;
     try {
-      List<TaskInfo> tasks = listTasks.apply(new ConfigResource());
-      long now = TimeUtil.nowMs();
-      boolean viewAll = currentUser.getCapabilities().canViewQueue();
-      for (TaskInfo task : tasks) {
-        String start;
-        switch (task.state) {
-          case DONE:
-          case CANCELLED:
-          case RUNNING:
-          case READY:
-            start = format(task.state);
-            break;
-          default:
-            start = time(now, task.delay);
-            break;
-        }
-
-        // Shows information about tasks depending on the user rights
-        if (viewAll || task.projectName == null) {
-          String command = task.command.length() < taskNameWidth
-              ? task.command
-              : task.command.substring(0, taskNameWidth);
-
-          stdout.print(String.format("%8s %-12s %-12s %-4s %s\n",
-              task.id, start, startTime(task.startTime), "", command));
-        } else {
-          String remoteName = task.remoteName != null
-              ? task.remoteName + "/" + task.projectName
-              : task.projectName;
-
-          stdout.print(String.format("%8s %-12s %-4s %s\n",
-              task.id, start, startTime(task.startTime),
-              MoreObjects.firstNonNull(remoteName, "n/a")));
-        }
-      }
-      stdout.print("----------------------------------------------"
-          + "--------------------------------\n");
-      stdout.print("  " + tasks.size() + " tasks\n");
+      tasks = listTasks.apply(new ConfigResource());
     } catch (AuthException e) {
       throw die(e);
     }
+    boolean viewAll = currentUser.getCapabilities().canViewQueue();
+    long now = TimeUtil.nowMs();
+
+    if (groupByQueue) {
+      ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
+      for (String queueName : byQueue.keySet()) {
+        WorkQueue.Executor e = workQueue.getExecutor(queueName);
+        stdout.print(String.format("Queue: %s\n", queueName));
+        print(byQueue.get(queueName), now, viewAll, e.getCorePoolSize());
+      }
+    } else {
+      print(tasks, now, viewAll, 0);
+    }
+  }
+
+  private ListMultimap<String, TaskInfo> byQueue(List<TaskInfo> tasks) {
+    ListMultimap<String, TaskInfo> byQueue = LinkedListMultimap.create();
+    for (TaskInfo task : tasks) {
+      byQueue.put(task.queueName, task);
+    }
+    return byQueue;
+  }
+
+  private void print(List<TaskInfo> tasks, long now, boolean viewAll,
+      int threadPoolSize) {
+    for (TaskInfo task : tasks) {
+      String start;
+      switch (task.state) {
+        case DONE:
+        case CANCELLED:
+        case RUNNING:
+        case READY:
+          start = format(task.state);
+          break;
+        case OTHER:
+        case SLEEPING:
+        default:
+          start = time(now, task.delay);
+          break;
+      }
+
+      // Shows information about tasks depending on the user rights
+      if (viewAll || task.projectName == null) {
+        String command = task.command.length() < maxCommandWidth
+            ? task.command
+                : task.command.substring(0, maxCommandWidth);
+
+        stdout.print(String.format("%8s %-12s %-12s %-4s %s\n",
+            task.id, start, startTime(task.startTime), "", command));
+      } else {
+        String remoteName = task.remoteName != null
+            ? task.remoteName + "/" + task.projectName
+                : task.projectName;
+
+        stdout.print(String.format("%8s %-12s %-4s %s\n",
+            task.id, start, startTime(task.startTime),
+            MoreObjects.firstNonNull(remoteName, "n/a")));
+      }
+    }
+    stdout.print("----------------------------------------------"
+        + "--------------------------------\n");
+    stdout.print("  " + tasks.size() + " tasks");
+    if (threadPoolSize > 0) {
+      stdout.print(", " + threadPoolSize + " worker threads");
+    }
+    stdout.print("\n\n");
   }
 
   private static String time(long now, long delay) {
@@ -147,6 +189,7 @@
         return "waiting ....";
       case SLEEPING:
         return "sleeping";
+      case OTHER:
       default:
         return state.toString();
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 379f1b9..f807074 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -14,46 +14,63 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.common.EventListener;
-import com.google.gerrit.common.EventSource;
+import com.google.common.base.Supplier;
+import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventTypes;
+import com.google.gerrit.server.events.ProjectNameKeySerializer;
+import com.google.gerrit.server.events.SupplierSerializer;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.StreamCommandExecutor;
 import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 
 import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.Future;
 import java.util.concurrent.LinkedBlockingQueue;
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
-@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time",
-  runsAt = MASTER)
+@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
 final class StreamEvents extends BaseCommand {
+  private static final Logger log =
+      LoggerFactory.getLogger(StreamEvents.class);
+
   /** Maximum number of events that may be queued up for each connection. */
   private static final int MAX_EVENTS = 128;
 
   /** Number of events to write before yielding off the thread. */
   private static final int BATCH_SIZE = 32;
 
+  @Option(name = "--subscribe", aliases = {"-s"}, metaVar = "SUBSCRIBE",
+      usage = "subscribe to specific stream-events")
+  private List<String> subscribedToEvents = new ArrayList<>();
+
   @Inject
   private IdentifiedUser currentUser;
 
   @Inject
-  private EventSource source;
+  private DynamicSet<UserScopedEventListener> eventListeners;
 
   @Inject
   @StreamCommandExecutor
@@ -63,28 +80,22 @@
   private final LinkedBlockingQueue<Event> queue =
       new LinkedBlockingQueue<>(MAX_EVENTS);
 
-  private final Gson gson = new Gson();
+  private Gson gson;
+
+  private RegistrationHandle eventListenerRegistration;
 
   /** Special event to notify clients they missed other events. */
   private static final class DroppedOutputEvent extends Event {
-    public DroppedOutputEvent() {
-      super("dropped-output");
+    private static final String TYPE = "dropped-output";
+    DroppedOutputEvent() {
+      super(TYPE);
     }
   }
 
-  private static final DroppedOutputEvent droppedOutputEvent = new DroppedOutputEvent();
-
   static {
-    EventTypes.registerClass(droppedOutputEvent);
+    EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
   }
 
-  private final EventListener listener = new EventListener() {
-    @Override
-    public void onEvent(final Event event) {
-      offer(event);
-    }
-  };
-
   private final CancelableRunnable writer = new CancelableRunnable() {
     @Override
     public void run() {
@@ -102,7 +113,7 @@
     }
   };
 
-  /** True if {@link #droppedOutputEvent} needs to be sent. */
+  /** True if {@link DroppedOutputEvent} needs to be sent. */
   private volatile boolean dropped;
 
   /** Lock to protect {@link #queue}, {@link #task}, {@link #done}. */
@@ -138,12 +149,32 @@
     }
 
     stdout = toPrintWriter(out);
-    source.addEventListener(listener, currentUser);
+    eventListenerRegistration =
+        eventListeners.add(new UserScopedEventListener() {
+          @Override
+          public void onEvent(final Event event) {
+            if (subscribedToEvents.isEmpty()
+                || subscribedToEvents.contains(event.getType())) {
+              offer(event);
+            }
+          }
+
+          @Override
+          public CurrentUser getUser() {
+            return currentUser;
+          }
+        });
+
+    gson = new GsonBuilder()
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+        .registerTypeAdapter(
+            Project.NameKey.class, new ProjectNameKeySerializer())
+        .create();
   }
 
   @Override
   protected void onExit(final int rc) {
-    source.removeEventListener(listener);
+    eventListenerRegistration.remove();
 
     synchronized (taskLock) {
       done = true;
@@ -154,7 +185,7 @@
 
   @Override
   public void destroy() {
-    source.removeEventListener(listener);
+    eventListenerRegistration.remove();
 
     final boolean exit;
     synchronized (taskLock) {
@@ -202,14 +233,14 @@
         // destroy() above, or it closed the stream and is no longer
         // accepting output. Either way terminate this instance.
         //
-        source.removeEventListener(listener);
+        eventListenerRegistration.remove();
         flush();
         onExit(0);
         return;
       }
 
       if (dropped) {
-        write(droppedOutputEvent);
+        write(new DroppedOutputEvent());
         dropped = false;
       }
 
@@ -236,9 +267,16 @@
   }
 
   private void write(final Object message) {
-    final String msg = gson.toJson(message) + "\n";
-    synchronized (stdout) {
-      stdout.print(msg);
+    String msg = null;
+    try {
+      msg = gson.toJson(message) + "\n";
+    } catch (Exception e) {
+      log.warn("Could not deserialize the msg: ", e);
+    }
+    if (msg != null) {
+      synchronized (stdout) {
+        stdout.print(msg);
+      }
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
index b957a7a..7e74ef0 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
@@ -14,22 +14,21 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitRule;
-import com.google.gerrit.server.change.TestSubmitRule.Input;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 /** Command that allows testing of prolog submit-rules in a live instance. */
 @CommandMetaData(name = "rule", description = "Test prolog submit rules")
 final class TestSubmitRuleCommand extends BaseTestPrologCommand {
   @Inject
-  private Provider<TestSubmitRule> view;
+  private TestSubmitRule view;
 
   @Override
-  protected RestModifyView<RevisionResource, Input> createView() {
-    return view.get();
+  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
+    return view;
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
index 2e7f0df..3a885f9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
@@ -15,21 +15,20 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.change.TestSubmitRule.Input;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 @CommandMetaData(name = "type", description = "Test prolog submit type")
 final class TestSubmitTypeCommand extends BaseTestPrologCommand {
   @Inject
-  private Provider<TestSubmitType> view;
+  private TestSubmitType view;
 
   @Override
-  protected RestModifyView<RevisionResource, Input> createView() {
-    return view.get();
+  protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
+    return view;
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
index 34f7107..181b0c6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.ChangeCache;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidationException;
 import com.google.gerrit.server.git.validators.UploadValidators;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
+import org.eclipse.jgit.transport.PostUploadHook;
+import org.eclipse.jgit.transport.PostUploadHookChain;
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.UploadPack;
@@ -38,7 +41,7 @@
 /** Publishes Git repositories over SSH using the Git upload-pack protocol. */
 final class Upload extends AbstractGitCommand {
   @Inject
-  private Provider<ReviewDb> db;
+  private ReviewDb db;
 
   @Inject
   private TransferConfig config;
@@ -47,12 +50,19 @@
   private TagCache tagCache;
 
   @Inject
-  private ChangeCache changeCache;
+  private ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  @Nullable
+  private SearchingChangeCacheImpl changeCache;
 
   @Inject
   private DynamicSet<PreUploadHook> preUploadHooks;
 
   @Inject
+  private DynamicSet<PostUploadHook> postUploadHooks;
+
+  @Inject
   private UploadValidators.Factory uploadValidatorsFactory;
 
   @Inject
@@ -65,12 +75,14 @@
     }
 
     final UploadPack up = new UploadPack(repo);
-    if (!projectControl.allRefsAreVisible()) {
-      up.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeCache, repo,
-          projectControl, db.get(), true));
-    }
+    up.setAdvertiseRefsHook(
+        new VisibleRefFilter(
+            tagCache, changeNotesFactory, changeCache, repo, projectControl, db,
+            true));
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
+    up.setPostUploadHook(
+        PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
 
     List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
     allPreUploadHooks.add(uploadValidatorsFactory.create(project, repo,
@@ -78,6 +90,7 @@
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
     try {
       up.upload(in, out, err);
+      session.setPeerAgent(up.getPeerUserAgent());
     } catch (UploadValidationException e) {
       // UploadValidationException is used by the UploadValidators to
       // stop the uploadPack. We do not want this exception to go beyond this
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index c3dae3a..0edba4f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -17,13 +17,11 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -39,6 +37,7 @@
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -104,7 +103,7 @@
   @Inject
   private GetArchive.AllowedFormats allowedFormats;
   @Inject
-  private Provider<ReviewDb> db;
+  private ReviewDb db;
   private Options options = new Options();
 
   /**
@@ -114,7 +113,7 @@
    */
   protected void readArguments() throws IOException, Failure {
     String argCmd = "argument ";
-    List<String> args = Lists.newArrayList();
+    List<String> args = new ArrayList<>();
 
     // Read arguments in Pkt-Line format
     PacketLineIn packetIn = new PacketLineIn(in);
@@ -127,7 +126,7 @@
         throw new Failure(1, "fatal: 'argument' token or flush expected");
       }
       String[] parts = s.substring(argCmd.length()).split("=", 2);
-      for(String p : parts) {
+      for (String p : parts) {
         args.add(p);
       }
     }
@@ -218,7 +217,7 @@
   private boolean canRead(ObjectId revId) throws IOException {
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revId);
-      return projectControl.canReadCommit(db.get(), rw, commit);
+      return projectControl.canReadCommit(db, repo, commit);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
new file mode 100644
index 0000000..3f4ca61
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.plugin;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Argument;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LfsPluginAuthCommand extends SshCommand {
+  private static final Logger log =
+      LoggerFactory.getLogger(LfsPluginAuthCommand.class);
+  private static final String CONFIGURATION_ERROR = "Server configuration error:"
+      + " LFS auth over SSH is not properly configured.";
+
+  public interface LfsSshPluginAuth {
+    String authenticate(CurrentUser user, List<String> args)
+        throws UnloggedFailure, Failure;
+  }
+
+  public static class Module extends CommandModule {
+    private final boolean pluginProvided;
+
+    @Inject
+    Module(@GerritServerConfig Config cfg) {
+      pluginProvided = cfg.getString("lfs", null, "plugin") != null;
+    }
+
+    @Override
+    protected void configure() {
+      if (pluginProvided) {
+        command("git-lfs-authenticate").to(LfsPluginAuthCommand.class);
+        DynamicItem.itemOf(binder(), LfsSshPluginAuth.class);
+      }
+    }
+  }
+
+  private final DynamicItem<LfsSshPluginAuth> auth;
+  private final Provider<CurrentUser> user;
+
+  @Argument(index = 0, multiValued = true, metaVar = "PARAMS")
+  private List<String> args = new ArrayList<>();
+
+  @Inject
+  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth,
+      Provider<CurrentUser> user) {
+    this.auth = auth;
+    this.user = user;
+  }
+
+  @Override
+  protected void run() throws UnloggedFailure, Exception {
+    LfsSshPluginAuth pluginAuth = auth.get();
+    if (pluginAuth == null) {
+      log.warn(CONFIGURATION_ERROR);
+      throw new UnloggedFailure(1, CONFIGURATION_ERROR);
+    }
+
+    stdout.print(pluginAuth.authenticate(user.get(), args));
+  }
+}
diff --git a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
index 70ea632..ae2a0a0 100644
--- a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
+++ b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
 
 import org.junit.Before;
 import org.junit.Test;
diff --git a/gerrit-util-cli/BUILD b/gerrit-util-cli/BUILD
new file mode 100644
index 0000000..f3be5f3
--- /dev/null
+++ b/gerrit-util-cli/BUILD
@@ -0,0 +1,13 @@
+java_library(
+  name = 'cli',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-common:annotations',
+    '//gerrit-common:server',
+    '//lib:args4j',
+    '//lib:guava',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index e70ab72..b5888b50 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -37,7 +37,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -59,6 +58,7 @@
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.ResourceBundle;
@@ -282,7 +282,7 @@
 
   @SuppressWarnings("rawtypes")
   private static Map<String, OptionHandler> index(List<OptionHandler> in) {
-    Map<String, OptionHandler> m = Maps.newHashMap();
+    Map<String, OptionHandler> m = new HashMap<>();
     for (OptionHandler handler : in) {
       if (handler.option instanceof NamedOptionDef) {
         NamedOptionDef def = (NamedOptionDef) handler.option;
@@ -352,7 +352,7 @@
     private void ensureOptionsInitialized() {
       if (optionsList == null) {
         help = new HelpOption();
-        optionsList = Lists.newArrayList();
+        optionsList = new ArrayList<>();
         addOption(help, help);
       }
     }
diff --git a/gerrit-util-http/BUCK b/gerrit-util-http/BUCK
index e9b2a3d..cfab096 100644
--- a/gerrit-util-http/BUCK
+++ b/gerrit-util-http/BUCK
@@ -15,7 +15,7 @@
     '//lib:guava',
     '//lib:servlet-api-3_1',
     '//lib/httpcomponents:httpclient',
-    '//lib/jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit:jgit',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-util-http/BUILD b/gerrit-util-http/BUILD
new file mode 100644
index 0000000..0e3ac0e
--- /dev/null
+++ b/gerrit-util-http/BUILD
@@ -0,0 +1,39 @@
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+java_library(
+  name = 'http',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = ['//lib:servlet-api-3_1'],
+  visibility = ['//visibility:public'],
+)
+
+TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
+
+java_library(
+  name = 'testutil',
+  srcs = TESTUTIL_SRCS,
+  deps = [
+    '//gerrit-extension-api:api',
+    '//lib:guava',
+    '//lib:servlet-api-3_1',
+    '//lib/httpcomponents:httpclient',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'http_tests',
+  srcs = glob(
+    ['src/test/java/**/*.java'],
+    exclude = TESTUTIL_SRCS,
+  ),
+  deps = [
+    ':http',
+    ':testutil',
+    '//lib:junit',
+    '//lib:servlet-api-3_1-without-neverlink',
+    '//lib:truth',
+    '//lib/easymock:easymock',
+  ],
+)
diff --git a/gerrit-util-ssl/BUILD b/gerrit-util-ssl/BUILD
new file mode 100644
index 0000000..6333d45
--- /dev/null
+++ b/gerrit-util-ssl/BUILD
@@ -0,0 +1,5 @@
+java_library(
+  name = 'ssl',
+  srcs = glob(['src/main/java/**/*.java']),
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-war/BUCK b/gerrit-war/BUCK
index d5c85ad..6d74a83 100644
--- a/gerrit-war/BUCK
+++ b/gerrit-war/BUCK
@@ -23,8 +23,8 @@
     '//lib:gwtorm',
     '//lib/guice:guice',
     '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/log:api',
-    '//lib/jgit:jgit',
   ],
   provided_deps = ['//lib:servlet-api-3_1'],
   visibility = [
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index cc352e9..eef6b87 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.12.8</version>
+  <version>2.13.10</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -23,15 +23,24 @@
 
   <developers>
     <developer>
+      <name>Andrew Bonventre</name>
+    </developer>
+    <developer>
       <name>Dave Borowitz</name>
     </developer>
     <developer>
+      <name>David Ostrovsky</name>
+    </developer>
+    <developer>
       <name>David Pursehouse</name>
     </developer>
     <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Hugo Arès</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
index 33f9ae3..5a97d20 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -31,7 +32,7 @@
   private final Path path;
 
   @Inject
-  SitePathFromSystemConfigProvider(SchemaFactory<ReviewDb> schemaFactory)
+  SitePathFromSystemConfigProvider(@ReviewDbFactory SchemaFactory<ReviewDb> schemaFactory)
       throws OrmException {
     path = read(schemaFactory);
   }
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
index 665d420..d35f31d 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.pgm.init.InitPlugins.JAR;
 import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.pgm.init.PluginsDistribution;
 import com.google.inject.Singleton;
 
@@ -26,6 +25,7 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
 import java.util.List;
 
 import javax.servlet.ServletContext;
@@ -36,7 +36,7 @@
   private ServletContext servletContext;
   private File pluginsDir;
 
-  public UnzippedDistribution(ServletContext servletContext) {
+  UnzippedDistribution(ServletContext servletContext) {
     this.servletContext = servletContext;
   }
 
@@ -57,7 +57,7 @@
 
   @Override
   public List<String> listPluginNames() throws FileNotFoundException {
-    List<String> names = Lists.newArrayList();
+    List<String> names = new ArrayList<>();
     String[] list = getPluginsDir().list();
     if (list != null) {
       for (String pluginJarName : list) {
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index b76f0ec..1120e0d 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -18,14 +18,16 @@
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.common.base.Splitter;
-import com.google.gerrit.common.ChangeHookRunner;
+import com.google.gerrit.common.EventBroker;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
@@ -40,16 +42,18 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
+import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.mail.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
@@ -57,6 +61,7 @@
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.DatabaseModule;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
@@ -67,6 +72,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 import com.google.inject.AbstractModule;
 import com.google.inject.CreationException;
 import com.google.inject.Guice;
@@ -259,14 +265,7 @@
           listener().to(ReviewDbDataSourceProvider.class);
         }
       });
-    }
-    modules.add(new DatabaseModule());
-    return Guice.createInjector(PRODUCTION, modules);
-  }
 
-  private Injector createCfgInjector() {
-    final List<Module> modules = new ArrayList<>();
-    if (sitePath == null) {
       // If we didn't get the site path from the system property
       // we need to get it from the database, as that's our old
       // method of locating the site path on disk.
@@ -280,8 +279,15 @@
       });
       modules.add(new GerritServerConfigModule());
     }
+    modules.add(new DatabaseModule());
+    modules.add(new ConfigNotesMigration.Module());
+    modules.add(new DropWizardMetricMaker.ApiModule());
+    return Guice.createInjector(PRODUCTION, modules);
+  }
+
+  private Injector createCfgInjector() {
+    final List<Module> modules = new ArrayList<>();
     modules.add(new SchemaModule());
-    modules.add(new LocalDiskRepositoryManager.Module());
     modules.add(SchemaVersionCheck.module());
     modules.add(new AuthConfigModule());
     return dbInjector.createChildInjector(modules);
@@ -289,14 +295,17 @@
 
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
+    modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
-    modules.add(new WorkQueue.Module());
-    modules.add(new ChangeHookRunner.Module());
+    modules.add(new EventBroker.Module());
+    modules.add(new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    modules.add(new StreamEventsApiListener.Module());
     modules.add(new ReceiveCommitsExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new ChangeCacheImplModule(false));
+    modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(new SmtpEmailSender.Module());
@@ -304,13 +313,12 @@
     modules.add(new PluginRestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
-    switch (indexType) {
-      case LUCENE:
-        modules.add(new LuceneIndexModule());
-        break;
-      default:
-        throw new IllegalStateException("unsupported index.type = " + indexType);
-    }
+
+    // Index module shutdown must happen before work queue shutdown, otherwise
+    // work queue can get stuck waiting on index futures that will never return.
+    modules.add(createIndexModule());
+
+    modules.add(new WorkQueue.Module());
     modules.add(new CanonicalWebUrlModule() {
       @Override
       protected Class<? extends Provider<String>> provider() {
@@ -321,7 +329,8 @@
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
-        bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+        bind(GerritOptions.class)
+            .toInstance(new GerritOptions(config, false, false, false));
       }
     });
     modules.add(new GarbageCollectionModule());
@@ -329,6 +338,15 @@
     return cfgInjector.createChildInjector(modules);
   }
 
+  private Module createIndexModule() {
+    switch (indexType) {
+      case LUCENE:
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      default:
+        throw new IllegalStateException("unsupported index.type = " + indexType);
+    }
+  }
+
   private void initIndexType() {
     indexType = IndexModule.getIndexType(cfgInjector);
   }
@@ -338,7 +356,8 @@
     modules.add(sysInjector.getInstance(SshModule.class));
     modules.add(new SshHostKeyModule());
     modules.add(new DefaultCommandModule(false,
-        sysInjector.getInstance(DownloadConfig.class)));
+        sysInjector.getInstance(DownloadConfig.class),
+        sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
     if (indexType == IndexType.LUCENE) {
       modules.add(new IndexCommandsModule());
     }
@@ -349,8 +368,10 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(RequestContextFilter.module());
     modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
     modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     if (sshInjector != null) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
     } else {
@@ -367,6 +388,9 @@
     }
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
+    // StaticModule contains a "/*" wildcard, place it last.
+    modules.add(sysInjector.getInstance(StaticModule.class));
+
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/gerrit-war/src/main/resources/log4j.properties b/gerrit-war/src/main/resources/log4j.properties
index 8bc9bb2..bc5e575 100644
--- a/gerrit-war/src/main/resources/log4j.properties
+++ b/gerrit-war/src/main/resources/log4j.properties
@@ -23,7 +23,7 @@
 log4j.logger.org.apache.mina=WARN
 log4j.logger.org.apache.sshd.common=WARN
 log4j.logger.org.apache.sshd.server=WARN
-log4j.logger.org.apache.sshd.common.keyprovider.FileKeyPairProvider=INFO
+log4j.logger.org.apache.sshd.common.keyprovider.AbstractFileKeyPairProvider=INFO
 log4j.logger.com.google.gerrit.sshd.GerritServerSession=WARN
 
 # Silence non-critical messages from mime-util.
diff --git a/lib/BUCK b/lib/BUCK
index ee4edd7..327a203 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -1,38 +1,47 @@
 include_defs('//lib/maven.defs')
+include_defs('//lib/GUAVA_VERSION')
 
+define_license(name = 'antlr')
 define_license(name = 'Apache1.1')
 define_license(name = 'Apache2.0')
-define_license(name = 'CC-BY3.0')
-define_license(name = 'MPL1.1')
-define_license(name = 'PublicDomain')
-define_license(name = 'antlr')
 define_license(name = 'args4j')
 define_license(name = 'asciidoctor')
 define_license(name = 'automaton')
 define_license(name = 'bouncycastle')
+define_license(name = 'CC-BY3.0-unported')
 define_license(name = 'clippy')
-define_license(name = 'codemirror')
+define_license(name = 'codemirror-minified')
+define_license(name = 'codemirror-original')
 define_license(name = 'diffy')
-define_license(name = 'drifty')
-define_license(name = 'freebie_application_icon_set')
+define_license(name = 'es6-promise')
+define_license(name = 'fetch')
 define_license(name = 'h2')
+define_license(name = 'highlightjs')
 define_license(name = 'jgit')
 define_license(name = 'jsch')
+define_license(name = 'MPL1.1')
+define_license(name = 'moment')
+define_license(name = 'OFL1.1')
 define_license(name = 'ow2')
+define_license(name = 'page.js')
+define_license(name = 'polymer')
 define_license(name = 'postgresql')
 define_license(name = 'prologcafe')
+define_license(name = 'promise-polyfill')
 define_license(name = 'protobuf')
+define_license(name = 'PublicDomain')
+define_license(name = 'silk_icons')
 define_license(name = 'slf4j')
 define_license(name = 'xz')
+
 define_license(name = 'DO_NOT_DISTRIBUTE')
 
 maven_jar(
   name = 'gwtorm_client',
-  id = 'com.google.gerrit:gwtorm:1.14-20-gec13fdc',
-  bin_sha1 = '60c2f2a5584959343ad1b21c3c79ba0fe825ceac',
-  src_sha1 = '4c562a3aafd1c3828217ee178568ed3d34ec86eb',
+  id = 'com.google.gerrit:gwtorm:1.15',
+  bin_sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
+  src_sha1 = '9524088d6e46e299b12791cb1a63c4ba6a478b96',
   license = 'Apache2.0',
-  repository = GERRIT,
 )
 
 java_library(
@@ -44,28 +53,47 @@
 
 maven_jar(
   name = 'gwtjsonrpc',
-  id = 'gwtjsonrpc:gwtjsonrpc:1.7-2-g272ca32',
-  bin_sha1 = '91be25537f7e53e0b5ff5edb9a42ebfc56f764b6',
-  src_sha1 = '7e6d8892f2e3bf21a9854afcfd2534263636dcbc',
+  id = 'com.google.gerrit:gwtjsonrpc:1.9',
+  bin_sha1 = '458f55e92584fbd9ab91a89fa1c37654922a0f2b',
+  src_sha1 = 'ba539361c80a26f0d30a2f56068f6d83f44062d8',
   license = 'Apache2.0',
-  repository = GERRIT,
 )
 
 maven_jar(
   name = 'gson',
-  id = 'com.google.code.gson:gson:2.3.1',
-  sha1 = 'ecb6e1f8e4b0e84c4b886c2f14a1500caf309757',
+  id = 'com.google.code.gson:gson:2.7',
+  sha1 = '751f548c85fa49f330cecbb1875893f971b33c4e',
   license = 'Apache2.0',
 )
 
 maven_jar(
   name = 'guava',
-  id = 'com.google.guava:guava:19.0-rc2',
-  sha1 = '93e17f60bc524c2610b41c494bb829c11ca89436',
+  id = 'com.google.guava:guava:' + GUAVA_VERSION,
+  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
   license = 'Apache2.0',
 )
 
 maven_jar(
+  name = 'guava-retrying',
+  id = 'com.github.rholder:guava-retrying:2.0.0',
+  sha1 = '974bc0a04a11cc4806f7c20a34703bd23c34e7f4',
+  license = 'Apache2.0',
+  deps = [':jsr305'],
+)
+
+maven_jar(
+  name = 'jsr305',
+  id = 'com.google.code.findbugs:jsr305:3.0.1',
+  sha1 = 'f7be08ec23c21485b9b5a1cf1654c2ec8c58168d',
+  license = 'Apache2.0',
+  attach_source = False,
+  # Whitelist lib targets that have jsr305 as a dependency. Generally speaking
+  # Gerrit core should not depend on these annotations, and instead use
+  # equivalent annotations in com.google.gerrit.common.
+  visibility = ['//lib:guava-retrying'],
+)
+
+maven_jar(
   name = 'velocity',
   id = 'org.apache.velocity:velocity:1.7',
   sha1 = '2ceb567b8f3f21118ecdec129fe1271dbc09aa7a',
@@ -170,8 +198,8 @@
 
 maven_jar(
   name = 'postgresql',
-  id = 'postgresql:postgresql:9.1-901-1.jdbc4',
-  sha1 = '9bfabe48876ec38f6cbaa6931bad05c64a9ea942',
+  id = 'org.postgresql:postgresql:9.4.1211.jre7',
+  sha1 = '56b01e9e667f408818a6ef06a89598dbab80687d',
   license = 'postgresql',
   attach_source = False,
 )
@@ -213,8 +241,8 @@
 
 maven_jar(
   name = 'truth',
-  id = 'com.google.truth:truth:0.27',
-  sha1 = 'bd17774d2dc0fffa884d42c07d2537e86c67acd6',
+  id = 'com.google.truth:truth:0.28',
+  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
   license = 'DO_NOT_DISTRIBUTE',
   exported_deps = [
     ':guava',
@@ -228,14 +256,20 @@
   sha1 = '18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3',
   license = 'xz',
   attach_source = False,
-  visibility = ['//lib/jgit:jgit-archive'],
+  visibility = ['//gerrit-server:server'],
 )
 
 maven_jar(
-  name = 'javassist-3.17.1-GA',
-  # The GWT version is still at 3.16.1-GA, so those do not match
-  id = 'org.javassist:javassist:3.17.1-GA',
-  sha1 = '30c30512115866b6e0123f1913bc7735b9f76d08',
+  name = 'javassist',
+  id = 'org.javassist:javassist:3.20.0-GA',
+  sha1 = 'a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0',
   license = 'DO_NOT_DISTRIBUTE',
 )
 
+maven_jar(
+  name = 'blame-cache',
+  id = 'com/google/gitiles:blame-cache:0.1-9',
+  sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
+  license = 'Apache2.0',
+  repository = GERRIT,
+)
diff --git a/lib/BUILD b/lib/BUILD
new file mode 100644
index 0000000..e89e63c
--- /dev/null
+++ b/lib/BUILD
@@ -0,0 +1,204 @@
+java_library(
+  name = 'servlet-api-3_1',
+  neverlink = 1,
+  exports = ['@servlet_api_3_1//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'servlet-api-3_1-without-neverlink',
+  exports = ['@servlet_api_3_1//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gwtjsonrpc',
+  exports = ['@gwtjsonrpc//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gwtjsonrpc_src',
+  exports = ['@gwtjsonrpc_src//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gson',
+  exports = ['@gson//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gwtorm_client',
+  exports = ['@gwtorm_client//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gwtorm_client_src',
+  exports = ['@gwtorm_client_src//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'protobuf',
+  exports = ['@protobuf//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'gwtorm',
+  exports = [':gwtorm_client'],
+  runtime_deps = [':protobuf'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'guava',
+  exports = ['@guava//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'velocity',
+  exports = ['@velocity//jar'],
+  runtime_deps = [
+    '//lib/commons:collections',
+    '//lib/commons:lang',
+    '//lib/commons:oro',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jsch',
+  exports = ['@jsch//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'juniversalchardet',
+  exports = ['@juniversalchardet//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'args4j',
+  exports = ['@args4j//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'automaton',
+  exports = ['@automaton//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'pegdown',
+  exports = ['@pegdown//jar'],
+  runtime_deps = [':grappa'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'grappa',
+  exports = ['@grappa//jar'],
+  runtime_deps = [
+    ':jitescript',
+    '//lib/ow2:ow2-asm',
+    '//lib/ow2:ow2-asm-analysis',
+    '//lib/ow2:ow2-asm-tree',
+    '//lib/ow2:ow2-asm-util',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jitescript',
+  exports = ['@jitescript//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'tukaani-xz',
+  exports = ['@tukaani_xz//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'mime-util',
+  exports = ['@mime_util//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'guava-retrying',
+  exports = ['@guava_retrying//jar'],
+  runtime_deps = [':jsr305'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jsr305',
+  exports = ['@jsr305//jar'],
+)
+
+java_library(
+  name = 'blame-cache',
+  exports = ['@blame_cache//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'h2',
+  exports = ['@h2//jar'],
+  visibility = ['//visibility:public'],
+)
+
+
+java_library(
+  name = 'jimfs',
+  exports = ['@jimfs//jar'],
+  runtime_deps = [':guava'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'junit',
+  exports = [
+    '@junit//jar',
+    ':hamcrest-core',
+  ],
+  runtime_deps = [':hamcrest-core'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'hamcrest-core',
+  exports = ['@hamcrest_core//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'truth',
+  exports = [
+    '@truth//jar',
+    ':guava',
+    ':junit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'javassist',
+  exports = ['@javassist//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'derby',
+  exports = ['@derby//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/GUAVA_VERSION b/lib/GUAVA_VERSION
new file mode 100644
index 0000000..f889e2b
--- /dev/null
+++ b/lib/GUAVA_VERSION
@@ -0,0 +1,2 @@
+GUAVA_VERSION = '19.0'
+GUAVA_DOC_URL = 'https://google.github.io/guava/releases/' + GUAVA_VERSION + '/api/docs/'
diff --git a/lib/JGIT_VERSION b/lib/JGIT_VERSION
new file mode 100644
index 0000000..abd06f3
--- /dev/null
+++ b/lib/JGIT_VERSION
@@ -0,0 +1,6 @@
+include_defs('//lib/maven.defs')
+
+REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
+VERS = '4.5.2.201704071617-r'
+DOC_VERS = VERS # Set to VERS unless using a snapshot
+JGIT_DOC_URL="http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
diff --git a/lib/LICENSE-CC-BY3.0 b/lib/LICENSE-CC-BY3.0
deleted file mode 100644
index 39dbc91..0000000
--- a/lib/LICENSE-CC-BY3.0
+++ /dev/null
@@ -1,333 +0,0 @@
-link:http://creativecommons.org/licenses/by/3.0/us/[CC-BY 3.0]
-
-THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
-CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
-PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
-WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
-PROHIBITED.
-
-BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
-AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
-LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
-THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
-TERMS AND CONDITIONS.
-
-1.  Definitions
-
-  a.  "Adaptation" means a work based upon the Work, or upon the Work
-      and other pre-existing works, such as a translation, adaptation,
-      derivative work, arrangement of music or other alterations of a
-      literary or artistic work, or phonogram or performance and
-      includes cinematographic adaptations or any other form in which
-      the Work may be recast, transformed, or adapted including in any
-      form recognizably derived from the original, except that a work
-      that constitutes a Collection will not be considered an
-      Adaptation for the purpose of this License.  For the avoidance
-      of doubt, where the Work is a musical work, performance or
-      phonogram, the synchronization of the Work in timed-relation
-      with a moving image ("synching") will be considered an
-      Adaptation for the purpose of this License.
-
-  b.  "Collection" means a collection of literary or artistic works,
-      such as encyclopedias and anthologies, or performances,
-      phonograms or broadcasts, or other works or subject matter other
-      than works listed in Section 1(f) below, which, by reason of the
-      selection and arrangement of their contents, constitute
-      intellectual creations, in which the Work is included in its
-      entirety in unmodified form along with one or more other
-      contributions, each constituting separate and independent works
-      in themselves, which together are assembled into a collective
-      whole.  A work that constitutes a Collection will not be
-      considered an Adaptation (as defined above) for the purposes of
-      this License.
-
-  c.  "Distribute" means to make available to the public the original
-      and copies of the Work or Adaptation, as appropriate, through
-      sale or other transfer of ownership.
-
-  d.  "Licensor" means the individual, individuals, entity or entities
-      that offer(s) the Work under the terms of this License.
-
-  e.  "Original Author" means, in the case of a literary or artistic
-      work, the individual, individuals, entity or entities who
-      created the Work or if no individual or entity can be
-      identified, the publisher; and in addition (i) in the case of a
-      performance the actors, singers, musicians, dancers, and other
-      persons who act, sing, deliver, declaim, play in, interpret or
-      otherwise perform literary or artistic works or expressions of
-      folklore; (ii) in the case of a phonogram the producer being the
-      person or legal entity who first fixes the sounds of a
-      performance or other sounds; and, (iii) in the case of
-      broadcasts, the organization that transmits the broadcast.
-
-  f.  "Work" means the literary and/or artistic work offered under the
-      terms of this License including without limitation any
-      production in the literary, scientific and artistic domain,
-      whatever may be the mode or form of its expression including
-      digital form, such as a book, pamphlet and other writing; a
-      lecture, address, sermon or other work of the same nature; a
-      dramatic or dramatico-musical work; a choreographic work or
-      entertainment in dumb show; a musical composition with or
-      without words; a cinematographic work to which are assimilated
-      works expressed by a process analogous to cinematography; a work
-      of drawing, painting, architecture, sculpture, engraving or
-      lithography; a photographic work to which are assimilated works
-      expressed by a process analogous to photography; a work of
-      applied art; an illustration, map, plan, sketch or
-      three-dimensional work relative to geography, topography,
-      architecture or science; a performance; a broadcast; a
-      phonogram; a compilation of data to the extent it is protected
-      as a copyrightable work; or a work performed by a variety or
-      circus performer to the extent it is not otherwise considered a
-      literary or artistic work.
-
-  g.  "You" means an individual or entity exercising rights under this
-      License who has not previously violated the terms of this
-      License with respect to the Work, or who has received express
-      permission from the Licensor to exercise rights under this
-      License despite a previous violation.
-
-  h.  "Publicly Perform" means to perform public recitations of the
-      Work and to communicate to the public those public recitations,
-      by any means or process, including by wire or wireless means or
-      public digital performances; to make available to the public
-      Works in such a way that members of the public may access these
-      Works from a place and at a place individually chosen by them;
-      to perform the Work to the public by any means or process and
-      the communication to the public of the performances of the Work,
-      including by public digital performance; to broadcast and
-      rebroadcast the Work by any means including signs, sounds or
-      images.
-
-  i.  "Reproduce" means to make copies of the Work by any means
-      including without limitation by sound or visual recordings and
-      the right of fixation and reproducing fixations of the Work,
-      including storage of a protected performance or phonogram in
-      digital form or other electronic medium.
-
-2.  Fair Dealing Rights.  Nothing in this License is intended to
-    reduce, limit, or restrict any uses free from copyright or rights
-    arising from limitations or exceptions that are provided for in
-    connection with the copyright protection under copyright law or
-    other applicable laws.
-
-3.  License Grant.  Subject to the terms and conditions of this
-    License, Licensor hereby grants You a worldwide, royalty-free,
-    non-exclusive, perpetual (for the duration of the applicable
-    copyright) license to exercise the rights in the Work as stated
-    below:
-
-  a.  to Reproduce the Work, to incorporate the Work into one or more
-      Collections, and to Reproduce the Work as incorporated in the
-      Collections;
-
-  b.  to create and Reproduce Adaptations provided that any such
-      Adaptation, including any translation in any medium, takes
-      reasonable steps to clearly label, demarcate or otherwise
-      identify that changes were made to the original Work.  For
-      example, a translation could be marked "The original work was
-      translated from English to Spanish," or a modification could
-      indicate "The original work has been modified.";
-
-  c.  to Distribute and Publicly Perform the Work including as
-      incorporated in Collections; and,
-
-  d.  to Distribute and Publicly Perform Adaptations.
-
-  e.  For the avoidance of doubt:
-
-    i.   Non-waivable Compulsory License Schemes.  In those
-	     jurisdictions in which the right to collect royalties
-	     through any statutory or compulsory licensing scheme
-	     cannot be waived, the Licensor reserves the exclusive
-	     right to collect such royalties for any exercise by You
-	     of the rights granted under this License;
-
-    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
-	     in which the right to collect royalties through any
-	     statutory or compulsory licensing scheme can be waived,
-	     the Licensor waives the exclusive right to collect such
-	     royalties for any exercise by You of the rights granted
-	     under this License; and,
-
-    iii. Voluntary License Schemes.  The Licensor waives the right to
-	     collect royalties, whether individually or, in the event
-	     that the Licensor is a member of a collecting society
-	     that administers voluntary licensing schemes, via that
-	     society, from any exercise by You of the rights granted
-	     under this License.
-
-The above rights may be exercised in all media and formats whether now
-known or hereafter devised.  The above rights include the right to
-make such modifications as are technically necessary to exercise the
-rights in other media and formats.  Subject to Section 8(f), all
-rights not expressly granted by Licensor are hereby reserved.
-
-4.  Restrictions.  The license granted in Section 3 above is expressly
-    made subject to and limited by the following restrictions:
-
-  a.  You may Distribute or Publicly Perform the Work only under the
-      terms of this License.  You must include a copy of, or the
-      Uniform Resource Identifier (URI) for, this License with every
-      copy of the Work You Distribute or Publicly Perform.  You may
-      not offer or impose any terms on the Work that restrict the
-      terms of this License or the ability of the recipient of the
-      Work to exercise the rights granted to that recipient under the
-      terms of the License.  You may not sublicense the Work.  You
-      must keep intact all notices that refer to this License and to
-      the disclaimer of warranties with every copy of the Work You
-      Distribute or Publicly Perform.  When You Distribute or Publicly
-      Perform the Work, You may not impose any effective technological
-      measures on the Work that restrict the ability of a recipient of
-      the Work from You to exercise the rights granted to that
-      recipient under the terms of the License.  This Section 4(a)
-      applies to the Work as incorporated in a Collection, but this
-      does not require the Collection apart from the Work itself to be
-      made subject to the terms of this License.  If You create a
-      Collection, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Collection any credit as
-      required by Section 4(b), as requested.  If You create an
-      Adaptation, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Adaptation any credit as
-      required by Section 4(b), as requested.
-
-  b.  If You Distribute, or Publicly Perform the Work or any
-      Adaptations or Collections, You must, unless a request has been
-      made pursuant to Section 4(a), keep intact all copyright notices
-      for the Work and provide, reasonable to the medium or means You
-      are utilizing: (i) the name of the Original Author (or
-      pseudonym, if applicable) if supplied, and/or if the Original
-      Author and/or Licensor designate another party or parties (e.g.,
-      a sponsor institute, publishing entity, journal) for attribution
-      ("Attribution Parties") in Licensor's copyright notice, terms of
-      service or by other reasonable means, the name of such party or
-      parties; (ii) the title of the Work if supplied; (iii) to the
-      extent reasonably practicable, the URI, if any, that Licensor
-      specifies to be associated with the Work, unless such URI does
-      not refer to the copyright notice or licensing information for
-      the Work; and (iv) , consistent with Section 3(b), in the case
-      of an Adaptation, a credit identifying the use of the Work in
-      the Adaptation (e.g., "French translation of the Work by
-      Original Author," or "Screenplay based on original Work by
-      Original Author").  The credit required by this Section 4 (b)
-      may be implemented in any reasonable manner; provided, however,
-      that in the case of a Adaptation or Collection, at a minimum
-      such credit will appear, if a credit for all contributing
-      authors of the Adaptation or Collection appears, then as part of
-      these credits and in a manner at least as prominent as the
-      credits for the other contributing authors.  For the avoidance
-      of doubt, You may only use the credit required by this Section
-      for the purpose of attribution in the manner set out above and,
-      by exercising Your rights under this License, You may not
-      implicitly or explicitly assert or imply any connection with,
-      sponsorship or endorsement by the Original Author, Licensor
-      and/or Attribution Parties, as appropriate, of You or Your use
-      of the Work, without the separate, express prior written
-      permission of the Original Author, Licensor and/or Attribution
-      Parties.
-
-  c.  Except as otherwise agreed in writing by the Licensor or as may
-      be otherwise permitted by applicable law, if You Reproduce,
-      Distribute or Publicly Perform the Work either by itself or as
-      part of any Adaptations or Collections, You must not distort,
-      mutilate, modify or take other derogatory action in relation to
-      the Work which would be prejudicial to the Original Author's
-      honor or reputation.  Licensor agrees that in those
-      jurisdictions (e.g.  Japan), in which any exercise of the right
-      granted in Section 3(b) of this License (the right to make
-      Adaptations) would be deemed to be a distortion, mutilation,
-      modification or other derogatory action prejudicial to the
-      Original Author's honor and reputation, the Licensor will waive
-      or not assert, as appropriate, this Section, to the fullest
-      extent permitted by the applicable national law, to enable You
-      to reasonably exercise Your right under Section 3(b) of this
-      License (right to make Adaptations) but not otherwise.
-
-5.  Representations, Warranties and Disclaimer
-
-UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
-LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
-WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
-STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
-TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
-NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
-OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
-SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
-SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
-
-6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
-    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
-    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
-    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
-    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
-    DAMAGES.
-
-7.  Termination
-
-  a.  This License and the rights granted hereunder will terminate
-      automatically upon any breach by You of the terms of this
-      License.  Individuals or entities who have received Adaptations
-      or Collections from You under this License, however, will not
-      have their licenses terminated provided such individuals or
-      entities remain in full compliance with those licenses.
-      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
-      this License.
-
-  b.  Subject to the above terms and conditions, the license granted
-      here is perpetual (for the duration of the applicable copyright
-      in the Work).  Notwithstanding the above, Licensor reserves the
-      right to release the Work under different license terms or to
-      stop distributing the Work at any time; provided, however that
-      any such election will not serve to withdraw this License (or
-      any other license that has been, or is required to be, granted
-      under the terms of this License), and this License will continue
-      in full force and effect unless terminated as stated above.
-
-8. Miscellaneous
-
-  a.  Each time You Distribute or Publicly Perform the Work or a
-      Collection, the Licensor offers to the recipient a license to
-      the Work on the same terms and conditions as the license granted
-      to You under this License.
-
-  b.  Each time You Distribute or Publicly Perform an Adaptation,
-      Licensor offers to the recipient a license to the original Work
-      on the same terms and conditions as the license granted to You
-      under this License.
-
-  c.  If any provision of this License is invalid or unenforceable
-      under applicable law, it shall not affect the validity or
-      enforceability of the remainder of the terms of this License,
-      and without further action by the parties to this agreement,
-      such provision shall be reformed to the minimum extent necessary
-      to make such provision valid and enforceable.
-
-  d.  No term or provision of this License shall be deemed waived and
-      no breach consented to unless such waiver or consent shall be in
-      writing and signed by the party to be charged with such waiver
-      or consent.
-
-  e.  This License constitutes the entire agreement between the
-      parties with respect to the Work licensed here.  There are no
-      understandings, agreements or representations with respect to
-      the Work not specified here.  Licensor shall not be bound by any
-      additional provisions that may appear in any communication from
-      You.  This License may not be modified without the mutual
-      written agreement of the Licensor and You.
-
-  f.  The rights granted under, and the subject matter referenced, in
-      this License were drafted utilizing the terminology of the Berne
-      Convention for the Protection of Literary and Artistic Works (as
-      amended on September 28, 1979), the Rome Convention of 1961, the
-      WIPO Copyright Treaty of 1996, the WIPO Performances and
-      Phonograms Treaty of 1996 and the Universal Copyright Convention
-      (as revised on July 24, 1971).  These rights and subject matter
-      take effect in the relevant jurisdiction in which the License
-      terms are sought to be enforced according to the corresponding
-      provisions of the implementation of those treaty provisions in
-      the applicable national law.  If the standard suite of rights
-      granted under applicable copyright law includes additional
-      rights not granted under this License, such additional rights
-      are deemed to be included in the License; this License is not
-      intended to restrict the license of any rights under applicable
-      law.
diff --git a/lib/LICENSE-CC-BY3.0-unported b/lib/LICENSE-CC-BY3.0-unported
new file mode 100644
index 0000000..d2f2550
--- /dev/null
+++ b/lib/LICENSE-CC-BY3.0-unported
@@ -0,0 +1,333 @@
+link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0]
+
+THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
+CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
+PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
+WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
+PROHIBITED.
+
+BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
+AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
+LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
+THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
+TERMS AND CONDITIONS.
+
+1.  Definitions
+
+  a.  "Adaptation" means a work based upon the Work, or upon the Work
+      and other pre-existing works, such as a translation, adaptation,
+      derivative work, arrangement of music or other alterations of a
+      literary or artistic work, or phonogram or performance and
+      includes cinematographic adaptations or any other form in which
+      the Work may be recast, transformed, or adapted including in any
+      form recognizably derived from the original, except that a work
+      that constitutes a Collection will not be considered an
+      Adaptation for the purpose of this License.  For the avoidance
+      of doubt, where the Work is a musical work, performance or
+      phonogram, the synchronization of the Work in timed-relation
+      with a moving image ("synching") will be considered an
+      Adaptation for the purpose of this License.
+
+  b.  "Collection" means a collection of literary or artistic works,
+      such as encyclopedias and anthologies, or performances,
+      phonograms or broadcasts, or other works or subject matter other
+      than works listed in Section 1(f) below, which, by reason of the
+      selection and arrangement of their contents, constitute
+      intellectual creations, in which the Work is included in its
+      entirety in unmodified form along with one or more other
+      contributions, each constituting separate and independent works
+      in themselves, which together are assembled into a collective
+      whole.  A work that constitutes a Collection will not be
+      considered an Adaptation (as defined above) for the purposes of
+      this License.
+
+  c.  "Distribute" means to make available to the public the original
+      and copies of the Work or Adaptation, as appropriate, through
+      sale or other transfer of ownership.
+
+  d.  "Licensor" means the individual, individuals, entity or entities
+      that offer(s) the Work under the terms of this License.
+
+  e.  "Original Author" means, in the case of a literary or artistic
+      work, the individual, individuals, entity or entities who
+      created the Work or if no individual or entity can be
+      identified, the publisher; and in addition (i) in the case of a
+      performance the actors, singers, musicians, dancers, and other
+      persons who act, sing, deliver, declaim, play in, interpret or
+      otherwise perform literary or artistic works or expressions of
+      folklore; (ii) in the case of a phonogram the producer being the
+      person or legal entity who first fixes the sounds of a
+      performance or other sounds; and, (iii) in the case of
+      broadcasts, the organization that transmits the broadcast.
+
+  f.  "Work" means the literary and/or artistic work offered under the
+      terms of this License including without limitation any
+      production in the literary, scientific and artistic domain,
+      whatever may be the mode or form of its expression including
+      digital form, such as a book, pamphlet and other writing; a
+      lecture, address, sermon or other work of the same nature; a
+      dramatic or dramatico-musical work; a choreographic work or
+      entertainment in dumb show; a musical composition with or
+      without words; a cinematographic work to which are assimilated
+      works expressed by a process analogous to cinematography; a work
+      of drawing, painting, architecture, sculpture, engraving or
+      lithography; a photographic work to which are assimilated works
+      expressed by a process analogous to photography; a work of
+      applied art; an illustration, map, plan, sketch or
+      three-dimensional work relative to geography, topography,
+      architecture or science; a performance; a broadcast; a
+      phonogram; a compilation of data to the extent it is protected
+      as a copyrightable work; or a work performed by a variety or
+      circus performer to the extent it is not otherwise considered a
+      literary or artistic work.
+
+  g.  "You" means an individual or entity exercising rights under this
+      License who has not previously violated the terms of this
+      License with respect to the Work, or who has received express
+      permission from the Licensor to exercise rights under this
+      License despite a previous violation.
+
+  h.  "Publicly Perform" means to perform public recitations of the
+      Work and to communicate to the public those public recitations,
+      by any means or process, including by wire or wireless means or
+      public digital performances; to make available to the public
+      Works in such a way that members of the public may access these
+      Works from a place and at a place individually chosen by them;
+      to perform the Work to the public by any means or process and
+      the communication to the public of the performances of the Work,
+      including by public digital performance; to broadcast and
+      rebroadcast the Work by any means including signs, sounds or
+      images.
+
+  i.  "Reproduce" means to make copies of the Work by any means
+      including without limitation by sound or visual recordings and
+      the right of fixation and reproducing fixations of the Work,
+      including storage of a protected performance or phonogram in
+      digital form or other electronic medium.
+
+2.  Fair Dealing Rights.  Nothing in this License is intended to
+    reduce, limit, or restrict any uses free from copyright or rights
+    arising from limitations or exceptions that are provided for in
+    connection with the copyright protection under copyright law or
+    other applicable laws.
+
+3.  License Grant.  Subject to the terms and conditions of this
+    License, Licensor hereby grants You a worldwide, royalty-free,
+    non-exclusive, perpetual (for the duration of the applicable
+    copyright) license to exercise the rights in the Work as stated
+    below:
+
+  a.  to Reproduce the Work, to incorporate the Work into one or more
+      Collections, and to Reproduce the Work as incorporated in the
+      Collections;
+
+  b.  to create and Reproduce Adaptations provided that any such
+      Adaptation, including any translation in any medium, takes
+      reasonable steps to clearly label, demarcate or otherwise
+      identify that changes were made to the original Work.  For
+      example, a translation could be marked "The original work was
+      translated from English to Spanish," or a modification could
+      indicate "The original work has been modified.";
+
+  c.  to Distribute and Publicly Perform the Work including as
+      incorporated in Collections; and,
+
+  d.  to Distribute and Publicly Perform Adaptations.
+
+  e.  For the avoidance of doubt:
+
+    i.   Non-waivable Compulsory License Schemes.  In those
+	     jurisdictions in which the right to collect royalties
+	     through any statutory or compulsory licensing scheme
+	     cannot be waived, the Licensor reserves the exclusive
+	     right to collect such royalties for any exercise by You
+	     of the rights granted under this License;
+
+    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
+	     in which the right to collect royalties through any
+	     statutory or compulsory licensing scheme can be waived,
+	     the Licensor waives the exclusive right to collect such
+	     royalties for any exercise by You of the rights granted
+	     under this License; and,
+
+    iii. Voluntary License Schemes.  The Licensor waives the right to
+	     collect royalties, whether individually or, in the event
+	     that the Licensor is a member of a collecting society
+	     that administers voluntary licensing schemes, via that
+	     society, from any exercise by You of the rights granted
+	     under this License.
+
+The above rights may be exercised in all media and formats whether now
+known or hereafter devised.  The above rights include the right to
+make such modifications as are technically necessary to exercise the
+rights in other media and formats.  Subject to Section 8(f), all
+rights not expressly granted by Licensor are hereby reserved.
+
+4.  Restrictions.  The license granted in Section 3 above is expressly
+    made subject to and limited by the following restrictions:
+
+  a.  You may Distribute or Publicly Perform the Work only under the
+      terms of this License.  You must include a copy of, or the
+      Uniform Resource Identifier (URI) for, this License with every
+      copy of the Work You Distribute or Publicly Perform.  You may
+      not offer or impose any terms on the Work that restrict the
+      terms of this License or the ability of the recipient of the
+      Work to exercise the rights granted to that recipient under the
+      terms of the License.  You may not sublicense the Work.  You
+      must keep intact all notices that refer to this License and to
+      the disclaimer of warranties with every copy of the Work You
+      Distribute or Publicly Perform.  When You Distribute or Publicly
+      Perform the Work, You may not impose any effective technological
+      measures on the Work that restrict the ability of a recipient of
+      the Work from You to exercise the rights granted to that
+      recipient under the terms of the License.  This Section 4(a)
+      applies to the Work as incorporated in a Collection, but this
+      does not require the Collection apart from the Work itself to be
+      made subject to the terms of this License.  If You create a
+      Collection, upon notice from any Licensor You must, to the
+      extent practicable, remove from the Collection any credit as
+      required by Section 4(b), as requested.  If You create an
+      Adaptation, upon notice from any Licensor You must, to the
+      extent practicable, remove from the Adaptation any credit as
+      required by Section 4(b), as requested.
+
+  b.  If You Distribute, or Publicly Perform the Work or any
+      Adaptations or Collections, You must, unless a request has been
+      made pursuant to Section 4(a), keep intact all copyright notices
+      for the Work and provide, reasonable to the medium or means You
+      are utilizing: (i) the name of the Original Author (or
+      pseudonym, if applicable) if supplied, and/or if the Original
+      Author and/or Licensor designate another party or parties (e.g.,
+      a sponsor institute, publishing entity, journal) for attribution
+      ("Attribution Parties") in Licensor's copyright notice, terms of
+      service or by other reasonable means, the name of such party or
+      parties; (ii) the title of the Work if supplied; (iii) to the
+      extent reasonably practicable, the URI, if any, that Licensor
+      specifies to be associated with the Work, unless such URI does
+      not refer to the copyright notice or licensing information for
+      the Work; and (iv) , consistent with Section 3(b), in the case
+      of an Adaptation, a credit identifying the use of the Work in
+      the Adaptation (e.g., "French translation of the Work by
+      Original Author," or "Screenplay based on original Work by
+      Original Author").  The credit required by this Section 4 (b)
+      may be implemented in any reasonable manner; provided, however,
+      that in the case of a Adaptation or Collection, at a minimum
+      such credit will appear, if a credit for all contributing
+      authors of the Adaptation or Collection appears, then as part of
+      these credits and in a manner at least as prominent as the
+      credits for the other contributing authors.  For the avoidance
+      of doubt, You may only use the credit required by this Section
+      for the purpose of attribution in the manner set out above and,
+      by exercising Your rights under this License, You may not
+      implicitly or explicitly assert or imply any connection with,
+      sponsorship or endorsement by the Original Author, Licensor
+      and/or Attribution Parties, as appropriate, of You or Your use
+      of the Work, without the separate, express prior written
+      permission of the Original Author, Licensor and/or Attribution
+      Parties.
+
+  c.  Except as otherwise agreed in writing by the Licensor or as may
+      be otherwise permitted by applicable law, if You Reproduce,
+      Distribute or Publicly Perform the Work either by itself or as
+      part of any Adaptations or Collections, You must not distort,
+      mutilate, modify or take other derogatory action in relation to
+      the Work which would be prejudicial to the Original Author's
+      honor or reputation.  Licensor agrees that in those
+      jurisdictions (e.g.  Japan), in which any exercise of the right
+      granted in Section 3(b) of this License (the right to make
+      Adaptations) would be deemed to be a distortion, mutilation,
+      modification or other derogatory action prejudicial to the
+      Original Author's honor and reputation, the Licensor will waive
+      or not assert, as appropriate, this Section, to the fullest
+      extent permitted by the applicable national law, to enable You
+      to reasonably exercise Your right under Section 3(b) of this
+      License (right to make Adaptations) but not otherwise.
+
+5.  Representations, Warranties and Disclaimer
+
+UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
+LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
+WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
+STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
+TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
+NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
+OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
+SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
+SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
+
+6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
+    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
+    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
+    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
+    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+    DAMAGES.
+
+7.  Termination
+
+  a.  This License and the rights granted hereunder will terminate
+      automatically upon any breach by You of the terms of this
+      License.  Individuals or entities who have received Adaptations
+      or Collections from You under this License, however, will not
+      have their licenses terminated provided such individuals or
+      entities remain in full compliance with those licenses.
+      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
+      this License.
+
+  b.  Subject to the above terms and conditions, the license granted
+      here is perpetual (for the duration of the applicable copyright
+      in the Work).  Notwithstanding the above, Licensor reserves the
+      right to release the Work under different license terms or to
+      stop distributing the Work at any time; provided, however that
+      any such election will not serve to withdraw this License (or
+      any other license that has been, or is required to be, granted
+      under the terms of this License), and this License will continue
+      in full force and effect unless terminated as stated above.
+
+8. Miscellaneous
+
+  a.  Each time You Distribute or Publicly Perform the Work or a
+      Collection, the Licensor offers to the recipient a license to
+      the Work on the same terms and conditions as the license granted
+      to You under this License.
+
+  b.  Each time You Distribute or Publicly Perform an Adaptation,
+      Licensor offers to the recipient a license to the original Work
+      on the same terms and conditions as the license granted to You
+      under this License.
+
+  c.  If any provision of this License is invalid or unenforceable
+      under applicable law, it shall not affect the validity or
+      enforceability of the remainder of the terms of this License,
+      and without further action by the parties to this agreement,
+      such provision shall be reformed to the minimum extent necessary
+      to make such provision valid and enforceable.
+
+  d.  No term or provision of this License shall be deemed waived and
+      no breach consented to unless such waiver or consent shall be in
+      writing and signed by the party to be charged with such waiver
+      or consent.
+
+  e.  This License constitutes the entire agreement between the
+      parties with respect to the Work licensed here.  There are no
+      understandings, agreements or representations with respect to
+      the Work not specified here.  Licensor shall not be bound by any
+      additional provisions that may appear in any communication from
+      You.  This License may not be modified without the mutual
+      written agreement of the Licensor and You.
+
+  f.  The rights granted under, and the subject matter referenced, in
+      this License were drafted utilizing the terminology of the Berne
+      Convention for the Protection of Literary and Artistic Works (as
+      amended on September 28, 1979), the Rome Convention of 1961, the
+      WIPO Copyright Treaty of 1996, the WIPO Performances and
+      Phonograms Treaty of 1996 and the Universal Copyright Convention
+      (as revised on July 24, 1971).  These rights and subject matter
+      take effect in the relevant jurisdiction in which the License
+      terms are sought to be enforced according to the corresponding
+      provisions of the implementation of those treaty provisions in
+      the applicable national law.  If the standard suite of rights
+      granted under applicable copyright law includes additional
+      rights not granted under this License, such additional rights
+      are deemed to be included in the License; this License is not
+      intended to restrict the license of any rights under applicable
+      law.
diff --git a/lib/LICENSE-OFL1.1 b/lib/LICENSE-OFL1.1
new file mode 100644
index 0000000..0754257
--- /dev/null
+++ b/lib/LICENSE-OFL1.1
@@ -0,0 +1,93 @@
+Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/LICENSE-args4j b/lib/LICENSE-args4j
index 36cd75f..c49840b 100644
--- a/lib/LICENSE-args4j
+++ b/lib/LICENSE-args4j
@@ -1,32 +1,19 @@
-Copyright (c) 2003, Kohsuke Kawaguchi
-All rights reserved.
+Copyright (c) 2013 Kohsuke Kawaguchi and other contributors
 
-Redistribution and use in source and binary forms,
-with or without modification, are permitted provided
-that the following conditions are met:
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
 
-    * 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.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
 
-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.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/LICENSE-automaton b/lib/LICENSE-automaton
index 72dcb1c..1fc2206 100644
--- a/lib/LICENSE-automaton
+++ b/lib/LICENSE-automaton
@@ -1,8 +1,6 @@
-Copyright (c) 2007-2009, dk.brics.automaton
+Copyright (c) 2001-2011 Anders Moeller
 All rights reserved.
 
-http://www.opensource.org/licenses/bsd-license.php
-
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are met:
 
diff --git a/lib/LICENSE-bouncycastle b/lib/LICENSE-bouncycastle
index d17a4bc..1f866cb 100644
--- a/lib/LICENSE-bouncycastle
+++ b/lib/LICENSE-bouncycastle
@@ -1,4 +1,4 @@
-Copyright (c) 2000 - 2012 The Legion Of The Bouncy Castle
+Copyright (c) 2000 - 2015 The Legion of the Bouncy Castle Inc.
 (http://www.bouncycastle.org)
 
 Permission is hereby granted, free of charge, to any person obtaining
diff --git a/lib/LICENSE-codemirror b/lib/LICENSE-codemirror
deleted file mode 100644
index 7df9fec..0000000
--- a/lib/LICENSE-codemirror
+++ /dev/null
@@ -1,44 +0,0 @@
-Copyright (C) 2013 by Marijn Haverbeke <marijnh@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-----
-
-codemirror Python mode:
-----
-The MIT License
-
-Copyright (c) 2010 Timothy Farrell
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/lib/LICENSE-codemirror-minified b/lib/LICENSE-codemirror-minified
new file mode 100644
index 0000000..89f23625
--- /dev/null
+++ b/lib/LICENSE-codemirror-minified
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
+Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/LICENSE-codemirror-original b/lib/LICENSE-codemirror-original
new file mode 100644
index 0000000..7661321
--- /dev/null
+++ b/lib/LICENSE-codemirror-original
@@ -0,0 +1,19 @@
+Copyright (C) 2016 by Marijn Haverbeke <marijnh@gmail.com> and others
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/LICENSE-diffy b/lib/LICENSE-diffy
index 2b58536..e837a1a 100644
--- a/lib/LICENSE-diffy
+++ b/lib/LICENSE-diffy
@@ -1 +1 @@
-link:http://creativecommons.org/licenses/by/3.0/us/[CC-BY 3.0] (c) Sara Owens, inkylabs.com
+link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0] (c) Sara Owens, inkylabs.com
diff --git a/lib/LICENSE-drifty b/lib/LICENSE-drifty
deleted file mode 100644
index 18ab118..0000000
--- a/lib/LICENSE-drifty
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2014 Drifty (http://drifty.com/)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/lib/LICENSE-es6-promise b/lib/LICENSE-es6-promise
new file mode 100644
index 0000000..954ec59
--- /dev/null
+++ b/lib/LICENSE-es6-promise
@@ -0,0 +1,19 @@
+Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/LICENSE-fetch b/lib/LICENSE-fetch
new file mode 100644
index 0000000..0e319d5
--- /dev/null
+++ b/lib/LICENSE-fetch
@@ -0,0 +1,20 @@
+Copyright (c) 2014-2016 GitHub, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-freebie_application_icon_set b/lib/LICENSE-freebie_application_icon_set
deleted file mode 100644
index 753ab1f..0000000
--- a/lib/LICENSE-freebie_application_icon_set
+++ /dev/null
@@ -1 +0,0 @@
-link:http://creativecommons.org/licenses/by/3.0/us/[CC-BY 3.0] (c) Matt Gentile, link:http://tympanus.net/codrops/2012/10/02/freebie-application-icon-set-png-psd-csh/[Freebie: Application Icon Set]
diff --git a/lib/LICENSE-highlightjs b/lib/LICENSE-highlightjs
new file mode 100644
index 0000000..da266fa
--- /dev/null
+++ b/lib/LICENSE-highlightjs
@@ -0,0 +1,24 @@
+Copyright (c) 2006, Ivan Sagalaev
+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 highlight.js 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 REGENTS 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 REGENTS AND 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.
\ No newline at end of file
diff --git a/lib/LICENSE-moment b/lib/LICENSE-moment
new file mode 100644
index 0000000..9ee5374
--- /dev/null
+++ b/lib/LICENSE-moment
@@ -0,0 +1,22 @@
+Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-page.js b/lib/LICENSE-page.js
new file mode 100644
index 0000000..78152a9
--- /dev/null
+++ b/lib/LICENSE-page.js
@@ -0,0 +1,20 @@
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the 'Software'), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-polymer b/lib/LICENSE-polymer
new file mode 100644
index 0000000..322c5a8
--- /dev/null
+++ b/lib/LICENSE-polymer
@@ -0,0 +1,27 @@
+Copyright (c) 2014 The Polymer 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.
diff --git a/lib/LICENSE-promise-polyfill b/lib/LICENSE-promise-polyfill
new file mode 100644
index 0000000..6f7c0123
--- /dev/null
+++ b/lib/LICENSE-promise-polyfill
@@ -0,0 +1,20 @@
+Copyright (c) 2014 Taylor Hakes
+Copyright (c) 2014 Forbes Lindesay
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/LICENSE-silk_icons b/lib/LICENSE-silk_icons
new file mode 100644
index 0000000..b7417f6
--- /dev/null
+++ b/lib/LICENSE-silk_icons
@@ -0,0 +1,338 @@
+link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0] (c) Mark James, link:http://famfamfam.com/lab/icons/silk/[SILK ICONS]
+
+As an author, I would appreciate a reference to my authorship of the Silk
+icon set contents within a readme file or equivalent documentation for
+the software which includes the set or a subset of the icons contained
+within.
+
+THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
+CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
+PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
+WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
+PROHIBITED.
+
+BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
+AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
+LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
+THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
+TERMS AND CONDITIONS.
+
+1.  Definitions
+
+  a.  "Adaptation" means a work based upon the Work, or upon the Work
+      and other pre-existing works, such as a translation, adaptation,
+      derivative work, arrangement of music or other alterations of a
+      literary or artistic work, or phonogram or performance and
+      includes cinematographic adaptations or any other form in which
+      the Work may be recast, transformed, or adapted including in any
+      form recognizably derived from the original, except that a work
+      that constitutes a Collection will not be considered an
+      Adaptation for the purpose of this License.  For the avoidance
+      of doubt, where the Work is a musical work, performance or
+      phonogram, the synchronization of the Work in timed-relation
+      with a moving image ("synching") will be considered an
+      Adaptation for the purpose of this License.
+
+  b.  "Collection" means a collection of literary or artistic works,
+      such as encyclopedias and anthologies, or performances,
+      phonograms or broadcasts, or other works or subject matter other
+      than works listed in Section 1(f) below, which, by reason of the
+      selection and arrangement of their contents, constitute
+      intellectual creations, in which the Work is included in its
+      entirety in unmodified form along with one or more other
+      contributions, each constituting separate and independent works
+      in themselves, which together are assembled into a collective
+      whole.  A work that constitutes a Collection will not be
+      considered an Adaptation (as defined above) for the purposes of
+      this License.
+
+  c.  "Distribute" means to make available to the public the original
+      and copies of the Work or Adaptation, as appropriate, through
+      sale or other transfer of ownership.
+
+  d.  "Licensor" means the individual, individuals, entity or entities
+      that offer(s) the Work under the terms of this License.
+
+  e.  "Original Author" means, in the case of a literary or artistic
+      work, the individual, individuals, entity or entities who
+      created the Work or if no individual or entity can be
+      identified, the publisher; and in addition (i) in the case of a
+      performance the actors, singers, musicians, dancers, and other
+      persons who act, sing, deliver, declaim, play in, interpret or
+      otherwise perform literary or artistic works or expressions of
+      folklore; (ii) in the case of a phonogram the producer being the
+      person or legal entity who first fixes the sounds of a
+      performance or other sounds; and, (iii) in the case of
+      broadcasts, the organization that transmits the broadcast.
+
+  f.  "Work" means the literary and/or artistic work offered under the
+      terms of this License including without limitation any
+      production in the literary, scientific and artistic domain,
+      whatever may be the mode or form of its expression including
+      digital form, such as a book, pamphlet and other writing; a
+      lecture, address, sermon or other work of the same nature; a
+      dramatic or dramatico-musical work; a choreographic work or
+      entertainment in dumb show; a musical composition with or
+      without words; a cinematographic work to which are assimilated
+      works expressed by a process analogous to cinematography; a work
+      of drawing, painting, architecture, sculpture, engraving or
+      lithography; a photographic work to which are assimilated works
+      expressed by a process analogous to photography; a work of
+      applied art; an illustration, map, plan, sketch or
+      three-dimensional work relative to geography, topography,
+      architecture or science; a performance; a broadcast; a
+      phonogram; a compilation of data to the extent it is protected
+      as a copyrightable work; or a work performed by a variety or
+      circus performer to the extent it is not otherwise considered a
+      literary or artistic work.
+
+  g.  "You" means an individual or entity exercising rights under this
+      License who has not previously violated the terms of this
+      License with respect to the Work, or who has received express
+      permission from the Licensor to exercise rights under this
+      License despite a previous violation.
+
+  h.  "Publicly Perform" means to perform public recitations of the
+      Work and to communicate to the public those public recitations,
+      by any means or process, including by wire or wireless means or
+      public digital performances; to make available to the public
+      Works in such a way that members of the public may access these
+      Works from a place and at a place individually chosen by them;
+      to perform the Work to the public by any means or process and
+      the communication to the public of the performances of the Work,
+      including by public digital performance; to broadcast and
+      rebroadcast the Work by any means including signs, sounds or
+      images.
+
+  i.  "Reproduce" means to make copies of the Work by any means
+      including without limitation by sound or visual recordings and
+      the right of fixation and reproducing fixations of the Work,
+      including storage of a protected performance or phonogram in
+      digital form or other electronic medium.
+
+2.  Fair Dealing Rights.  Nothing in this License is intended to
+    reduce, limit, or restrict any uses free from copyright or rights
+    arising from limitations or exceptions that are provided for in
+    connection with the copyright protection under copyright law or
+    other applicable laws.
+
+3.  License Grant.  Subject to the terms and conditions of this
+    License, Licensor hereby grants You a worldwide, royalty-free,
+    non-exclusive, perpetual (for the duration of the applicable
+    copyright) license to exercise the rights in the Work as stated
+    below:
+
+  a.  to Reproduce the Work, to incorporate the Work into one or more
+      Collections, and to Reproduce the Work as incorporated in the
+      Collections;
+
+  b.  to create and Reproduce Adaptations provided that any such
+      Adaptation, including any translation in any medium, takes
+      reasonable steps to clearly label, demarcate or otherwise
+      identify that changes were made to the original Work.  For
+      example, a translation could be marked "The original work was
+      translated from English to Spanish," or a modification could
+      indicate "The original work has been modified.";
+
+  c.  to Distribute and Publicly Perform the Work including as
+      incorporated in Collections; and,
+
+  d.  to Distribute and Publicly Perform Adaptations.
+
+  e.  For the avoidance of doubt:
+
+    i.   Non-waivable Compulsory License Schemes.  In those
+	     jurisdictions in which the right to collect royalties
+	     through any statutory or compulsory licensing scheme
+	     cannot be waived, the Licensor reserves the exclusive
+	     right to collect such royalties for any exercise by You
+	     of the rights granted under this License;
+
+    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
+	     in which the right to collect royalties through any
+	     statutory or compulsory licensing scheme can be waived,
+	     the Licensor waives the exclusive right to collect such
+	     royalties for any exercise by You of the rights granted
+	     under this License; and,
+
+    iii. Voluntary License Schemes.  The Licensor waives the right to
+	     collect royalties, whether individually or, in the event
+	     that the Licensor is a member of a collecting society
+	     that administers voluntary licensing schemes, via that
+	     society, from any exercise by You of the rights granted
+	     under this License.
+
+The above rights may be exercised in all media and formats whether now
+known or hereafter devised.  The above rights include the right to
+make such modifications as are technically necessary to exercise the
+rights in other media and formats.  Subject to Section 8(f), all
+rights not expressly granted by Licensor are hereby reserved.
+
+4.  Restrictions.  The license granted in Section 3 above is expressly
+    made subject to and limited by the following restrictions:
+
+  a.  You may Distribute or Publicly Perform the Work only under the
+      terms of this License.  You must include a copy of, or the
+      Uniform Resource Identifier (URI) for, this License with every
+      copy of the Work You Distribute or Publicly Perform.  You may
+      not offer or impose any terms on the Work that restrict the
+      terms of this License or the ability of the recipient of the
+      Work to exercise the rights granted to that recipient under the
+      terms of the License.  You may not sublicense the Work.  You
+      must keep intact all notices that refer to this License and to
+      the disclaimer of warranties with every copy of the Work You
+      Distribute or Publicly Perform.  When You Distribute or Publicly
+      Perform the Work, You may not impose any effective technological
+      measures on the Work that restrict the ability of a recipient of
+      the Work from You to exercise the rights granted to that
+      recipient under the terms of the License.  This Section 4(a)
+      applies to the Work as incorporated in a Collection, but this
+      does not require the Collection apart from the Work itself to be
+      made subject to the terms of this License.  If You create a
+      Collection, upon notice from any Licensor You must, to the
+      extent practicable, remove from the Collection any credit as
+      required by Section 4(b), as requested.  If You create an
+      Adaptation, upon notice from any Licensor You must, to the
+      extent practicable, remove from the Adaptation any credit as
+      required by Section 4(b), as requested.
+
+  b.  If You Distribute, or Publicly Perform the Work or any
+      Adaptations or Collections, You must, unless a request has been
+      made pursuant to Section 4(a), keep intact all copyright notices
+      for the Work and provide, reasonable to the medium or means You
+      are utilizing: (i) the name of the Original Author (or
+      pseudonym, if applicable) if supplied, and/or if the Original
+      Author and/or Licensor designate another party or parties (e.g.,
+      a sponsor institute, publishing entity, journal) for attribution
+      ("Attribution Parties") in Licensor's copyright notice, terms of
+      service or by other reasonable means, the name of such party or
+      parties; (ii) the title of the Work if supplied; (iii) to the
+      extent reasonably practicable, the URI, if any, that Licensor
+      specifies to be associated with the Work, unless such URI does
+      not refer to the copyright notice or licensing information for
+      the Work; and (iv) , consistent with Section 3(b), in the case
+      of an Adaptation, a credit identifying the use of the Work in
+      the Adaptation (e.g., "French translation of the Work by
+      Original Author," or "Screenplay based on original Work by
+      Original Author").  The credit required by this Section 4 (b)
+      may be implemented in any reasonable manner; provided, however,
+      that in the case of a Adaptation or Collection, at a minimum
+      such credit will appear, if a credit for all contributing
+      authors of the Adaptation or Collection appears, then as part of
+      these credits and in a manner at least as prominent as the
+      credits for the other contributing authors.  For the avoidance
+      of doubt, You may only use the credit required by this Section
+      for the purpose of attribution in the manner set out above and,
+      by exercising Your rights under this License, You may not
+      implicitly or explicitly assert or imply any connection with,
+      sponsorship or endorsement by the Original Author, Licensor
+      and/or Attribution Parties, as appropriate, of You or Your use
+      of the Work, without the separate, express prior written
+      permission of the Original Author, Licensor and/or Attribution
+      Parties.
+
+  c.  Except as otherwise agreed in writing by the Licensor or as may
+      be otherwise permitted by applicable law, if You Reproduce,
+      Distribute or Publicly Perform the Work either by itself or as
+      part of any Adaptations or Collections, You must not distort,
+      mutilate, modify or take other derogatory action in relation to
+      the Work which would be prejudicial to the Original Author's
+      honor or reputation.  Licensor agrees that in those
+      jurisdictions (e.g.  Japan), in which any exercise of the right
+      granted in Section 3(b) of this License (the right to make
+      Adaptations) would be deemed to be a distortion, mutilation,
+      modification or other derogatory action prejudicial to the
+      Original Author's honor and reputation, the Licensor will waive
+      or not assert, as appropriate, this Section, to the fullest
+      extent permitted by the applicable national law, to enable You
+      to reasonably exercise Your right under Section 3(b) of this
+      License (right to make Adaptations) but not otherwise.
+
+5.  Representations, Warranties and Disclaimer
+
+UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
+LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
+WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
+STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
+TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
+NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
+OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
+SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
+SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
+
+6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
+    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
+    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
+    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
+    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+    DAMAGES.
+
+7.  Termination
+
+  a.  This License and the rights granted hereunder will terminate
+      automatically upon any breach by You of the terms of this
+      License.  Individuals or entities who have received Adaptations
+      or Collections from You under this License, however, will not
+      have their licenses terminated provided such individuals or
+      entities remain in full compliance with those licenses.
+      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
+      this License.
+
+  b.  Subject to the above terms and conditions, the license granted
+      here is perpetual (for the duration of the applicable copyright
+      in the Work).  Notwithstanding the above, Licensor reserves the
+      right to release the Work under different license terms or to
+      stop distributing the Work at any time; provided, however that
+      any such election will not serve to withdraw this License (or
+      any other license that has been, or is required to be, granted
+      under the terms of this License), and this License will continue
+      in full force and effect unless terminated as stated above.
+
+8. Miscellaneous
+
+  a.  Each time You Distribute or Publicly Perform the Work or a
+      Collection, the Licensor offers to the recipient a license to
+      the Work on the same terms and conditions as the license granted
+      to You under this License.
+
+  b.  Each time You Distribute or Publicly Perform an Adaptation,
+      Licensor offers to the recipient a license to the original Work
+      on the same terms and conditions as the license granted to You
+      under this License.
+
+  c.  If any provision of this License is invalid or unenforceable
+      under applicable law, it shall not affect the validity or
+      enforceability of the remainder of the terms of this License,
+      and without further action by the parties to this agreement,
+      such provision shall be reformed to the minimum extent necessary
+      to make such provision valid and enforceable.
+
+  d.  No term or provision of this License shall be deemed waived and
+      no breach consented to unless such waiver or consent shall be in
+      writing and signed by the party to be charged with such waiver
+      or consent.
+
+  e.  This License constitutes the entire agreement between the
+      parties with respect to the Work licensed here.  There are no
+      understandings, agreements or representations with respect to
+      the Work not specified here.  Licensor shall not be bound by any
+      additional provisions that may appear in any communication from
+      You.  This License may not be modified without the mutual
+      written agreement of the Licensor and You.
+
+  f.  The rights granted under, and the subject matter referenced, in
+      this License were drafted utilizing the terminology of the Berne
+      Convention for the Protection of Literary and Artistic Works (as
+      amended on September 28, 1979), the Rome Convention of 1961, the
+      WIPO Copyright Treaty of 1996, the WIPO Performances and
+      Phonograms Treaty of 1996 and the Universal Copyright Convention
+      (as revised on July 24, 1971).  These rights and subject matter
+      take effect in the relevant jurisdiction in which the License
+      terms are sought to be enforced according to the corresponding
+      provisions of the implementation of those treaty provisions in
+      the applicable national law.  If the standard suite of rights
+      granted under applicable copyright law includes additional
+      rights not granted under this License, such additional rights
+      are deemed to be included in the License; this License is not
+      intended to restrict the license of any rights under applicable
+      law.
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
new file mode 100644
index 0000000..ede7665
--- /dev/null
+++ b/lib/antlr/BUILD
@@ -0,0 +1,31 @@
+
+[java_library(
+  name = n,
+  exports = ['@%s//jar' % n],
+) for n in [
+  'antlr27',
+  'stringtemplate',
+]]
+
+java_library(
+  name = 'java_runtime',
+  exports = ['@java_runtime//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_binary(
+  name = 'antlr-tool',
+  main_class = 'org.antlr.Tool',
+  runtime_deps = [':tool'],
+  visibility = ['//gerrit-antlr:__pkg__'],
+)
+
+java_library(
+  name = 'tool',
+  exports = ['@org_antlr//jar'],
+  runtime_deps = [
+    ':antlr27',
+    ':java_runtime',
+    ':stringtemplate',
+  ],
+)
diff --git a/lib/asciidoctor/BUCK b/lib/asciidoctor/BUCK
index 18726c83..733c670 100644
--- a/lib/asciidoctor/BUCK
+++ b/lib/asciidoctor/BUCK
@@ -16,6 +16,7 @@
     '//lib:args4j',
     '//lib:guava',
     '//lib/log:api',
+    '//lib/log:nop',
   ],
   visibility = ['//tools/eclipse:classpath'],
 )
@@ -35,16 +36,16 @@
     '//gerrit-server:constants',
     '//lib:args4j',
     '//lib:guava',
-    '//lib/lucene:analyzers-common',
-    '//lib/lucene:core-and-backward-codecs',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core-and-backward-codecs',
   ],
   visibility = ['//tools/eclipse:classpath'],
 )
 
 maven_jar(
   name = 'asciidoctor',
-  id = 'org.asciidoctor:asciidoctorj:1.5.2',
-  sha1 = '39d33f739ec1c46f6e908a725264eb74b23c9f99',
+  id = 'org.asciidoctor:asciidoctorj:1.5.4.1',
+  sha1 = 'f7ddfb2bbed2f8da3f9ad0d1a5514f04b4274a5a',
   license = 'asciidoctor',
   visibility = [],
   attach_source = False,
@@ -52,8 +53,8 @@
 
 maven_jar(
   name = 'jruby',
-  id = 'org.jruby:jruby-complete:1.7.18',
-  sha1 = 'a1be3e1790aace5c99614a87785454d875eb21c2',
+  id = 'org.jruby:jruby-complete:1.7.25',
+  sha1 = '8eb234259ec88edc05eedab05655f458a84bfcab',
   license = 'DO_NOT_DISTRIBUTE',
   visibility = [],
   attach_source = False,
diff --git a/lib/asciidoctor/java/AsciiDoctor.java b/lib/asciidoctor/java/AsciiDoctor.java
index dce939d..8e18feb1 100644
--- a/lib/asciidoctor/java/AsciiDoctor.java
+++ b/lib/asciidoctor/java/AsciiDoctor.java
@@ -28,6 +28,7 @@
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.io.FilenameFilter;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -104,7 +105,7 @@
 
     for (String attribute : attributes) {
       int equalsIndex = attribute.indexOf('=');
-      if(equalsIndex > -1) {
+      if (equalsIndex > -1) {
         String name = attribute.substring(0, equalsIndex);
         String value = attribute.substring(equalsIndex + 1, attribute.length());
 
@@ -148,6 +149,16 @@
         renderInput(options, new File(inputFile));
         zipFile(out, outName, zip);
       }
+
+      File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
+        @Override
+        public boolean accept(File dir, String name) {
+          return name.endsWith(".css");
+        }
+      });
+      for (File css : cssFiles) {
+        zipFile(css, css.getName(), zip);
+      }
     }
   }
 
diff --git a/lib/auto/BUCK b/lib/auto/BUCK
index c688ee4..6197e34 100644
--- a/lib/auto/BUCK
+++ b/lib/auto/BUCK
@@ -2,12 +2,8 @@
 
 maven_jar(
   name = 'auto-value',
-  id = 'com.google.auto.value:auto-value:1.1',
-  sha1 = 'f6951c141ea3e89c0f8b01da16834880a1ebf162',
-  # Exclude un-relocated dependencies and replace with our own versions; see
-  # https://github.com/google/auto/blob/auto-value-1.1/value/pom.xml#L151
-  exclude = ['org/apache/*'],
-  deps = ['//lib:velocity'],
+  id = 'com.google.auto.value:auto-value:1.3-rc1',
+  sha1 = 'b764e0fb7e11353fbff493b22fd6e83bf091a179',
   license = 'Apache2.0',
   visibility = ['PUBLIC'],
 )
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
new file mode 100644
index 0000000..e07c36d
--- /dev/null
+++ b/lib/auto/BUILD
@@ -0,0 +1,21 @@
+java_plugin(
+  name = 'auto-annotation-plugin',
+  processor_class = 'com.google.auto.value.processor.AutoAnnotationProcessor',
+  deps = ['@auto_value//jar'],
+)
+
+java_plugin(
+  name = 'auto-value-plugin',
+  processor_class = 'com.google.auto.value.processor.AutoValueProcessor',
+  deps = ['@auto_value//jar'],
+)
+
+java_library(
+  name = 'auto-value',
+  exported_plugins = [
+    ':auto-annotation-plugin',
+    ':auto-value-plugin',
+  ],
+  exports = ['@auto_value//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/bouncycastle/BUCK b/lib/bouncycastle/BUCK
index 0ce5817..68fa006 100644
--- a/lib/bouncycastle/BUCK
+++ b/lib/bouncycastle/BUCK
@@ -1,7 +1,7 @@
 include_defs('//lib/maven.defs')
 
 # This version must match the version that also appears in
-# gerrit-pgm/src/main/resources/com/google/gerrit/pgm/libraries.config
+# gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
 VERSION = '1.52'
 
 maven_jar(
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD
new file mode 100644
index 0000000..49c54ba
--- /dev/null
+++ b/lib/bouncycastle/BUILD
@@ -0,0 +1,38 @@
+java_library(
+  name = 'bcprov',
+  neverlink = 1,
+  exports = ['@bcprov//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'bcprov-without-neverlink',
+  exports = ['@bcprov//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'bcpg',
+  neverlink = 1,
+  exports = ['@bcpg//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'bcpg-without-neverlink',
+  exports = ['@bcpg//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'bcpkix',
+  neverlink = 1,
+  exports = ['@bcpkix//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'bcpkix-without-neverlink',
+  exports = ['@bcpkix//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
index 6187b3b..a0e0e9a 100644
--- a/lib/codemirror/BUCK
+++ b/lib/codemirror/BUCK
@@ -1,152 +1,141 @@
 include_defs('//lib/maven.defs')
 include_defs('//lib/codemirror/cm.defs')
-include_defs('//lib/codemirror/closure.defs')
 
-REPO = MAVEN_CENTRAL
-VERSION = '5.8'
-SHA1 = '1cbe267adf1da9659dae49253305649dae2391e9'
+VERSION = '5.17.0'
+TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
+TOP_MINIFIED = 'META-INF/resources/webjars/codemirror-minified/%s' % VERSION
 
-if REPO == MAVEN_CENTRAL:
-  URL = REPO + 'org/webjars/codemirror/%s/codemirror-%s.jar' % (VERSION, VERSION)
-  TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
-  ZIP = 'codemirror-%s.jar' % VERSION
-else:
-  URL = REPO + 'net/codemirror/codemirror-%s.zip' % VERSION
-  TOP = 'codemirror-%s' % VERSION
-  ZIP = 'codemirror-%s.zip' % VERSION
-
-
-CLOSURE_VERSION = 'v20141120'
-
-CLOSURE_COMPILER_ARGS = [
-  '--compilation_level SIMPLE_OPTIMIZATIONS',
-  '--warning_level QUIET'
-]
-
-genrule(
-  name = 'css',
-  cmd = ';'.join([
-      "echo '/** @license' >$OUT",
-      'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
-      "echo '*/' >>$OUT",
-    ] +
-    ['unzip -p $(location :zip) %s/%s >>$OUT' % (TOP, n)
-     for n in CM_CSS]
-  ),
-  out = 'cm.css',
+maven_jar(
+  name = 'codemirror-minified',
+  id = 'org.webjars.npm:codemirror-minified:' + VERSION,
+  sha1 = '05ad901fc9be67eb7ba8997d896488093deb898e',
+  attach_source = False,
+  license = 'codemirror-minified',
+  visibility = [],
 )
 
-for n in CM_THEMES:
+maven_jar(
+  name = 'codemirror-original',
+  id = 'org.webjars.npm:codemirror:' + VERSION,
+  sha1 = 'c025b8d9aca1061e26d1fa482bea0ecea1412e85',
+  attach_source = False,
+  license = 'codemirror-original',
+  visibility = [],
+)
+
+DIFF_MATCH_PATCH_VERSION = '20121119-1'
+DIFF_MATCH_PATCH_TOP = ('META-INF/resources/webjars/google-diff-match-patch/%s'
+    % DIFF_MATCH_PATCH_VERSION)
+
+maven_jar(
+  name = 'diff-match-patch',
+  id = 'org.webjars:google-diff-match-patch:' + DIFF_MATCH_PATCH_VERSION,
+  sha1 = '0cf1782dbcb8359d95070da9176059a5a9d37709',
+  license = 'Apache2.0',
+  attach_source = False,
+)
+
+for archive, suffix, top in [('codemirror-original', '', TOP), ('codemirror-minified', '_r', TOP_MINIFIED)]:
+  # Main JavaScript and addons
   genrule(
-    name = 'theme_%s' % n,
+    name = 'cm' + suffix,
     cmd = ';'.join([
         "echo '/** @license' >$OUT",
-        'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
+        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
         "echo '*/' >>$OUT",
-        'unzip -p $(location :zip) %s/theme/%s.css >>$OUT' % (TOP, n)
+      ] +
+      ['unzip -p $(location :%s) %s/%s >>$OUT' % (archive, top, n) for n in CM_JS] +
+      ['unzip -p $(location :%s) %s/addon/%s >>$OUT' % (archive, top, n)
+       for n in CM_ADDONS]
+    ),
+    out = 'cm%s.js' % suffix,
+  )
+
+  # Main CSS
+  genrule(
+    name = 'css' + suffix,
+    cmd = ';'.join([
+        "echo '/** @license' >$OUT",
+        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
+        "echo '*/' >>$OUT",
+      ] +
+      ['unzip -p $(location :%s) %s/%s >>$OUT' % (archive, top, n)
+       for n in CM_CSS]
+    ),
+    out = 'cm%s.css' % suffix,
+  )
+
+  # Modes
+  for n in CM_MODES:
+    genrule (
+      name = 'mode_%s%s' % (n, suffix),
+      cmd = ';'.join([
+          "echo '/** @license' >$OUT",
+          'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
+          "echo '*/' >>$OUT",
+          'unzip -p $(location :%s) %s/mode/%s/%s.js >>$OUT' % (archive, top, n, n),
+        ]
+      ),
+      out = 'mode_%s%s.js' % (n, suffix),
+    )
+
+  # Themes
+  for n in CM_THEMES:
+    genrule(
+      name = 'theme_%s%s' % (n, suffix),
+      cmd = ';'.join([
+          "echo '/** @license' >$OUT",
+          'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
+          "echo '*/' >>$OUT",
+          'unzip -p $(location :%s) %s/theme/%s.css >>$OUT' % (archive, top, n)
+        ]
+      ),
+      out = 'theme_%s%s.css' % (n, suffix),
+    )
+
+  # Merge Addon bundled with diff-match-patch
+  genrule(
+    name = 'addon_merge%s' % suffix,
+    cmd = ';'.join([
+        "echo '/** @license' >$OUT",
+        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
+        "echo '*/\n' >>$OUT",
+        "echo '// The google-diff-match-patch library is from https://google-diff-match-patch.googlecode.com/svn-history/r106/trunk/javascript/diff_match_patch.js\n' >> $OUT",
+        "echo '/** @license' >>$OUT",
+        'cat $(location //lib:LICENSE-Apache2.0) >>$OUT',
+        "echo '*/' >>$OUT",
+        'unzip -p $(location :diff-match-patch) %s/diff_match_patch.js >>$OUT' % DIFF_MATCH_PATCH_TOP,
+        "echo ';' >> $OUT",
+        'unzip -p $(location :%s) %s/addon/merge/merge.js >>$OUT' % (archive, top)
       ]
     ),
-    out = 'theme_%s.css' % n,
+    out = 'addon_merge%s.js' % suffix,
   )
 
-genrule(
-  name = 'cm-verbose',
-  cmd = ';'.join([
-      "echo '/** @license' >$OUT",
-      'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
-      "echo '*/' >>$OUT",
-    ] +
-    ['unzip -p $(location :zip) %s/%s >>$OUT' % (TOP, n) for n in CM_JS] +
-    ['unzip -p $(location :zip) %s/addon/%s >>$OUT' % (TOP, n)
-     for n in CM_ADDONS]
-  ),
-  out = 'cm-verbose.js',
-)
-
-js_minify(
-  name = 'js',
-  generated = [':cm-verbose'],
-  compiler_args = CLOSURE_COMPILER_ARGS,
-  out = 'cm.js'
-)
-
-for n in CM_MODES:
-  genrule (
-    name = 'mode_%s_src' % n,
+  # Jar packaging
+  genrule(
+    name = 'jar' + suffix,
     cmd = ';'.join([
-      "echo '/** @license' >$OUT",
-      'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
-      "echo '*/' >>$OUT",
-      'unzip -p $(location :zip) %s/mode/%s/%s.js >>$OUT' % (TOP, n, n),
-      ]),
-    out = 'mode_%s_src.js' %n,
-  )
-  js_minify(
-    name = 'mode_%s_js' % n,
-    generated = [':mode_%s_src' % n],
-    compiler_args = CLOSURE_COMPILER_ARGS,
-    out = 'mode_%s.js' % n,
+      'cd $TMP',
+      'mkdir -p net/codemirror/{addon,lib,mode,theme}',
+      'cp $(location :css%s) net/codemirror/lib/cm.css' % suffix,
+      'cp $(location :cm%s) net/codemirror/lib/cm.js' % suffix]
+      + ['cp $(location :mode_%s%s) net/codemirror/mode/%s.js' % (n, suffix, n)
+         for n in CM_MODES]
+      + ['cp $(location :theme_%s%s) net/codemirror/theme/%s.css' % (n, suffix, n)
+         for n in CM_THEMES]
+      + ['cp $(location :addon_merge%s) net/codemirror/addon/merge_bundled.js' % suffix]
+      + ['zip -qr $OUT net/codemirror/{addon,lib,mode,theme}']),
+    out = 'codemirror%s.jar' % suffix,
   )
 
-prebuilt_jar(
-  name = 'codemirror',
-  binary_jar = ':jar',
-  deps = [
-    ':jar',
-    '//lib:LICENSE-codemirror',
-  ],
-  visibility = ['PUBLIC'],
-)
+  prebuilt_jar(
+    name = 'codemirror' + suffix,
+    binary_jar = ':jar%s' % suffix,
+    deps = [
+      ':jar' + suffix,
+      '//lib:LICENSE-' + archive,
+    ],
+    visibility = ['PUBLIC'],
+  )
 
-genrule(
-  name = 'jar',
-  cmd = ';'.join([
-    'cd $TMP',
-    'mkdir -p net/codemirror/{lib,mode,theme}',
-    'cp $(location :css) net/codemirror/lib',
-    'cp $(location :js) net/codemirror/lib']
-    + ['cp $(location :mode_%s_js) net/codemirror/mode/%s.js' % (n, n)
-       for n in CM_MODES]
-    + ['cp $(location :theme_%s) net/codemirror/theme/%s.css' % (n, n)
-       for n in CM_THEMES]
-    + ['zip -qr $OUT net/codemirror/{lib,mode,theme}']),
-  out = 'codemirror.jar',
-)
-
-genrule(
-  name = 'zip',
-  cmd = '$(exe //tools:download_file)' +
-    ' -o $OUT' +
-    ' -u ' + URL +
-    ' -v ' + SHA1,
-  out = ZIP,
-)
-
-java_binary(
-  name = 'js_minifier',
-  main_class = 'com.google.javascript.jscomp.CommandLineRunner',
-  deps = [':compiler-jar']
-)
-
-maven_jar(
-  name = 'compiler-jar',
-  id = 'com.google.javascript:closure-compiler:' + CLOSURE_VERSION,
-  sha1 = '369618bf5a96f73e32655dc48919c0f97558d3b1',
-  license = 'Apache2.0',
-  deps = [
-    ':closure-compiler-externs',
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:protobuf',
-  ],
-  visibility = [],
-)
-
-maven_jar(
-  name = 'closure-compiler-externs',
-  id = 'com.google.javascript:closure-compiler-externs:' + CLOSURE_VERSION,
-  sha1 = '247eff337e2737de43c8d963aaaef15bd8cda132',
-  license = 'Apache2.0',
-  visibility = [],
-)
diff --git a/lib/codemirror/closure.defs b/lib/codemirror/closure.defs
deleted file mode 100644
index 0da1501..0000000
--- a/lib/codemirror/closure.defs
+++ /dev/null
@@ -1,18 +0,0 @@
-def js_minify(
-    name,
-    out,
-    compiler_args = [],
-    srcs = [],
-    generated = []):
-  cmd = ['$(exe :js_minifier) --js_output_file $OUT'] + compiler_args
-  if srcs:
-    cmd.append('$SRCS')
-  if generated:
-    cmd.extend(['$(location %s)' % n for n in generated])
-
-  genrule(
-    name = name,
-    cmd = ' '.join(cmd),
-    srcs = srcs,
-    out = out,
-  )
diff --git a/lib/codemirror/cm.defs b/lib/codemirror/cm.defs
index abb6d92..baf2ce5 100644
--- a/lib/codemirror/cm.defs
+++ b/lib/codemirror/cm.defs
@@ -1,15 +1,18 @@
 CM_CSS = [
   'lib/codemirror.css',
   'addon/dialog/dialog.css',
+  'addon/merge/merge.css',
   'addon/scroll/simplescrollbars.css',
   'addon/search/matchesonscrollbar.css',
+  'addon/lint/lint.css',
 ]
 
 CM_JS = [
   'lib/codemirror.js',
   'mode/meta.js',
-  'keymap/vim.js',
   'keymap/emacs.js',
+  'keymap/sublime.js',
+  'keymap/vim.js',
 ]
 
 CM_ADDONS = [
@@ -19,69 +22,190 @@
   'edit/trailingspace.js',
   'scroll/annotatescrollbar.js',
   'scroll/simplescrollbars.js',
+  'search/jump-to-line.js',
   'search/matchesonscrollbar.js',
   'search/searchcursor.js',
   'search/search.js',
   'selection/mark-selection.js',
+  'mode/multiplex.js',
   'mode/overlay.js',
   'mode/simple.js',
+  'lint/lint.js',
 ]
 
 # Available themes must be enumerated here,
 # in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java,
 # in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
 CM_THEMES = [
+  '3024-day',
+  '3024-night',
+  'abcdef',
+  'ambiance',
+  'base16-dark',
+  'base16-light',
+  'bespin',
+  'blackboard',
+  'cobalt',
+  'colorforth',
+  'dracula',
   'eclipse',
   'elegant',
+  'erlang-dark',
+  'hopscotch',
+  'icecoder',
+  'isotope',
+  'lesser-dark',
+  'liquibyte',
+  'material',
+  'mbo',
+  'mdn-like',
   'midnight',
+  'monokai',
   'neat',
+  'neo',
   'night',
+  'paraiso-dark',
+  'paraiso-light',
+  'pastel-on-dark',
+  'railscasts',
+  'rubyblue',
+  'seti',
+  'solarized',
+  'the-matrix',
+  'tomorrow-night-bright',
+  'tomorrow-night-eighties',
+  'ttcn',
   'twilight',
+  'vibrant-ink',
+  'xq-dark',
+  'xq-light',
+  'yeti',
+  'zenburn',
 ]
 
 # Available modes must be enumerated here,
 # in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
+# gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java,
 # and in CodeMirror's own mode/meta.js script.
 CM_MODES = [
+  'apl',
+  'asciiarmor',
+  'asn.1',
+  'asterisk',
+  'brainfuck',
   'clike',
   'clojure',
+  'cmake',
+  'cobol',
   'coffeescript',
   'commonlisp',
+  'crystal',
   'css',
+  'cypher',
   'd',
   'dart',
   'diff',
+  'django',
   'dockerfile',
   'dtd',
+  'dylan',
+  'ebnf',
+  'ecl',
+  'eiffel',
+  'elm',
   'erlang',
+  'factor',
+  'fcl',
+  'forth',
+  'fortran',
   'gas',
   'gfm',
+  'gherkin',
   'go',
   'groovy',
+  'haml',
+  'handlebars',
+  'haskell-literate',
   'haskell',
+  'haxe',
+  'htmlembedded',
   'htmlmixed',
+  'http',
+  'idl',
+  'jade',
   'javascript',
+  'jinja2',
+  'jsx',
+  'julia',
+  'livescript',
   'lua',
   'markdown',
+  'mathematica',
+  'mbox',
+  'mirc',
+  'mllike',
+  'modelica',
+  'mscgen',
+  'mumps',
+  'nginx',
+  'nsis',
+  'ntriples',
+  'octave',
+  'oz',
+  'pascal',
+  'pegjs',
   'perl',
   'php',
   'pig',
+  'powershell',
   'properties',
+  'protobuf',
   'puppet',
   'python',
+  'q',
   'r',
+  'rpm',
   'rst',
   'ruby',
+  'rust',
+  'sas',
+  'sass',
   'scheme',
   'shell',
+  'sieve',
+  'slim',
   'smalltalk',
+  'smarty',
+  'solr',
   'soy',
+  'sparql',
+  'spreadsheet',
   'sql',
   'stex',
+  'stylus',
+  'swift',
   'tcl',
+  'textile',
+  'tiddlywiki',
+  'tiki',
+  'toml',
+  'tornado',
+  'troff',
+  'ttcn-cfg',
+  'ttcn',
+  'turtle',
+  'twig',
+  'vb',
+  'vbscript',
   'velocity',
   'verilog',
   'vhdl',
+  'vue',
+  'webidl',
   'xml',
+  'xquery',
+  'yacas',
+  'yaml-frontmatter',
   'yaml',
+  'z80',
 ]
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
index 6922f4d..7c27477 100644
--- a/lib/commons/BUCK
+++ b/lib/commons/BUCK
@@ -48,8 +48,8 @@
 
 maven_jar(
   name = 'net',
-  id = 'commons-net:commons-net:2.2',
-  sha1 = '07993c12f63c78378f8c90de4bc2ee62daa7ca3a',
+  id = 'commons-net:commons-net:3.5',
+  sha1 = '342fc284019f590e1308056990fdb24a08f06318',
   license = 'Apache2.0',
   exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
 )
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
new file mode 100644
index 0000000..8c42e53f
--- /dev/null
+++ b/lib/commons/BUILD
@@ -0,0 +1,54 @@
+java_library(
+  name = 'codec',
+  exports = ['@commons_codec//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'collections',
+  exports = ['@commons_collections//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'compress',
+  exports = ['@commons_compress//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lang',
+  exports = ['@commons_lang//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'net',
+  exports = ['@commons_net//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'dbcp',
+  exports = ['@commons_dbcp//jar'],
+  runtime_deps = [':pool'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'pool',
+  exports = ['@commons_pool//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'oro',
+  exports = ['@commons_oro//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'validator',
+  exports = ['@commons_validator//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/dropwizard/BUCK b/lib/dropwizard/BUCK
new file mode 100644
index 0000000..de73e13
--- /dev/null
+++ b/lib/dropwizard/BUCK
@@ -0,0 +1,8 @@
+include_defs('//lib/maven.defs')
+
+maven_jar(
+  name = 'dropwizard-core',
+  id = 'io.dropwizard.metrics:metrics-core:3.1.2',
+  sha1 = '224f03afd2521c6c94632f566beb1bb5ee32cf07',
+  license = 'Apache2.0',
+)
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD
new file mode 100644
index 0000000..9d4a8d3
--- /dev/null
+++ b/lib/dropwizard/BUILD
@@ -0,0 +1,5 @@
+java_library(
+  name = 'dropwizard-core',
+  exports = ['@dropwizard_core//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/easymock/BUCK b/lib/easymock/BUCK
index c0cb77b..93640a0 100644
--- a/lib/easymock/BUCK
+++ b/lib/easymock/BUCK
@@ -2,9 +2,9 @@
 
 maven_jar(
   name = 'easymock',
-  id = 'org.easymock:easymock:3.3.1', # When bumping the version
+  id = 'org.easymock:easymock:3.4', # When bumping the version
   # number, make sure to also move powermock to a compatible version
-  sha1 = 'a497d7f00c9af78b72b6d8f24762d9210309148a',
+  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':cglib-2_2',
@@ -22,8 +22,8 @@
 
 maven_jar(
   name = 'objenesis',
-  id = 'org.objenesis:objenesis:2.1',
-  sha1 = '87c0ea803b69252868d09308b4618f766f135a96',
+  id = 'org.objenesis:objenesis:2.2',
+  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
   license = 'DO_NOT_DISTRIBUTE',
   visibility = ['//lib/powermock:powermock-reflect'],
   attach_source = False,
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
new file mode 100644
index 0000000..df77128
--- /dev/null
+++ b/lib/easymock/BUILD
@@ -0,0 +1,22 @@
+java_library(
+  name = 'easymock',
+  exports = ['@easymock//jar'],
+  runtime_deps = [
+    ':cglib-2_2',
+    ':objenesis',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'cglib-2_2',
+  exports = ['@cglib_2_2//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'objenesis',
+  exports = ['@objenesis//jar'],
+  visibility = ['//visibility:public'],
+)
+
diff --git a/lib/fonts/BUCK b/lib/fonts/BUCK
new file mode 100644
index 0000000..c5b78eb
--- /dev/null
+++ b/lib/fonts/BUCK
@@ -0,0 +1,30 @@
+# Source Code Pro. Version 2.010 Roman / 1.030 Italics
+# https://github.com/adobe-fonts/source-code-pro/releases/tag/2.010R-ro%2F1.030R-it
+genrule(
+  name = 'sourcecodepro',
+  cmd = 'zip -rq $OUT .',
+  srcs = [
+    'SourceCodePro-Regular.woff',
+    'SourceCodePro-Regular.woff2'
+  ],
+  out = 'sourcecodepro.zip',
+  license = 'OFL1.1',
+  visibility = ['PUBLIC'],
+)
+
+# Open Sans at Revision 53a5266 and converted using a Google woff file
+# converter (same one that Google Fonts uses).
+# https://github.com/google/fonts/tree/master/apache/opensans
+genrule(
+  name = 'opensans',
+  cmd = 'zip -rq $OUT .',
+  srcs = [
+    'OpenSans-Bold.woff',
+    'OpenSans-Bold.woff2',
+    'OpenSans-Regular.woff',
+    'OpenSans-Regular.woff2'
+  ],
+  out = 'opensans.zip',
+  license = 'Apache2.0',
+  visibility = ['PUBLIC'],
+)
diff --git a/lib/fonts/OpenSans-Bold.woff b/lib/fonts/OpenSans-Bold.woff
new file mode 100644
index 0000000..74c4086
--- /dev/null
+++ b/lib/fonts/OpenSans-Bold.woff
Binary files differ
diff --git a/lib/fonts/OpenSans-Bold.woff2 b/lib/fonts/OpenSans-Bold.woff2
new file mode 100644
index 0000000..44d6c26
--- /dev/null
+++ b/lib/fonts/OpenSans-Bold.woff2
Binary files differ
diff --git a/lib/fonts/OpenSans-Regular.woff b/lib/fonts/OpenSans-Regular.woff
new file mode 100644
index 0000000..882f7c9
--- /dev/null
+++ b/lib/fonts/OpenSans-Regular.woff
Binary files differ
diff --git a/lib/fonts/OpenSans-Regular.woff2 b/lib/fonts/OpenSans-Regular.woff2
new file mode 100644
index 0000000..52217ee
--- /dev/null
+++ b/lib/fonts/OpenSans-Regular.woff2
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff b/lib/fonts/SourceCodePro-Regular.woff
new file mode 100644
index 0000000..395436e
--- /dev/null
+++ b/lib/fonts/SourceCodePro-Regular.woff
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff2 b/lib/fonts/SourceCodePro-Regular.woff2
new file mode 100644
index 0000000..65cd591
--- /dev/null
+++ b/lib/fonts/SourceCodePro-Regular.woff2
Binary files differ
diff --git a/lib/guice/BUCK b/lib/guice/BUCK
index 3893b80..867b521 100644
--- a/lib/guice/BUCK
+++ b/lib/guice/BUCK
@@ -1,6 +1,6 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '4.0'
+VERSION = '4.1.0'
 EXCLUDE = [
   'META-INF/DEPENDENCIES',
   'META-INF/LICENSE',
@@ -19,7 +19,7 @@
 maven_jar(
   name = 'guice_library',
   id = 'com.google.inject:guice:' + VERSION,
-  sha1 = '0f990a43d3725781b6db7cd0acf0a8b62dfd1649',
+  sha1 = 'eeb69005da379a10071aa4948c48d89250febb07',
   license = 'Apache2.0',
   deps = [':aopalliance'],
   exclude_java_sources = True,
@@ -33,7 +33,7 @@
 maven_jar(
   name = 'guice-assistedinject',
   id = 'com.google.inject.extensions:guice-assistedinject:' + VERSION,
-  sha1 = '8fa6431da1a2187817e3e52e967535899e2e46ca',
+  sha1 = 'af799dd7e23e6fe8c988da12314582072b07edcb',
   license = 'Apache2.0',
   deps = [':guice'],
   exclude = EXCLUDE,
@@ -42,7 +42,7 @@
 maven_jar(
   name = 'guice-servlet',
   id = 'com.google.inject.extensions:guice-servlet:' + VERSION,
-  sha1 = '4503da866f4c402b5090579b40c1c4aaefabb164',
+  sha1 = '90ac2db772d9b85e2b05417b74f7464bcc061dcb',
   license = 'Apache2.0',
   deps = [':guice'],
   exclude = EXCLUDE,
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
new file mode 100644
index 0000000..acade50
--- /dev/null
+++ b/lib/guice/BUILD
@@ -0,0 +1,39 @@
+java_library(
+  name = 'guice',
+  exports = [
+    ':guice_library',
+    ':javax-inject',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'guice_library',
+  exports = ['@guice_library//jar'],
+  runtime_deps = ['aopalliance'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'guice-assistedinject',
+  exports = ['@guice_assistedinject//jar'],
+  runtime_deps = [':guice'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'guice-servlet',
+  exports = ['@guice_servlet//jar'],
+  runtime_deps = [':guice'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'aopalliance',
+  exports = ['@aopalliance//jar'],
+)
+
+java_library(
+  name = 'javax-inject',
+  exports = ['@javax_inject//jar'],
+)
diff --git a/lib/gwt/BUCK b/lib/gwt/BUCK
index 6876dfe..ebd58f1 100644
--- a/lib/gwt/BUCK
+++ b/lib/gwt/BUCK
@@ -8,6 +8,7 @@
   sha1 = 'bdc7af42581745d3d79c2efe0b514f432b998a5b',
   license = 'Apache2.0',
   attach_source = False,
+  exclude = ['javax/servlet/*'],
 )
 
 maven_jar(
@@ -28,22 +29,3 @@
   visibility = ['PUBLIC'],
 )
 
-maven_jar(
-  name = 'gwt-test-utils',
-  id = 'com.googlecode.gwt-test-utils:gwt-test-utils:0.47',
-  sha1 = '284749ed37d8034bac05e374070c09cce88db540',
-  license = 'Apache2.0',
-  deps = [
-    ':javassist',
-    '//lib/log:api',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'javassist',
-  id = 'org.javassist:javassist:3.18.1-GA',
-  sha1 = 'd9a09f7732226af26bf99f19e2cffe0ae219db5b',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
new file mode 100644
index 0000000..2168bb4
--- /dev/null
+++ b/lib/gwt/BUILD
@@ -0,0 +1,9 @@
+[java_library(
+  name = n,
+  exports = ['@%s//jar' % n.replace("-", "_")],
+  visibility = ["//visibility:public"],
+) for n in [
+  'javax-validation',
+  'dev',
+  'user',
+]]
diff --git a/lib/highlightjs/BUCK b/lib/highlightjs/BUCK
new file mode 100644
index 0000000..9940136
--- /dev/null
+++ b/lib/highlightjs/BUCK
@@ -0,0 +1,5 @@
+export_file(
+  name = 'highlightjs',
+  src = 'highlight.min.js',
+  visibility = ['PUBLIC'],
+)
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
new file mode 100644
index 0000000..8cb9e8b
--- /dev/null
+++ b/lib/highlightjs/building.md
@@ -0,0 +1,72 @@
+# Building Highlight.js for Gerrit
+
+Highlight JS needs to be built with specific language support. Here are the
+steps to build the minified file that appears here.
+
+NOTE: If you are adding support for a language to Highlight.js make sure to add
+it to the list of languages in the build command below.
+
+## Prerequisites
+
+You will need:
+
+* nodejs
+* closure-compiler
+* git
+
+## Steps to Create the Pack File
+
+The packed version of Highlight.js is an un-minified JS file with all of the
+languages included. Build it with the following:
+
+    $>  # start in some temp directory
+    $>  git clone https://github.com/isagalaev/highlight.js.git
+    $>  cd highlight.js
+    $>  node tools/build.js -n \
+          bash \
+          cpp \
+          cs \
+          clojure \
+          css \
+          d \
+          dart \
+          go \
+          haskell \
+          java \
+          javascript \
+          json \
+          lisp \
+          lua \
+          markdown \
+          objectivec \
+          ocaml \
+          perl \
+          protobuf \
+          python \
+          ruby \
+          rust \
+          scala \
+          sql \
+          swift \
+          typescript \
+          xml \
+          yaml
+
+The resulting JS file will appear in the "build" directory of the Highlight.js
+repo under the name "highlight.pack.js".
+
+## Minification
+
+Minify the file using closure-compiler using the command below. (Modify
+`/path/to` with the path to your compiler jar.)
+
+    $>  java -jar /path/to/closure-compiler.jar \
+            --js build/highlight.pack.js \
+            --js_output_file build/highlight.min.js
+
+Copy the header comment that appears on the first line of
+build/highlight.pack.js and add it to the start of build/highlight.min.js.
+
+## Finish
+
+Copy the resulting build/highlight.min.js file to lib/highlightjs
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
new file mode 100644
index 0000000..cfc8c1c
--- /dev/null
+++ b/lib/highlightjs/highlight.min.js
@@ -0,0 +1,105 @@
+/*! highlight.js v9.5.0 | BSD3 License | git.io/hljslicense */
+(function(b){var p="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):p&&(p.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return p.hljs}))})(function(b){function p(a){return a.replace(/[&<>]/gm,function(a){return M[a]})}function C(a,c){var e=a&&a.exec(c);return e&&0===e.index}function v(a,c){var e,b={};for(e in a)b[e]=a[e];if(c)for(e in c)b[e]=c[e];return b}function H(a){var c=[];(function g(a,b){for(var k=a.firstChild;k;k=
+k.nextSibling)3===k.nodeType?b+=k.nodeValue.length:1===k.nodeType&&(c.push({event:"start",offset:b,node:k}),b=g(k,b),k.nodeName.toLowerCase().match(/br|hr|img|input/)||c.push({event:"stop",offset:b,node:k}));return b})(a,0);return c}function N(a,c,e){function b(){return a.length&&c.length?a[0].offset!==c[0].offset?a[0].offset<c[0].offset?a:c:"start"===c[0].event?a:c:a.length?a:c}function d(a){n+="<"+a.nodeName.toLowerCase()+I.map.call(a.attributes,function(a){return" "+a.nodeName+'="'+p(a.value)+
+'"'}).join("")+">"}function f(a){n+="</"+a.nodeName.toLowerCase()+">"}function k(a){("start"===a.event?d:f)(a.node)}for(var l=0,n="",m=[];a.length||c.length;){var h=b(),n=n+p(e.substr(l,h[0].offset-l)),l=h[0].offset;if(h===a){m.reverse().forEach(f);do k(h.splice(0,1)[0]),h=b();while(h===a&&h.length&&h[0].offset===l);m.reverse().forEach(d)}else"start"===h[0].event?m.push(h[0].node):m.pop(),k(h.splice(0,1)[0])}return n+p(e.substr(l))}function O(a){function c(a){return a&&a.source||a}function e(e,b){return new RegExp(c(e),
+"m"+(a.case_insensitive?"i":"")+(b?"g":""))}function b(d,f){if(!d.compiled){d.compiled=!0;d.keywords=d.keywords||d.beginKeywords;if(d.keywords){var k={},l=function(c,e){a.case_insensitive&&(e=e.toLowerCase());e.split(" ").forEach(function(a){a=a.split("|");k[a[0]]=[c,a[1]?Number(a[1]):1]})};"string"===typeof d.keywords?l("keyword",d.keywords):D(d.keywords).forEach(function(a){l(a,d.keywords[a])});d.keywords=k}d.lexemesRe=e(d.lexemes||/\w+/,!0);f&&(d.beginKeywords&&(d.begin="\\b("+d.beginKeywords.split(" ").join("|")+
+")\\b"),d.begin||(d.begin=/\B|\b/),d.beginRe=e(d.begin),d.end||d.endsWithParent||(d.end=/\B|\b/),d.end&&(d.endRe=e(d.end)),d.terminator_end=c(d.end)||"",d.endsWithParent&&f.terminator_end&&(d.terminator_end+=(d.end?"|":"")+f.terminator_end));d.illegal&&(d.illegalRe=e(d.illegal));null==d.relevance&&(d.relevance=1);d.contains||(d.contains=[]);var n=[];d.contains.forEach(function(a){a.variants?a.variants.forEach(function(c){n.push(v(a,c))}):n.push("self"===a?d:a)});d.contains=n;d.contains.forEach(function(a){b(a,
+d)});d.starts&&b(d.starts,f);var m=d.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([d.terminator_end,d.illegal]).map(c).filter(Boolean);d.terminators=m.length?e(m.join("|"),!0):{exec:function(){return null}}}}b(a)}function A(a,c,e,b){function d(a,c){if(C(a.endRe,c)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return d(a.parent,c)}function f(a,c,e,b){return'<span class="'+(b?"":t.classPrefix)+(a+'">')+c+(e?"":"</span>")}function k(){var a=
+r,c;if(null!=h.subLanguage)if((c="string"===typeof h.subLanguage)&&!w[h.subLanguage])c=p(q);else{var e=c?A(h.subLanguage,q,!0,u[h.subLanguage]):F(q,h.subLanguage.length?h.subLanguage:void 0);0<h.relevance&&(B+=e.relevance);c&&(u[h.subLanguage]=e.top);c=f(e.language,e.value,!1,!0)}else{var b;if(h.keywords){e="";b=0;h.lexemesRe.lastIndex=0;for(c=h.lexemesRe.exec(q);c;){e+=p(q.substr(b,c.index-b));b=h;var d=c,d=m.case_insensitive?d[0].toLowerCase():d[0];(b=b.keywords.hasOwnProperty(d)&&b.keywords[d])?
+(B+=b[1],e+=f(b[0],p(c[0]))):e+=p(c[0]);b=h.lexemesRe.lastIndex;c=h.lexemesRe.exec(q)}c=e+p(q.substr(b))}else c=p(q)}r=a+c;q=""}function l(a){r+=a.className?f(a.className,"",!0):"";h=Object.create(a,{parent:{value:h}})}function n(a,c){q+=a;if(null==c)return k(),0;var b;a:{b=h;var f,g;f=0;for(g=b.contains.length;f<g;f++)if(C(b.contains[f].beginRe,c)){b=b.contains[f];break a}b=void 0}if(b)return b.skip?q+=c:(b.excludeBegin&&(q+=c),k(),b.returnBegin||b.excludeBegin||(q=c)),l(b,c),b.returnBegin?0:c.length;
+if(b=d(h,c)){f=h;f.skip?q+=c:(f.returnEnd||f.excludeEnd||(q+=c),k(),f.excludeEnd&&(q=c));do h.className&&(r+="</span>"),h.skip||(B+=h.relevance),h=h.parent;while(h!==b.parent);b.starts&&l(b.starts,"");return f.returnEnd?0:c.length}if(!e&&C(h.illegalRe,c))throw Error('Illegal lexeme "'+c+'" for mode "'+(h.className||"<unnamed>")+'"');q+=c;return c.length||1}var m=x(a);if(!m)throw Error('Unknown language: "'+a+'"');O(m);var h=b||m,u={},r="";for(b=h;b!==m;b=b.parent)b.className&&(r=f(b.className,"",
+!0)+r);var q="",B=0;try{for(var y,v,z=0;;){h.terminators.lastIndex=z;y=h.terminators.exec(c);if(!y)break;v=n(c.substr(z,y.index-z),y[0]);z=y.index+v}n(c.substr(z));for(b=h;b.parent;b=b.parent)b.className&&(r+="</span>");return{relevance:B,value:r,language:a,top:h}}catch(E){if(E.message&&-1!==E.message.indexOf("Illegal"))return{relevance:0,value:p(c)};throw E;}}function F(a,c){c=c||t.languages||D(w);var b={relevance:0,value:p(a)},g=b;c.filter(x).forEach(function(c){var f=A(c,a,!1);f.language=c;f.relevance>
+g.relevance&&(g=f);f.relevance>b.relevance&&(g=b,b=f)});g.language&&(b.second_best=g);return b}function J(a){return t.tabReplace||t.useBR?a.replace(P,function(a,b){if(t.useBR&&"\n"===a)return"<br>";if(t.tabReplace)return b.replace(/\t/g,t.tabReplace)}):a}function K(a){var c,b,g,d,f;a:if(b=a.className+" ",b+=a.parentNode?a.parentNode.className:"",f=Q.exec(b))f=x(f[1])?f[1]:"no-highlight";else{b=b.split(/\s+/);f=0;for(d=b.length;f<d;f++)if(c=b[f],L.test(c)||x(c)){f=c;break a}f=void 0}L.test(f)||(t.useBR?
+(c=document.createElementNS("http://www.w3.org/1999/xhtml","div"),c.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):c=a,d=c.textContent,b=f?A(f,d,!0):F(d),c=H(c),c.length&&(g=document.createElementNS("http://www.w3.org/1999/xhtml","div"),g.innerHTML=b.value,b.value=N(c,H(g),d)),b.value=J(b.value),a.innerHTML=b.value,d=a.className,f=f?G[f]:b.language,c=[d.trim()],d.match(/\bhljs\b/)||c.push("hljs"),-1===d.indexOf(f)&&c.push(f),f=c.join(" ").trim(),a.className=f,a.result={language:b.language,
+re:b.relevance},b.second_best&&(a.second_best={language:b.second_best.language,re:b.second_best.relevance}))}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");I.forEach.call(a,K)}}function x(a){a=(a||"").toLowerCase();return w[a]||w[G[a]]}var I=[],D=Object.keys,w={},G={},L=/^(no-?highlight|plain|text)$/i,Q=/\blang(?:uage)?-([\w-]+)\b/i,P=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,t={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},M={"&":"&amp;","<":"&lt;",">":"&gt;"};
+b.highlight=A;b.highlightAuto=F;b.fixMarkup=J;b.highlightBlock=K;b.configure=function(a){t=v(t,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,c){var e=w[a]=c(b);e.aliases&&e.aliases.forEach(function(c){G[c]=a})};b.listLanguages=function(){return D(w)};b.getLanguage=x;b.inherit=v;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE=
+"(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE={begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};
+b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/};b.COMMENT=function(a,c,e){a=b.inherit({className:"comment",begin:a,end:c,contains:[]},e||{});a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#",
+"$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE={className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,
+end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};b.registerLanguage("bash",function(a){var c={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},b={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,c,{className:"variable",begin:/\$\(/,
+end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/-?[a-z\.]+/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
+_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,b,{className:"string",begin:/'/,end:/'/},c]}});b.registerLanguage("clojure",function(a){var c={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},b=a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),g=a.COMMENT(";","$",{relevance:0}),
+d={className:"literal",begin:/\b(true|false|nil)\b/},f={begin:"[\\[\\{]",end:"[\\]\\}]"},k={className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},l=a.COMMENT("\\^\\{","\\}"),n={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},m={begin:"\\(",end:"\\)"},h={endsWithParent:!0,relevance:0},p={keywords:{"builtin-name":"def defonce cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},
+lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:h},r=[m,b,k,l,g,n,f,c,d,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];m.contains=[a.COMMENT("comment",""),p,h];h.contains=r;f.contains=r;return{aliases:["clj"],illegal:/\S/,contains:[m,b,k,l,g,n,f,c,d]}});b.registerLanguage("cpp",function(a){var c={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},b={className:"string",variants:[{begin:'(u8?|U)?L?"',
+end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},g={className:"number",variants:[{begin:"\\b(0b[01'_]+)"},{begin:"\\b([\\d'_]+(\\.[\\d'_]*)?|\\.[\\d'_]+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9'_]+|(\\b[\\d'_]+(\\.[\\d'_]*)?|\\.[\\d'_]+)([eE][-+]?[\\d'_]+)?)"}],relevance:0},d={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},
+contains:[{begin:/\\\n/,relevance:0},a.inherit(b,{className:"meta-string"}),{className:"meta-string",begin:"<",end:">",illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},f=a.IDENT_RE+"\\s*\\(",k={keyword:"int float while private char catch export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const struct for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using class asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return",
+built_in:"std string cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr",
+literal:"true false nullptr NULL"},l=[c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,g,b];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:k,illegal:"</",contains:l.concat([d,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:k,contains:["self",c]},{begin:a.IDENT_RE+"::",keywords:k},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
+end:/;/}],keywords:k,contains:l.concat([{begin:/\(/,end:/\)/,keywords:k,contains:l.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:k,illegal:/[^\w\s\*&]/,contains:[{begin:f,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,g,c]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
+d]}]),exports:{preprocessor:d,strings:b,keywords:k}}});b.registerLanguage("cs",function(a){var c={keyword:"abstract as base bool break byte case catch char checked const continue decimal dynamic default delegate do double else enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long when object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual volatile void while async nameof ascending descending from get group into join let orderby partial select set value var where yield",
+literal:"null false true"},b={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},g=a.inherit(b,{illegal:/\n/}),d={className:"subst",begin:"{",end:"}",keywords:c},f=a.inherit(d,{illegal:/\n/}),k={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},a.BACKSLASH_ESCAPE,f]},l={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},d]},n=a.inherit(l,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},f]});d.contains=
+[l,k,b,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE];f.contains=[n,k,g,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.inherit(a.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];b={variants:[l,k,b,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]};g=a.IDENT_RE+"(<"+a.IDENT_RE+">)?(\\[\\])?";return{aliases:["csharp"],keywords:c,illegal:/::/,contains:[a.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:"</?",
+end:">"}]}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},b,a.C_NUMBER_MODE,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.TITLE_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},
+{beginKeywords:"new return throw await",relevance:0},{className:"function",begin:"("+g+"\\s+)+"+a.IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:c,contains:[{begin:a.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:c,relevance:0,contains:[b,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}});b.registerLanguage("css",function(a){return{case_insensitive:!0,
+illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,
+a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},
+a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("d",function(a){var b=a.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
+built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},{className:"string",begin:'"',contains:[{begin:"\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",relevance:0}],
+end:'"[cwd]?'},{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},{className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(i|[fF]i|Li))",
+relevance:0},{className:"number",begin:"\\b((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(L|u|U|Lu|LU|uL|UL)?",relevance:0},{className:"string",begin:"'(\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};|.)",end:"'",illegal:"."},{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}});b.registerLanguage("markdown",
+function(a){return{aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$"},{begin:"^.+?\\n[=-]{2,}$"}]},{begin:"<",end:">",subLanguage:"xml",relevance:0},{className:"bullet",begin:"^([*+-]|(\\d+\\.))\\s+"},{className:"strong",begin:"[*_]{2}.+?[*_]{2}"},{className:"emphasis",variants:[{begin:"\\*.+?\\*"},{begin:"_.+?_",relevance:0}]},{className:"quote",begin:"^>\\s+",end:"$"},{className:"code",variants:[{begin:"^```w*s*$",end:"^```s*$"},{begin:"`.+?`"},{begin:"^( {4}|\t)",
+end:"$",relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},{begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",
+begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}});b.registerLanguage("dart",function(a){var b={className:"subst",begin:"\\$\\{",end:"}",keywords:"true false null this is new super"},e={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[a.BACKSLASH_ESCAPE,b]},{begin:'"""',end:'"""',contains:[a.BACKSLASH_ESCAPE,b]},{begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
+b]},{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]}]};b.contains=[a.C_NUMBER_MODE,e];return{keywords:{keyword:"assert async await break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch sync this throw true try var void while with yield abstract as dynamic export external factory get implements import library operator part set static typedef",built_in:"print Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double int num document window querySelector querySelectorAll Element ElementList"},
+contains:[e,a.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown"}),a.COMMENT("///","$",{subLanguage:"markdown"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
+literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],keywords:b,illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[a.QUOTE_STRING_MODE,{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"`"}]},{className:"number",variants:[{begin:a.C_NUMBER_RE+"[dflsi]",relevance:1},a.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",end:/\s*\{/,excludeEnd:!0,
+contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},e={className:"meta",begin:"{-#",end:"#-}"},g={className:"meta",begin:"^#",end:"$"},d={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},f={begin:"\\(",end:"\\)",illegal:'"',contains:[e,g,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
+b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[f,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[f,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
+end:"where",keywords:"class family instance where",contains:[d,f,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[e,d,f,{begin:"{",end:"}",contains:f.contains},b]},{beginKeywords:"default",end:"$",contains:[d,f,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[d,a.QUOTE_STRING_MODE,
+b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},e,g,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,d,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){var b=a.UNDERSCORE_IDENT_RE+"(<"+a.UNDERSCORE_IDENT_RE+"(\\s*,\\s*"+a.UNDERSCORE_IDENT_RE+")*>)?";return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",
+illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"("+
+b+"\\s+)+"+a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,
+relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,
+a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("javascript",function(a){return{aliases:["js","jsx"],keywords:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
+literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},
+contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}],
+illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},e=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],g={end:",",endsWithParent:!0,excludeEnd:!0,contains:e,keywords:b},d={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,
+end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(g,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(g)],illegal:"\\S"};e.splice(e.length,0,d,a);return{contains:e,keywords:b,illegal:"\\S"}});b.registerLanguage("lisp",function(a){var b={className:"literal",begin:"\\b(t{1}|nil)\\b"},e={className:"number",variants:[{begin:"(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},
+{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{begin:"#(c|C)\\((\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)? +(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",end:"\\)"}]},g=a.inherit(a.QUOTE_STRING_MODE,{illegal:null});a=a.COMMENT(";","$",{relevance:0});var d={begin:"\\*",end:"\\*"},f={className:"symbol",begin:"[:&][a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},k={begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",
+relevance:0},l={contains:[e,g,d,f,{begin:"\\(",end:"\\)",contains:["self",b,g,e,k]},k],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},n={variants:[{begin:"'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"#'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*(::[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*)*"}]},m={begin:"\\(\\s*",end:"\\)"},
+h={endsWithParent:!0,relevance:0};m.contains=[{className:"name",variants:[{begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"\\|[^]*?\\|"}]},h];h.contains=[l,n,m,b,e,g,a,d,f,{begin:"\\|[^]*?\\|"},k];return{illegal:/\S/,contains:[e,{className:"meta",begin:"^#!",end:"$"},b,g,a,l,n,m,k]}});b.registerLanguage("lua",function(a){var b={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},e=[a.COMMENT("--(?!\\[=*\\[)","$"),a.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[b],
+relevance:10})];return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{keyword:"and break do else elseif end false for if in local nil not or repeat return then true until while",built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"},contains:e.concat([{className:"function",beginKeywords:"function",
+end:"\\)",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:e}].concat(e)},a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[b],relevance:5}])}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",
+endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",
+end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});
+b.registerLanguage("objectivec",function(a){var b=/[a-zA-Z@][a-zA-Z0-9_]*/;return{aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
+literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},lexemes:b,illegal:"</",contains:[{className:"built_in",begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"}]},
+{className:"meta",begin:"#",end:"$",contains:[{className:"meta-string",variants:[{begin:'"',end:'"'},{begin:"<",end:">"}]}]},{className:"class",begin:"(@interface|@class|@protocol|@implementation)\\b",end:"({|$)",excludeEnd:!0,keywords:"@interface @class @protocol @implementation",lexemes:b,contains:[a.UNDERSCORE_TITLE_MODE]},{begin:"\\."+a.UNDERSCORE_IDENT_RE,relevance:0}]}});b.registerLanguage("ocaml",function(a){return{aliases:["ml"],keywords:{keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
+built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",literal:"true false"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},a.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0},
+a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
+e={begin:"->{",end:"}"},g={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},d=[a.BACKSLASH_ESCAPE,b,g];a=[g,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),e,{className:"string",contains:d,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
+end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+a.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",
+relevance:0,contains:[a.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[a.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[a.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];b.contains=
+a;e.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
+contains:a}});b.registerLanguage("php",function(a){var b={begin:"\\$+[a-zA-Z_\u007f-\u00ff][a-zA-Z0-9_\u007f-\u00ff]*"},e={className:"meta",begin:/<\?(php)?|\?>/},g={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},d={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{aliases:["php3","php4","php5","php6"],case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",
+contains:[a.HASH_COMMENT_MODE,a.COMMENT("//","$",{contains:[e]}),a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[a.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},e,{className:"keyword",begin:/\$this\b/},b,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},
+{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:["self",b,a.C_BLOCK_COMMENT_MODE,g,d]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},
+{begin:"=>"},g,d]}});b.registerLanguage("protobuf",function(a){return{keywords:{keyword:"package import option optional required repeated group",built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},
+{className:"function",beginKeywords:"rpc",end:/;/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}});b.registerLanguage("python",function(a){var b={className:"meta",begin:/^(>>>|\.\.\.) /},e={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[b],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[b],relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,
+end:/'/},{begin:/(b|br)"/,end:/"/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},g={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]};return{aliases:["py","gyp"],keywords:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},
+illegal:/(<\/|->|\?)/,contains:[b,g,e,a.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def",relevance:10},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,contains:["self",b,g,e]},{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("ruby",function(a){var b={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",
+literal:"true false nil"},e={className:"doctag",begin:"@[A-Za-z]+"},g={begin:"#<",end:">"},e=[a.COMMENT("#","$",{contains:[e]}),a.COMMENT("^\\=begin","^\\=end",{contains:[e],relevance:10}),a.COMMENT("^__END__","\\n$")],d={className:"subst",begin:"#\\{",end:"}",keywords:b},f={className:"string",contains:[a.BACKSLASH_ESCAPE,d],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",
+end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},k={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:b};a=[f,g,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+a.IDENT_RE+"::)?"+a.IDENT_RE}]}].concat(e)},
+{className:"function",beginKeywords:"def",end:"$|;",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}),k].concat(e)},{begin:a.IDENT_RE+"::"},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[f,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
+relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:b},{begin:"("+a.RE_STARTERS_RE+")\\s*",contains:[g,{className:"regexp",contains:[a.BACKSLASH_ESCAPE,d],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(e),relevance:0}].concat(e);d.contains=a;k.contains=a;return{aliases:["rb","gemspec","podspec","thor","irb"],keywords:b,
+illegal:/\/\*/,contains:e.concat([{begin:/^\s*=>/,starts:{end:"$",contains:a}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:a}}]).concat(a)}});b.registerLanguage("rust",function(a){var b=a.inherit(a.C_BLOCK_COMMENT_MODE);b.contains.push("self");return{aliases:["rs"],keywords:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self Self sizeof static struct super trait true type typeof unsafe unsized use virtual while where yield move default int i8 i16 i32 i64 isize uint u8 u32 u64 usize float f32 f64 str char bool",
+literal:"true false Some None Ok Err",built_in:"Copy Send Sized Sync Drop Fn FnMut FnOnce drop Box ToOwned Clone PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator Option Result SliceConcatExt String ToString Vec assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules!"},
+lexemes:a.IDENT_RE+"!?",illegal:"</",contains:[a.C_LINE_COMMENT_MODE,b,a.inherit(a.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{className:"string",variants:[{begin:/r(#*)".*?"\1(?!#)/},{begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{begin:"\\b0b([01_]+)([uif](8|16|32|64|size))?"},{begin:"\\b0o([0-7_]+)([uif](8|16|32|64|size))?"},{begin:"\\b0x([A-Fa-f0-9_]+)([uif](8|16|32|64|size))?"},{begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([uif](8|16|32|64|size))?"}],
+relevance:0},{className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#\\!?\\[",end:"\\]",contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",beginKeywords:"type",end:";",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"\\S"},{className:"class",beginKeywords:"trait enum struct",end:"{",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"},{begin:a.IDENT_RE+
+"::",keywords:{built_in:"Copy Send Sized Sync Drop Fn FnMut FnOnce drop Box ToOwned Clone PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator Option Result SliceConcatExt String ToString Vec assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules!"}},
+{begin:"->"}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},e={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},g={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},e,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[g]},{className:"class",beginKeywords:"class object trait type",
+end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},g]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("sql",function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke",
+end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
+literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text varchar varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}]},{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b]},
+a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("swift",function(a){var b={keyword:"__COLUMN__ __FILE__ __FUNCTION__ __LINE__ as as! as? associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",
+literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},
+e=a.COMMENT("/\\*","\\*/",{contains:["self"]}),g={className:"subst",begin:/\\\(/,end:"\\)",keywords:b,contains:[]},d={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0},f=a.inherit(a.QUOTE_STRING_MODE,{contains:[g,a.BACKSLASH_ESCAPE]});g.contains=[d];return{keywords:b,contains:[f,a.C_LINE_COMMENT_MODE,e,{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},d,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,
+contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin:/</,end:/>/},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,contains:["self",d,f,a.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:b,end:"\\{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/})]},{className:"meta",begin:"(@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain)"},
+{beginKeywords:"import",end:/$/,contains:[a.C_LINE_COMMENT_MODE,e]}]}});b.registerLanguage("typescript",function(a){var b={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void"};
+return{aliases:["ts"],keywords:b,contains:[{className:"meta",begin:/^\s*['"]use strict['"]/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE],relevance:0},{className:"function",begin:"function",end:/[\{;]/,excludeEnd:!0,keywords:b,contains:["self",a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0},{begin:/module\./,keywords:{built_in:"module"},
+relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+a.IDENT_RE,relevance:0}]}});b.registerLanguage("yaml",function(a){var b={className:"attr",variants:[{begin:"^[ \\-]*[a-zA-Z_][\\w\\-]*:"},{begin:'^[ \\-]*"[a-zA-Z_][\\w\\-]*":'},{begin:"^[ \\-]*'[a-zA-Z_][\\w\\-]*':"}]},e={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}],contains:[a.BACKSLASH_ESCAPE,
+{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[b,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:e.contains,end:b.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!!"+a.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+a.UNDERSCORE_IDENT_RE+"$"},
+{className:"meta",begin:"\\*"+a.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},e,a.HASH_COMMENT_MODE,a.C_NUMBER_MODE],keywords:{literal:"{ } true false yes no Yes No True False null"}}});return b});
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
new file mode 100644
index 0000000..74ab00a
--- /dev/null
+++ b/lib/httpcomponents/BUILD
@@ -0,0 +1,29 @@
+java_library(
+  name = 'fluent-hc',
+  exports = ['@fluent_hc//jar'],
+  runtime_deps = [':httpclient'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'httpclient',
+  exports = ['@httpclient//jar'],
+  runtime_deps = [
+    '//lib/commons:codec',
+    ':httpcore',
+    '//lib/log:jcl-over-slf4j',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'httpcore',
+  exports = ['@httpcore//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'httpmime',
+  exports = ['@httpmime//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/jetty/BUCK b/lib/jetty/BUCK
index 342ae61..cc22b80 100644
--- a/lib/jetty/BUCK
+++ b/lib/jetty/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '9.2.13.v20150730'
+VERSION = '9.2.14.v20151106'
 EXCLUDE = ['about.html']
 
 maven_jar(
   name = 'servlet',
   id = 'org.eclipse.jetty:jetty-servlet:' + VERSION,
-  sha1 = '5ad6e38015a97ae9a60b6c2ad744ccfa9cf93a50',
+  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
   license = 'Apache2.0',
   deps = [':security'],
   exclude = EXCLUDE,
@@ -15,7 +15,7 @@
 maven_jar(
   name = 'security',
   id = 'org.eclipse.jetty:jetty-security:' + VERSION,
-  sha1 = 'cc7c7f27ec4cc279253be1675d9e47e58b995943',
+  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
   license = 'Apache2.0',
   deps = [':server'],
   exclude = EXCLUDE,
@@ -25,7 +25,7 @@
 maven_jar(
   name = 'servlets',
   id = 'org.eclipse.jetty:jetty-servlets:' + VERSION,
-  sha1 = '23eb48f1d889d45902e400750460d4cd94d74663',
+  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = [
@@ -37,7 +37,7 @@
 maven_jar(
   name = 'server',
   id = 'org.eclipse.jetty:jetty-server:' + VERSION,
-  sha1 = '5be7d1da0a7abffd142de3091d160717c120b6ab',
+  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
   license = 'Apache2.0',
   exported_deps = [
     ':continuation',
@@ -49,7 +49,7 @@
 maven_jar(
   name = 'jmx',
   id = 'org.eclipse.jetty:jetty-jmx:' + VERSION,
-  sha1 = 'a2ebbbcb47ed98ecd23be550f77e8dadc9f9a800',
+  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
   license = 'Apache2.0',
   exported_deps = [
     ':continuation',
@@ -61,7 +61,7 @@
 maven_jar(
   name = 'continuation',
   id = 'org.eclipse.jetty:jetty-continuation:' + VERSION,
-  sha1 = 'f6bd4e6871ecd0a5e7a5e5addcea160cd73f81bb',
+  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
   license = 'Apache2.0',
   exclude = EXCLUDE,
 )
@@ -69,7 +69,7 @@
 maven_jar(
   name = 'http',
   id = 'org.eclipse.jetty:jetty-http:' + VERSION,
-  sha1 = '23a745d9177ef67ef53cc46b9b70c5870082efc2',
+  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
   license = 'Apache2.0',
   exported_deps = [':io'],
   exclude = EXCLUDE,
@@ -78,7 +78,7 @@
 maven_jar(
   name = 'io',
   id = 'org.eclipse.jetty:jetty-io:' + VERSION,
-  sha1 = '7a351e6a1b63dfd56b6632623f7ca2793ffb67ad',
+  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
   license = 'Apache2.0',
   exported_deps = [':util'],
   exclude = EXCLUDE,
@@ -88,7 +88,7 @@
 maven_jar(
   name = 'util',
   id = 'org.eclipse.jetty:jetty-util:' + VERSION,
-  sha1 = 'c101476360a7cdd0670462de04053507d5e70c97',
+  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = [],
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
new file mode 100644
index 0000000..da3af1c
--- /dev/null
+++ b/lib/jetty/BUILD
@@ -0,0 +1,67 @@
+java_library(
+  name = 'servlet',
+  exports = ['@jetty_servlet//jar'],
+  runtime_deps = [':security'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'security',
+  exports = ['@jetty_security//jar'],
+  runtime_deps = [':server'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'servlets',
+  exports = ['@jetty_servlets//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'server',
+  exports = [
+    '@jetty_server//jar',
+    ':continuation',
+    ':http',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jmx',
+  exports = [
+    '@jetty_jmx//jar',
+    ':continuation',
+    ':http',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'continuation',
+  exports = ['@jetty_continuation//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'http',
+  exports = [
+    '@jetty_http//jar',
+    ':io',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'io',
+  exports = [
+    '@jetty_io//jar',
+    ':util',
+  ],
+)
+
+java_library(
+  name = 'util',
+  exports = ['@jetty_util//jar'],
+)
diff --git a/lib/jgit/BUCK b/lib/jgit/BUCK
deleted file mode 100644
index a26f076..0000000
--- a/lib/jgit/BUCK
+++ /dev/null
@@ -1,89 +0,0 @@
-include_defs('//lib/maven.defs')
-
-REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
-VERS = '4.1.2.201602141800-r'
-
-maven_jar(
-  name = 'jgit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit:' + VERS,
-  bin_sha1 = '123620d124bbea23b7ee2d8ec3eccbb59c7be921',
-  src_sha1 = '6d3b5c60170d6df3dd74cf4948d7f16feb390333',
-  license = 'jgit',
-  repository = REPO,
-  unsign = True,
-  deps = [':ewah'],
-  exclude = [
-    'META-INF/eclipse.inf',
-    'about.html',
-    'plugin.properties',
-  ],
-)
-
-maven_jar(
-  name = 'jgit-servlet',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + VERS,
-  sha1 = '76bf6924f0668abc65f9c4aa88492ee0a9d59544',
-  license = 'jgit',
-  repository = REPO,
-  deps = [':jgit'],
-  unsign = True,
-  exclude = [
-    'about.html',
-    'plugin.properties',
-  ],
-)
-
-maven_jar(
-  name = 'jgit-archive',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + VERS,
-  sha1 = '94a29ae139c9965ad95d3e14f5fd3a6ff28a7910',
-  license = 'jgit',
-  repository = REPO,
-  deps = [':jgit',
-    '//lib/commons:compress',
-    '//lib:tukaani-xz',
-  ],
-  unsign = True,
-  exclude = [
-    'about.html',
-    'plugin.properties',
-  ],
-)
-
-maven_jar(
-  name = 'junit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + VERS,
-  sha1 = '9ee5941c42e3991b1a90f35e9de63854c4b5c474',
-  license = 'DO_NOT_DISTRIBUTE',
-  repository = REPO,
-  unsign = True,
-  deps = [':jgit'],
-)
-
-maven_jar(
-  name = 'ewah',
-  id = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
-  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
-  license = 'Apache2.0',
-)
-
-gwt_module(
-  name = 'Edit',
-  srcs = [':jgit_edit_src'],
-  deps = [':edit_src'],
-  visibility = ['PUBLIC'],
-)
-
-prebuilt_jar(
-  name = 'edit_src',
-  binary_jar = ':jgit_edit_src',
-)
-
-genrule(
-  name = 'jgit_edit_src',
-  cmd = 'unzip -qd $TMP $(location :jgit_src) ' +
-    'org/eclipse/jgit/diff/Edit.java;' +
-    'cd $TMP;' +
-    'zip -Dq $OUT org/eclipse/jgit/diff/Edit.java',
-  out = 'edit.src.zip',
-)
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUCK b/lib/jgit/org.eclipse.jgit.archive/BUCK
new file mode 100644
index 0000000..ab95e2a
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.archive/BUCK
@@ -0,0 +1,16 @@
+include_defs('//lib/maven.defs')
+include_defs('//lib/JGIT_VERSION')
+
+maven_jar(
+  name = 'jgit-archive',
+  id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + VERS,
+  sha1 = 'd70270a355e8582c21a74c34fae0dfec4b641632',
+  license = 'jgit',
+  repository = REPO,
+  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+  unsign = True,
+  exclude = [
+    'about.html',
+    'plugin.properties',
+  ],
+ )
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
new file mode 100644
index 0000000..8fa94f2
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -0,0 +1,6 @@
+java_library(
+  name = 'jgit-archive',
+  exports = ['@jgit_archive//jar'],
+  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUCK b/lib/jgit/org.eclipse.jgit.http.server/BUCK
new file mode 100644
index 0000000..c157bec
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUCK
@@ -0,0 +1,16 @@
+include_defs('//lib/maven.defs')
+include_defs('//lib/JGIT_VERSION')
+
+maven_jar(
+  name = 'jgit-servlet',
+  id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + VERS,
+  sha1 = '04cf57795ffd2a577a20746f9c6a0b0922438dd9',
+  license = 'jgit',
+  repository = REPO,
+  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+  unsign = True,
+  exclude = [
+    'about.html',
+    'plugin.properties',
+  ],
+)
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
new file mode 100644
index 0000000..6a442cc
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -0,0 +1,6 @@
+java_library(
+  name = 'jgit-servlet',
+  exports = ['@jgit_servlet//jar'],
+  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUCK b/lib/jgit/org.eclipse.jgit.junit/BUCK
new file mode 100644
index 0000000..043fe62
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.junit/BUCK
@@ -0,0 +1,12 @@
+include_defs('//lib/maven.defs')
+include_defs('//lib/JGIT_VERSION')
+
+maven_jar(
+  name = 'junit',
+  id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + VERS,
+  sha1 = '5eaebfd74c106ac5cbee9e4303f19165812f23c6',
+  license = 'DO_NOT_DISTRIBUTE',
+  repository = REPO,
+  unsign = True,
+  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+)
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
new file mode 100644
index 0000000..d00b82c9
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -0,0 +1,6 @@
+java_library(
+  name = 'junit',
+  exports = ['@jgit_junit//jar'],
+  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/jgit/org.eclipse.jgit/BUCK b/lib/jgit/org.eclipse.jgit/BUCK
new file mode 100644
index 0000000..a0c3fae
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit/BUCK
@@ -0,0 +1,25 @@
+include_defs('//lib/maven.defs')
+include_defs('//lib/JGIT_VERSION')
+
+maven_jar(
+  name = 'jgit',
+  id = 'org.eclipse.jgit:org.eclipse.jgit:' + VERS,
+  bin_sha1 = '01352ebdefb10aab661b97413f651bd6ca3766ce',
+  src_sha1 = 'ad713d18ed0fa71279b002e2c1340a35dfbefee7',
+  license = 'jgit',
+  repository = REPO,
+  unsign = True,
+  deps = [':ewah'],
+  exclude = [
+    'META-INF/eclipse.inf',
+    'about.html',
+    'plugin.properties',
+  ],
+)
+
+maven_jar(
+  name = 'ewah',
+  id = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
+  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
+  license = 'Apache2.0',
+)
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
new file mode 100644
index 0000000..a1f9cad
--- /dev/null
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -0,0 +1,12 @@
+java_library(
+  name = 'jgit',
+  exports = ['@jgit//jar'],
+  runtime_deps = [':ewah'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'ewah',
+  exports = ['@ewah//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/joda/BUCK b/lib/joda/BUCK
index 420a8ac..d78c456 100644
--- a/lib/joda/BUCK
+++ b/lib/joda/BUCK
@@ -7,8 +7,8 @@
 
 maven_jar(
   name = 'joda-time',
-  id = 'joda-time:joda-time:2.8',
-  sha1 = '9f2785d7184b97d005a44241ccaf980f43b9ccdb',
+  id = 'joda-time:joda-time:2.9.4',
+  sha1 = '1c295b462f16702ebe720bbb08f62e1ba80da41b',
   deps = [':joda-convert'],
   license = 'Apache2.0',
   exclude = EXCLUDE,
@@ -17,8 +17,8 @@
 
 maven_jar(
   name = 'joda-convert',
-  id = 'org.joda:joda-convert:1.2',
-  bin_sha1 = '35ec554f0cd00c956cc69051514d9488b1374dec',
+  id = 'org.joda:joda-convert:1.8.1',
+  sha1 = '675642ac208e0b741bc9118dcbcae44c271b992a',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = ['//lib/joda:joda-time'],
diff --git a/lib/joda/BUILD b/lib/joda/BUILD
new file mode 100644
index 0000000..a673bf5
--- /dev/null
+++ b/lib/joda/BUILD
@@ -0,0 +1,11 @@
+java_library(
+  name = 'joda-time',
+  exports = ['@joda_time//jar'],
+  runtime_deps = ['joda-convert'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'joda-convert',
+  exports = ['@joda_convert//jar'],
+)
diff --git a/lib/js.defs b/lib/js.defs
new file mode 100644
index 0000000..c9a4256
--- /dev/null
+++ b/lib/js.defs
@@ -0,0 +1,171 @@
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+NPMJS = 'NPMJS'
+GERRIT = 'GERRIT'
+
+# NOTE: npm_binary rules do not get their licenses checked by gen_licenses.py,
+# as we would have to cut too many edges. DO NOT include these binaries in
+# build outputs. Using them in the build _process_ is ok.
+def npm_binary(
+    name,
+    version,
+    sha1 = '',
+    repository = NPMJS,
+    visibility = ['PUBLIC']):
+
+  dir = '%s-%s' % (name, version)
+  filename = '%s.tgz' % dir
+  dest = '%s@%s.npm_binary.tgz' % (name, version)
+  if repository == GERRIT:
+    url = 'http://gerrit-maven.storage.googleapis.com/npm-packages/%s' % filename
+  elif repository == NPMJS:
+    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+  else:
+    raise ValueError('invalid repository: %s' % repository)
+  cmd = ['$(exe //tools:download_file)', '-o', '$OUT', '-u', url]
+  if sha1:
+    cmd.extend(['-v', sha1])
+  genrule(
+    name = name,
+    cmd = ' '.join(cmd),
+    out = dest,
+    visibility = visibility,
+  )
+
+
+def run_npm_binary(target):
+  return '$(location //tools/js:run_npm_binary) $(location %s)' % target
+
+
+def bower_component(
+    name,
+    package,
+    version,
+    license,
+    deps = [],
+    semver = None,
+    sha1 = '',
+    visibility = ['PUBLIC']):
+  download_name = '%s__download_bower' % name
+  genrule(
+    name = download_name,
+    cmd = ' '.join([
+      '$(exe //tools/js:download_bower)',
+      '-b', '"%s"' % run_npm_binary('//lib/js:bower'),
+      '-n', name,
+      '-p', package,
+      '-v', version,
+      '-s', sha1,
+      '-o', '$OUT',
+    ]),
+    out = '%s.zip' % download_name,
+    license = license,
+    visibility = [],
+  )
+
+  renamed_name = '%s__renamed' % name
+  genrule(
+    name = renamed_name,
+    cmd = ' && '.join([
+      'cd $TMP',
+      'mkdir bower_components',
+      'cd bower_components',
+      'unzip $(location :%s)' % download_name,
+      'cd ..',
+      'zip -r $OUT bower_components',
+    ]),
+    out = '%s.zip' % renamed_name,
+    visibility = [],
+  )
+
+  genrule(
+    name = name,
+    cmd = _combine_components([':%s' % renamed_name] + deps),
+    out = '%s-%s.zip' % (name, version),
+    visibility = visibility,
+  )
+
+  version_name = '%s__bower_version' % name
+  dep_version = semver if semver is not None else version
+  deps_json = '{"%s": "%s#%s"}' % (name, package, dep_version)
+  genrule(
+    name = version_name,
+    cmd = "echo '%s' > $OUT" % deps_json,
+    out = version_name,
+    visibility = visibility,
+  )
+
+
+def bower_components(
+    name,
+    deps,
+    visibility = ['PUBLIC']):
+  genrule(
+    name = name,
+    cmd = _combine_components(deps),
+    out = '%s.bower_components.zip' % name,
+    visibility = visibility,
+  )
+
+
+def _combine_components(deps):
+  cmds = ['cd $TMP']
+  for d in deps:
+    cmds.append('unzip -qo $(location %s)' % d)
+  cmds.append('zip -r $OUT bower_components')
+  return ' && '.join(cmds)
+
+
+VULCANIZE_FLAGS = [
+  '--inline-scripts',
+  '--inline-css',
+  '--strip-comments',
+]
+
+def vulcanize(
+    name,
+    app,
+    srcs,
+    components,
+    extra_flags = [],
+    visibility = ['PUBLIC']):
+  genrule(
+    name = '%s__vulcanized' % name,
+    cmd = ' '.join([
+      'unzip', '-qd', '$TMP', '$(location %s)' % components,
+      '&&', 'rm', '-rf', '$SRCDIR/bower_components',
+      '&&', 'ln', '-s', '-f', '$TMP/bower_components', '.',
+      '&&', run_npm_binary('//lib/js:vulcanize')
+    ] + VULCANIZE_FLAGS + extra_flags + [
+      '--out-html', '$OUT',
+      '$SRCDIR/%s' % app,
+    ]),
+    srcs = srcs,
+    out = '%s.vulcanized.html' % name,
+    visibility = visibility,
+  )
+
+  genrule(
+    name = name,
+    cmd = ' '.join([
+      'cd', '$TMP',
+      '&&', run_npm_binary('//lib/js:crisper'), '--always-write-script',
+      '--source', '$(location :%s__vulcanized)' % name,
+      '--html', '%s.html' % name,
+      '--js', '%s.js' % name,
+      '&&', 'zip', '$OUT', '%s.html' % name, '%s.js' % name,
+    ]),
+    out = '%s.vulcanized.zip' % name,
+  )
diff --git a/lib/js/BUCK b/lib/js/BUCK
new file mode 100644
index 0000000..1c46d35
--- /dev/null
+++ b/lib/js/BUCK
@@ -0,0 +1,427 @@
+include_defs('//lib/js.defs')
+
+# WHEN REVIEWING NEW NPM_BINARY RULES:
+#
+# You must check licenses in the transitive closure of dependencies to ensure
+# they can be used by Gerrit. (npm binaries are not distributed with Gerrit
+# releases, so we are less restrictive in our selection of licenses, but we
+# still need to do a sanity check.)
+#
+# To do this:
+#   npm install -g license-checker
+#   mkdir /tmp/npmtmp
+#   cd /tmp/npmtmp
+#   npm install <package>@<version>
+#   license-checker
+# (Piping to grep -o 'licenses:.*' and/or sort -u may make the output saner.)
+
+npm_binary(
+  name = 'bower',
+  version = '1.7.9',
+  sha1 = 'b7296c2393e0d75edaa6ca39648132dd255812b0',
+)
+
+npm_binary(
+  name = 'crisper',
+  version = '2.0.2',
+  sha1 = '7183c58cea33632fb036c91cefd1b43e390d22a2',
+  repository = GERRIT,
+)
+
+npm_binary(
+  name = 'vulcanize',
+  version = '1.14.8',
+  sha1 = '679107f251c19ab7539529b1e3fdd40829e6fc63',
+  repository = GERRIT,
+)
+
+# ## Adding Bower component dependencies
+#
+# 1. Add a dummy bower_component rule to this file, specifying the semantic
+#    version you want to use. The actual version will be filled in by Bower,
+#    after evaluating the full dependency tree.
+#
+#      bower_component(
+#        name = 'somepackage',
+#        package = 'someauthor/somepackage',
+#        version = 'TODO',
+#        semver = '~1.0.0',
+#        license = 'DO_NOT_DISTRIBUTE'
+#      )
+#
+# 2. Add your bower_component as a dep to a bower_components rule.
+#
+#      bower_components(
+#        name = 'polygerrit_components',
+#        deps = [
+#          '//lib/js:foo',
+#          '//lib/js:somepackage',  # NEW
+#        ],
+#      )
+#
+# 3. Run bower2buck.py.
+#
+#      buck run //tools/js:bower2buck -- -o /tmp/newbuck
+#
+# 4. Use your favorite diff tool to merge the output in newbuck with this file.
+#    bower2buck reevaluates semantic versions and may upgrade some packages, so
+#    you may need to make changes beyond the new component that was added.
+#
+#      meld /tmp/newbuck lib/js/BUCK
+#
+#
+# ## Updating Bower component dependencies
+#
+# Use the same procedure as for adding dependencies, except just change the
+# version number of the existing bower_component rather than adding a new rule.
+
+bower_component(
+  name = 'accessibility-developer-tools',
+  package = 'accessibility-developer-tools',
+  version = '2.10.0',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'bc1a5e56ff1bed7a7a6ef22a4b4e8300e4822aa5',
+)
+
+bower_component(
+  name = 'async',
+  package = 'async',
+  version = '1.5.2',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '1ec975d3b3834646a7e3d4b7e68118b90ed72508',
+)
+
+bower_component(
+  name = 'chai',
+  package = 'chai',
+  version = '3.5.0',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '849ad3ee7c77506548b7b5db603a4e150b9431aa',
+)
+
+bower_component(
+  name = 'es6-promise',
+  package = 'stefanpenner/es6-promise',
+  version = '3.3.0',
+  license = 'es6-promise',
+  sha1 = 'a3a797bb22132f1ef75f9a2556173f81870c2e53',
+)
+
+bower_component(
+  name = 'fetch',
+  package = 'fetch',
+  version = '1.0.0',
+  license = 'fetch',
+  sha1 = '1b05a2bb40c73232c2909dc196de7519fe4db7a9',
+)
+
+bower_component(
+  name = 'iron-a11y-announcer',
+  package = 'polymerelements/iron-a11y-announcer',
+  version = '1.0.4',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '9a915711b35092fa2f86ff6e904c4f3e43aa5234',
+)
+
+bower_component(
+  name = 'iron-a11y-keys-behavior',
+  package = 'polymerelements/iron-a11y-keys-behavior',
+  version = '1.1.2',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '57fd39ee153ce37ed719ba3f7a405afb987d54f9',
+)
+
+bower_component(
+  name = 'iron-autogrow-textarea',
+  package = 'polymerelements/iron-autogrow-textarea',
+  version = '1.0.12',
+  deps = [
+    ':iron-behaviors',
+    ':iron-flex-layout',
+    ':iron-form-element-behavior',
+    ':iron-validatable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'b9b6874c9a2b5be435557a827ff8bd6661672ee3',
+)
+
+bower_component(
+  name = 'iron-behaviors',
+  package = 'polymerelements/iron-behaviors',
+  version = '1.0.16',
+  deps = [
+    ':iron-a11y-keys-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'bd70636a2c0a78c50d1a76f9b8ca1ffd815478a3',
+)
+
+bower_component(
+  name = 'iron-dropdown',
+  package = 'polymerelements/iron-dropdown',
+  version = '1.4.0',
+  deps = [
+    ':iron-a11y-keys-behavior',
+    ':iron-behaviors',
+    ':iron-overlay-behavior',
+    ':iron-resizable-behavior',
+    ':neon-animation',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '63e3d669a09edaa31c4f05afc76b53b919ef0595',
+)
+
+bower_component(
+  name = 'iron-fit-behavior',
+  package = 'polymerelements/iron-fit-behavior',
+  version = '1.2.2',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'bc53e9bab36b21f086ab8fac8c53cc7214aa1890',
+)
+
+bower_component(
+  name = 'iron-flex-layout',
+  package = 'polymerelements/iron-flex-layout',
+  version = '1.3.1',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'ba696394abff5e799fc06eb11bff4720129a1b52',
+)
+
+bower_component(
+  name = 'iron-form-element-behavior',
+  package = 'polymerelements/iron-form-element-behavior',
+  version = '1.0.6',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '8d9e6530edc1b99bec1a5c34853911fba3701220',
+)
+
+bower_component(
+  name = 'iron-input',
+  package = 'polymerelements/iron-input',
+  version = '1.0.10',
+  deps = [
+    ':iron-a11y-announcer',
+    ':iron-validatable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac',
+)
+
+bower_component(
+  name = 'iron-meta',
+  package = 'polymerelements/iron-meta',
+  version = '1.1.1',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'e06281b6ddb3355ceca44975a167381b1fd72ce5',
+)
+
+bower_component(
+  name = 'iron-overlay-behavior',
+  package = 'polymerelements/iron-overlay-behavior',
+  version = '1.7.6',
+  deps = [
+    ':iron-a11y-keys-behavior',
+    ':iron-fit-behavior',
+    ':iron-resizable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '83181085fda59446ce74fd0d5ca30c223f38ee4a',
+)
+
+bower_component(
+  name = 'iron-resizable-behavior',
+  package = 'polymerelements/iron-resizable-behavior',
+  version = '1.0.3',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '5982a3e19af7ed3e3de276a9b7bd266b3a144002',
+)
+
+bower_component(
+  name = 'iron-selector',
+  package = 'polymerelements/iron-selector',
+  version = '1.5.2',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'c57235dfda7fbb987c20ad0e97aac70babf1a1bf',
+)
+
+bower_component(
+  name = 'iron-test-helpers',
+  package = 'polymerelements/iron-test-helpers',
+  version = '1.2.5',
+  deps = [':polymer'],
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '433b03b106f5ff32049b84150cd70938e18b67ac',
+)
+
+bower_component(
+  name = 'iron-validatable-behavior',
+  package = 'polymerelements/iron-validatable-behavior',
+  version = '1.1.1',
+  deps = [
+    ':iron-meta',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '480423380be0536f948735d91bc472f6e7ced5b4',
+)
+
+bower_component(
+  name = 'lodash',
+  package = 'lodash',
+  version = '3.10.1',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '2f207a8293c4c554bf6cf071241f7a00dc513d3a',
+)
+
+bower_component(
+  name = 'mocha',
+  package = 'mocha',
+  version = '2.5.1',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'cb29bdd1047cfd9304659ecf10ec263f9c888c99',
+)
+
+bower_component(
+  name = 'moment',
+  package = 'moment/moment',
+  version = '2.13.0',
+  license = 'moment',
+  sha1 = 'fc8ce2c799bab21f6ced7aff928244f4ca8880aa',
+)
+
+bower_component(
+  name = 'neon-animation',
+  package = 'polymerelements/neon-animation',
+  version = '1.2.3',
+  deps = [
+    ':iron-meta',
+    ':iron-resizable-behavior',
+    ':iron-selector',
+    ':polymer',
+    ':web-animations-js',
+  ],
+  license = 'polymer',
+  sha1 = '71cc0d3e0afdf8b8563e87d2ff03a6fa19183bd9',
+)
+
+bower_component(
+  name = 'page',
+  package = 'visionmedia/page.js',
+  version = '1.7.1',
+  license = 'page.js',
+  sha1 = '51a05428dd4f68fae1df5f12d0e2b61ba67f7757',
+)
+
+bower_component(
+  name = 'polymer',
+  package = 'polymer/polymer',
+  version = '1.4.0',
+  deps = [':webcomponentsjs'],
+  license = 'polymer',
+  sha1 = 'b84725939ead7c7bdf9917b065f68ef8dc790d06',
+)
+
+bower_component(
+  name = 'promise-polyfill',
+  package = 'polymerlabs/promise-polyfill',
+  version = '1.0.0',
+  deps = [':polymer'],
+  license = 'promise-polyfill',
+  sha1 = 'a3b598c06cbd7f441402e666ff748326030905d6',
+)
+
+bower_component(
+  name = 'sinon-chai',
+  package = 'sinon-chai',
+  version = '2.8.0',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '0464b5d944fdf8116bb23e0b02ecfbac945b3517',
+)
+
+bower_component(
+  name = 'sinonjs',
+  package = 'sinonjs',
+  version = '1.17.1',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'a26a6aab7358807de52ba738770f6ac709afd240',
+)
+
+bower_component(
+  name = 'stacky',
+  package = 'stacky',
+  version = '1.3.2',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'd6c07a0112ab2e9677fe085933744466a89232fb',
+)
+
+bower_component(
+  name = 'test-fixture',
+  package = 'polymerelements/test-fixture',
+  version = '1.1.1',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'e373bd21c069163c3a754e234d52c07c77b20d3c',
+)
+
+bower_component(
+  name = 'web-animations-js',
+  package = 'web-animations/web-animations-js',
+  version = '2.2.1',
+  license = 'Apache2.0',
+  sha1 = '0e73b263a86aa6764ad35c273eb12055f83d7eda',
+)
+
+bower_component(
+  name = 'web-component-tester',
+  package = 'web-component-tester',
+  version = '4.2.2',
+  deps = [
+    ':accessibility-developer-tools',
+    ':async',
+    ':chai',
+    ':lodash',
+    ':mocha',
+    ':sinon-chai',
+    ':sinonjs',
+    ':stacky',
+    ':test-fixture',
+  ],
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '54556000c33d9ed7949aa546c1b4a1531491a5f0',
+)
+
+bower_component(
+  name = 'webcomponentsjs',
+  package = 'webcomponentsjs',
+  version = '0.7.22',
+  license = 'polymer',
+  sha1 = '8ba97a4a279ec6973a19b171c462a7b5cf454fb9',
+)
+
+# Zip highlightjs so that it can be imported as though it were a
+# bower_component and also attach the library license to the Buck dependency
+# graph.
+HLJS_DIR = 'bower_components/highlightjs'
+genrule(
+  name = 'highlightjs',
+  cmd = ' && '.join([
+    'mkdir -p %s' % HLJS_DIR,
+    'cp $(location //lib/highlightjs:highlightjs) %s/highlight.min.js' % HLJS_DIR,
+    'zip -r $OUT bower_components',
+  ]),
+  out = 'highlightjs.zip',
+  license = 'highlightjs',
+  visibility = ['PUBLIC'],
+)
diff --git a/lib/log/BUILD b/lib/log/BUILD
new file mode 100644
index 0000000..ac92ab6
--- /dev/null
+++ b/lib/log/BUILD
@@ -0,0 +1,47 @@
+java_library(
+  name = 'api',
+  exports = ['@log_api//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'nop',
+  exports = ['@log_nop//jar'],
+  runtime_deps = [':api'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'impl_log4j',
+  exports = ['@impl_log4j//jar'],
+  runtime_deps = [':log4j'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jcl-over-slf4j',
+  exports = ['@jcl_over_slf4j//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'log4j',
+  exports = ['@log4j//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'jsonevent-layout',
+  exports = ['@jsonevent_layout//jar'],
+  runtime_deps = [
+    ':json-smart',
+    '//lib/commons:lang'
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'json-smart',
+  exports = ['@json_smart//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/lucene/BUCK b/lib/lucene/BUCK
index c5107d5..c4a9872 100644
--- a/lib/lucene/BUCK
+++ b/lib/lucene/BUCK
@@ -1,22 +1,22 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '5.3.0'
+VERSION = '5.5.0'
 
 # core and backward-codecs both provide
 # META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
 merge_maven_jars(
-  name = 'core-and-backward-codecs',
+  name = 'lucene-core-and-backward-codecs',
   srcs = [
     ':backward-codecs_jar',
-    ':core_jar',
+    ':lucene-core',
   ],
   visibility = ['PUBLIC'],
 )
 
 maven_jar(
-  name = 'core_jar',
+  name = 'lucene-core',
   id = 'org.apache.lucene:lucene-core:' + VERSION,
-  sha1 = '9e12bb7c39e964a544e3a23b9c8ffa9599d38f10',
+  sha1 = 'a74fd869bb5ad7fe6b4cd29df9543a34aea81164',
   license = 'Apache2.0',
   exclude = [
     'META-INF/LICENSE.txt',
@@ -26,11 +26,11 @@
 )
 
 maven_jar(
-  name = 'analyzers-common',
+  name = 'lucene-analyzers-common',
   id = 'org.apache.lucene:lucene-analyzers-common:' + VERSION,
-  sha1 = '1502beac94cf437baff848ffbbb8f76172befa6b',
+  sha1 = '1e0e8243a4410be20c34683034fafa7bb52e55cc',
   license = 'Apache2.0',
-  deps = [':core-and-backward-codecs'],
+  deps = [':lucene-core-and-backward-codecs'],
   exclude = [
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
@@ -40,9 +40,9 @@
 maven_jar(
   name = 'backward-codecs_jar',
   id = 'org.apache.lucene:lucene-backward-codecs:' + VERSION,
-  sha1 = 'f654901e55fe56bdbe4be202767296929c2f8d9e',
+  sha1 = '68480974b2f54f519763632a7c1c5d51cbff3805',
   license = 'Apache2.0',
-  deps = [':core_jar'],
+  deps = [':lucene-core'],
   exclude = [
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
@@ -51,11 +51,11 @@
 )
 
 maven_jar(
-  name = 'misc',
+  name = 'lucene-misc',
   id = 'org.apache.lucene:lucene-misc:' + VERSION,
-  sha1 = 'd03ce6d1bb8ab3926b3acc717418c474a49ade69',
+  sha1 = '504d855a1a38190622fdf990b2298c067e7d60ca',
   license = 'Apache2.0',
-  deps = [':core-and-backward-codecs'],
+  deps = [':lucene-core-and-backward-codecs'],
   exclude = [
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
@@ -63,11 +63,11 @@
 )
 
 maven_jar(
-  name = 'queryparser',
+  name = 'lucene-queryparser',
   id = 'org.apache.lucene:lucene-queryparser:' + VERSION,
-  sha1 = '2c5e08580316c90b56a52e3cb686e1cf69db3f9e',
+  sha1 = '0fddc49725b562fd48dff0cff004336ad2a090a4',
   license = 'Apache2.0',
-  deps = [':core-and-backward-codecs'],
+  deps = [':lucene-core-and-backward-codecs'],
   exclude = [
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
new file mode 100644
index 0000000..679c9f0
--- /dev/null
+++ b/lib/lucene/BUILD
@@ -0,0 +1,33 @@
+load('//tools/bzl:maven.bzl', 'merge_maven_jars')
+
+# core and backward-codecs both provide
+# META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
+merge_maven_jars(
+  name = 'lucene-core-and-backward-codecs',
+  srcs = [
+    '@backward_codecs//jar',
+    '@lucene_core//jar',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lucene-analyzers-common',
+  exports = ['@lucene_analyzers_common//jar'],
+  runtime_deps = [':lucene-core-and-backward-codecs'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lucene-misc',
+  exports = ['@lucene_misc//jar'],
+  runtime_deps = [':lucene-core-and-backward-codecs'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lucene-queryparser',
+  exports = ['@lucene_queryparser//jar'],
+  runtime_deps = [':lucene-core-and-backward-codecs'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/maven.defs b/lib/maven.defs
index 5c29bed..913be35 100644
--- a/lib/maven.defs
+++ b/lib/maven.defs
@@ -16,6 +16,7 @@
 GERRIT_API = 'GERRIT_API:'
 MAVEN_CENTRAL = 'MAVEN_CENTRAL:'
 MAVEN_LOCAL = 'MAVEN_LOCAL:'
+MAVEN_SNAPSHOT = 'MAVEN_SNAPSHOT:'
 
 def define_license(name):
   n = 'LICENSE-' + name
@@ -43,37 +44,48 @@
     local_license = False):
   from os import path
 
+  def maven_snapshot(parts):
+    if len(parts) != 4:
+      raise NameError('%s:\nexpected id="groupId:artifactId:version:snapshot]"'
+                      % id)
+    group, artifact, version, snapshot = parts
+    jar = path.join(name,
+      version + '-SNAPSHOT',
+      '-'.join([artifact.lower(), version, snapshot]))
+    url = '/'.join([
+      repository,
+      group.replace('.', '/'),
+      artifact,
+      version + '-SNAPSHOT',
+      '-'.join([artifact.lower(), version, snapshot])])
+    return jar, url
+
+  def maven_release(parts):
+    if len(parts) not in [3, 4]:
+      raise NameError('%s:\nexpected id="groupId:artifactId:version[:classifier]"'
+                      % id)
+    if len(parts) == 4:
+      group, artifact, version, classifier = parts
+      file_version = version + '-' + classifier
+    else:
+      group, artifact, version = parts
+      file_version = version
+
+    jar = path.join(name, artifact.lower() + '-' + file_version)
+    url = '/'.join([
+      repository,
+      group.replace('.', '/'),
+      artifact,
+      version,
+      artifact + '-' + file_version])
+
+    return jar, url
+
   parts = id.split(':')
-  if len(parts) not in [3, 4]:
-    raise NameError('%s:\nexpected id="groupId:artifactId:version[:classifier]"'
-                    % id)
-  if len(parts) == 4:
-    group, artifact, version, classifier = parts
+  if repository.startswith(MAVEN_SNAPSHOT):
+    jar, url = maven_snapshot(parts)
   else:
-    group, artifact, version = parts
-    classifier = None
-
-  # SNAPSHOT artifacts are handled differently on Google storage bucket:
-  # 'SNAPSHOT' is discarded from the directory name. However on other
-  # Maven repositories, most notable local repository located in
-  # ~/.m2/repository (and is supported through MAVEN_LOCAL repository)
-  # it must be preserved, otherwise the artifact wouldn't be found.
-  # Atm the SNAPSHOT part is only discarded for Google storage bucket.
-  if 'SNAPSHOT' in version and repository.startswith(GERRIT):
-    file_version = version.replace('-SNAPSHOT', '')
-    version = version.split('-SNAPSHOT')[0] + '-SNAPSHOT'
-  else:
-    file_version = version
-
-  if classifier is not None:
-    file_version += '-' + classifier
-
-  jar = path.join(name, artifact.lower() + '-' + file_version)
-
-  url = '/'.join([
-    repository,
-    group.replace('.', '/'), artifact, version,
-    artifact + '-' + file_version])
+    jar, url = maven_release(parts)
 
   binjar = jar + '.jar'
   binurl = url + '.jar'
@@ -132,7 +144,6 @@
       deps = deps + license,
       binary_jar = ':%s__download_bin' % name,
       source_jar = ':%s__download_src' % name if srcjar else None,
-      visibility = visibility,
     )
     java_library(
       name = name,
diff --git a/lib/mina/BUCK b/lib/mina/BUCK
index 869fd5c..f22a710 100644
--- a/lib/mina/BUCK
+++ b/lib/mina/BUCK
@@ -8,9 +8,9 @@
 
 maven_jar(
   name = 'sshd',
-  id = 'org.apache.sshd:sshd-core:0.14.0',
-  sha1 = 'cb12fa1b1b07fb5ce3aa4f99b189743897bd4fca',
-  src_sha1 = '44d7e868fcfc85c64b20337d694290792af8281c',
+  id = 'org.apache.sshd:sshd-core:1.2.0',
+  sha1 = '4bc24a8228ba83dac832680366cf219da71dae8e',
+  src_sha1 = '490e3f03d7628ecf1cbb8317563fdbf06e68e29f',
   license = 'Apache2.0',
   deps = [':core'],
   exclude = EXCLUDE,
@@ -18,9 +18,9 @@
 
 maven_jar(
   name = 'core',
-  id = 'org.apache.mina:mina-core:2.0.8',
-  sha1 = 'd6ff69fa049aeaecdf0c04cafbb1ab53b7487883',
-  src_sha1 = 'c7b30746336f59d395d766b03c78a3a0a732ab26',
+  id = 'org.apache.mina:mina-core:2.0.10',
+  sha1 = 'a1cb1136b104219d6238de886bf5a3ea4554eb58',
+  src_sha1 = 'b70ff94ba379b4e825caca1af4ec83193fac4b10',
   license = 'Apache2.0',
   exclude = EXCLUDE,
 )
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
new file mode 100644
index 0000000..52468a4
--- /dev/null
+++ b/lib/mina/BUILD
@@ -0,0 +1,12 @@
+java_library(
+  name = 'sshd',
+  exports = ['@sshd//jar'],
+  visibility = ['//visibility:public'],
+  runtime_deps = [':core'],
+)
+
+java_library(
+  name = 'core',
+  exports = ['@mina_core//jar'],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/openid/BUILD b/lib/openid/BUILD
new file mode 100644
index 0000000..7d97a86
--- /dev/null
+++ b/lib/openid/BUILD
@@ -0,0 +1,23 @@
+java_library(
+  name = 'consumer',
+  exports = ['@openid_consumer//jar'],
+  runtime_deps = [
+    ':nekohtml',
+    ':xerces',
+    '//lib/httpcomponents:httpclient',
+    '//lib/log:jcl-over-slf4j',
+    '//lib/guice:guice',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'nekohtml',
+  exports = ['@nekohtml//jar'],
+  runtime_deps = [':xerces'],
+)
+
+java_library(
+  name = 'xerces',
+  exports = ['@xerces//jar'],
+)
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD
new file mode 100644
index 0000000..0b99b6f
--- /dev/null
+++ b/lib/ow2/BUILD
@@ -0,0 +1,30 @@
+java_library(
+  name = 'ow2-asm',
+  exports = ['@ow2_asm//jar'],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = 'ow2-asm-analysis',
+  exports = ['@ow2_asm_analysis//jar'],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = 'ow2-asm-commons',
+  exports = ['@ow2_asm_commons//jar'],
+  runtime_deps = [':ow2-asm-tree'],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = 'ow2-asm-tree',
+  exports = ['@ow2_asm_tree//jar'],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = 'ow2-asm-util',
+  exports = ['@ow2_asm_util//jar'],
+  visibility = ["//visibility:public"],
+)
diff --git a/lib/powermock/BUCK b/lib/powermock/BUCK
index 5ac97c4..b642457 100644
--- a/lib/powermock/BUCK
+++ b/lib/powermock/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '1.6.2' # When bumping VERSION, make sure to also move
+VERSION = '1.6.4' # When bumping VERSION, make sure to also move
 # easymock to a compatible version
 
 maven_jar(
   name = 'powermock-module-junit4',
   id = 'org.powermock:powermock-module-junit4:' + VERSION,
-  sha1 = 'dff58978da716e000463bc1b08013d6a7cf3d696',
+  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-module-junit4-common',
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'powermock-module-junit4-common',
   id = 'org.powermock:powermock-module-junit4-common:' + VERSION,
-  sha1 = '48dd7406e11a14fe2ae4ab641e1f27510e896640',
+  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
@@ -28,7 +28,7 @@
 maven_jar(
   name = 'powermock-reflect',
   id = 'org.powermock:powermock-reflect:' + VERSION,
-  sha1 = '1af1bbd1207c3ecdcf64973e6f9d57dcd17cc145',
+  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     '//lib:junit',
@@ -39,7 +39,7 @@
 maven_jar(
   name = 'powermock-api-easymock',
   id = 'org.powermock:powermock-api-easymock:' + VERSION,
-  sha1 = 'addd25742ac9fe3e0491cbd68e2515e3b06c77fd',
+  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-api-support',
@@ -50,7 +50,7 @@
 maven_jar(
   name = 'powermock-api-support',
   id = 'org.powermock:powermock-api-support:' + VERSION,
-  sha1 = '93b21413b4ee99b7bc0dd34e1416fdca96866aaf',
+  sha1 = '314daafb761541293595630e10a3699ebc07881d',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-core',
@@ -62,11 +62,11 @@
 maven_jar(
   name = 'powermock-core',
   id = 'org.powermock:powermock-core:' + VERSION,
-  sha1 = 'ea04e79244e19dcf0c3ccf6863c5b028b4b58c9c',
+  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
-    '//lib:javassist-3.17.1-GA',
+    '//lib:javassist',
     '//lib:junit',
   ],
 )
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
new file mode 100644
index 0000000..8dc7d23
--- /dev/null
+++ b/lib/powermock/BUILD
@@ -0,0 +1,60 @@
+java_library(
+  name = 'powermock-module-junit4',
+  exports = [
+    '@powermock_module_junit4//jar',
+    ':powermock-module-junit4-common',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'powermock-module-junit4-common',
+  exports = [
+    '@powermock_module_junit4_common//jar',
+    ':powermock-reflect',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'powermock-reflect',
+  exports = [
+    '@powermock_reflect//jar',
+    '//lib:junit',
+    '//lib/easymock:objenesis',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'powermock-api-easymock',
+  exports = [
+    '@powermock_api_easymock//jar',
+    ':powermock-api-support',
+    '//lib/easymock:easymock',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'powermock-api-support',
+  exports = [
+    '@powermock_api_support//jar',
+    ':powermock-core',
+    ':powermock-reflect',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'powermock-core',
+  exports = [
+    ':powermock-reflect',
+    '//lib:javassist',
+    '//lib:junit',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD
new file mode 100644
index 0000000..74d8b80
--- /dev/null
+++ b/lib/prolog/BUILD
@@ -0,0 +1,47 @@
+java_library(
+  name = 'runtime',
+  exports = ['@prolog_runtime//jar'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'compiler',
+  exports = ['@prolog_compiler//jar'],
+  runtime_deps = [
+    ':io',
+    ':runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'io',
+  exports = ['@prolog_io//jar'],
+)
+
+java_library(
+  name = 'cafeteria',
+  exports = ['@cafeteria//jar'],
+  runtime_deps = [
+    'io',
+    'runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+java_binary(
+  name = 'compiler_bin',
+  main_class = 'BuckPrologCompiler',
+  runtime_deps = [':compiler_lib'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'compiler_lib',
+  srcs = ['java/BuckPrologCompiler.java'],
+  deps = [
+    ':compiler',
+    ':runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
new file mode 100644
index 0000000..d4e9e08
--- /dev/null
+++ b/lib/prolog/prolog.bzl
@@ -0,0 +1,36 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+def prolog_cafe_library(
+    name,
+    srcs,
+    deps = [],
+    visibility = []):
+  genrule2(
+    name = name + '__pl2j',
+    cmd = '$(location //lib/prolog:compiler_bin) ' +
+      '$$(dirname $@) $@ ' +
+      '$(SRCS)',
+    srcs = srcs,
+    tools = ['//lib/prolog:compiler_bin'],
+    out = name + '.srcjar',
+  )
+  native.java_library(
+    name = name,
+    srcs = [':' + name + '__pl2j'],
+    deps = ['//lib/prolog:runtime'] + deps,
+    visibility = visibility,
+  )
diff --git a/plugins/BUCK b/plugins/BUCK
index 9948720..c6bb7f1 100644
--- a/plugins/BUCK
+++ b/plugins/BUCK
@@ -2,6 +2,7 @@
 CORE = [
   'commit-message-length-validator',
   'download-commands',
+  'hooks',
   'replication',
   'reviewnotes',
   'singleusergroup'
diff --git a/plugins/README b/plugins/README
deleted file mode 100644
index 00df3c5..0000000
--- a/plugins/README
+++ /dev/null
@@ -1,11 +0,0 @@
-If you are adding a directory here:
-
-- Search all pom.xml files for "CORE PLUGIN LIST".
-- Add the new plugin to that location.
-- (optional) Thank the Maven developers for making this easy.
-
-- Ensure the plugin's pom.xml <version> is the same as Gerrit's
-  own pom.xml(s). Gerrit will only embed a plugin that has the
-  same version as itself.
-
-- Register the plugin as a submodule with git submodule.
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 8d295ed..9b163e1 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 8d295ed48e8f52eef5661b6eb10d6402d197c776
+Subproject commit 9b163e113de9f3a49219a02d388f7f46ea2559d3
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index f9b7f69..e291425 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit f9b7f6994c579d93cd2e5887174d5ab9b477b095
+Subproject commit e291425ca82cf8cb4bcd53b5f65e881fc04961c5
diff --git a/plugins/download-commands b/plugins/download-commands
index 8ad70a0..5615076 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 8ad70a0ec142b4b718b97fbbdf97bd8e8dc7fb7a
+Subproject commit 5615076bcf114723d1744f7d8944f0df72dbbf2b
diff --git a/plugins/hooks b/plugins/hooks
new file mode 160000
index 0000000..dc8d1c1
--- /dev/null
+++ b/plugins/hooks
@@ -0,0 +1 @@
+Subproject commit dc8d1c18b3d140dd1b2fc7ffe4f4a53d39a1cf28
diff --git a/plugins/replication b/plugins/replication
index 51d6be2..7b1df75 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 51d6be2b49b8136d594309743edf26a74948bd23
+Subproject commit 7b1df75e6efbdf4202628ec2e3f9117c559d170d
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 26f38c4..275eeaa 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 26f38c4514687c388472be19c9789eaa84b1d564
+Subproject commit 275eeaa3ab917696da1a20b3a9fba56eca8ecc6c
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index f6df712..3ca1167 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit f6df7121d2704e73c2a315a660e5cc4e12ab1ab9
+Subproject commit 3ca1167edda713f4bfdcecd9c0e2626797d7027f
diff --git a/polygerrit-ui/.gitattributes b/polygerrit-ui/.gitattributes
new file mode 100644
index 0000000..2125666
--- /dev/null
+++ b/polygerrit-ui/.gitattributes
@@ -0,0 +1 @@
+* text=auto
\ No newline at end of file
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
new file mode 100644
index 0000000..b3b74a6
--- /dev/null
+++ b/polygerrit-ui/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+dist
+fonts
+bower_components
+.tmp
diff --git a/polygerrit-ui/BUCK b/polygerrit-ui/BUCK
new file mode 100644
index 0000000..80f9f29
--- /dev/null
+++ b/polygerrit-ui/BUCK
@@ -0,0 +1,33 @@
+include_defs('//lib/js.defs')
+
+bower_components(
+  name = 'polygerrit_components',
+  deps = [
+    '//lib/js:es6-promise',
+    '//lib/js:fetch',
+    '//lib/js:highlightjs',
+    '//lib/js:iron-autogrow-textarea',
+    '//lib/js:iron-dropdown',
+    '//lib/js:iron-input',
+    '//lib/js:iron-overlay-behavior',
+    '//lib/js:iron-selector',
+    '//lib/js:moment',
+    '//lib/js:page',
+    '//lib/js:polymer',
+    '//lib/js:promise-polyfill',
+  ],
+)
+
+genrule(
+  name = 'fonts',
+  cmd = ' && '.join([
+    'cd $TMP',
+    'for file in $SRCS; do unzip -q $file; done',
+    'zip -q $OUT *',
+  ]),
+  srcs = [
+    '//lib/fonts:sourcecodepro',
+  ],
+  out = 'fonts.zip',
+  visibility = ['PUBLIC'],
+)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
new file mode 100644
index 0000000..383fb50
--- /dev/null
+++ b/polygerrit-ui/README.md
@@ -0,0 +1,107 @@
+# PolyGerrit
+
+## Installing [Node.js](https://nodejs.org/en/download/)
+
+```sh
+# Debian/Ubuntu
+sudo apt-get install nodejs-legacy
+
+# OS X with Homebrew
+brew install node
+```
+
+All other platforms: [download from
+nodejs.org](https://nodejs.org/en/download/).
+
+## Optional: installing [go](https://golang.org/)
+
+This is only required for running the ```run-server.sh``` script for testing. See below.
+
+```sh
+# Debian/Ubuntu
+sudo apt-get install golang
+
+# OS X with Homebrew
+brew install go
+```
+
+All other platforms: [download from golang.org](https://golang.org/)
+
+# Add [go] to your path
+
+```
+PATH=$PATH:/usr/local/go/bin
+```
+
+## Local UI, Production Data
+
+To test the local UI against gerrit-review.googlesource.com:
+
+```sh
+./polygerrit-ui/run-server.sh
+```
+
+Then visit http://localhost:8081
+
+## Local UI, Test Data
+
+One-time setup:
+
+1. [Install Buck](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
+   for building Gerrit.
+2. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
+   and set up a local test site. Docs
+   [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
+   [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
+
+When your project is set up and works using the classic UI, run a test server
+that serves PolyGerrit:
+
+```sh
+buck build polygerrit && \
+java -jar buck-out/gen/polygerrit/polygerrit.war daemon --polygerrit-dev \
+-d ../gerrit_testsite --console-log --show-stack-trace
+```
+
+## Running Tests
+
+One-time setup:
+
+```sh
+# Debian/Ubuntu
+sudo apt-get install npm
+
+# OS X with Homebrew
+brew install npm
+
+# All platforms (including those above)
+sudo npm install -g web-component-tester
+```
+
+Run all web tests:
+
+```sh
+buck test --no-results-cache --include web
+```
+
+The `--no-results-cache` flag prevents flaky test failures from being
+cached.
+
+If you need to pass additional arguments to `wct`:
+
+```sh
+WCT_ARGS='-p --some-flag="foo bar"' buck test --no-results-cache --include web
+```
+
+For interactively working on a single test file, do the following:
+
+```sh
+./polygerrit-ui/run-server.sh
+```
+
+Then visit http://localhost:8081/elements/foo/bar_test.html
+
+## Style guide
+
+We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
+with a few exceptions. When in doubt, remain consistent with the code around you.
diff --git a/polygerrit-ui/app/BUCK b/polygerrit-ui/app/BUCK
new file mode 100644
index 0000000..d03acf2
--- /dev/null
+++ b/polygerrit-ui/app/BUCK
@@ -0,0 +1,98 @@
+include_defs('//lib/js.defs')
+
+WCT_TEST_PATTERNS = [
+  'test/*.js',
+  'test/*.html',
+  '**/*_test.html',
+]
+PY_TEST_PATTERNS = ['polygerrit_wct_tests.py']
+APP_SRCS = glob(
+  ['**'],
+  excludes = [
+    'BUCK',
+    'index.html',
+    'test/**',
+  ] + WCT_TEST_PATTERNS + PY_TEST_PATTERNS)
+
+# List libraries to be copied statically into the build. (i.e. Libraries not
+# expected to be Vulcanized.)
+WEB_JS_LIBS = [
+  ('bower_components/webcomponentsjs', 'webcomponents-lite.js'),
+  ('bower_components/highlightjs', 'highlight.min.js'),
+]
+
+# Map the static libraries to commands for the polygerrit_ui rule.
+JS_LIBS_MKDIR_CMDS = []
+JS_LIBS_UNZIP_CMDS = []
+for lib in WEB_JS_LIBS:
+  JS_LIBS_MKDIR_CMDS.append('mkdir -p ' + lib[0])
+  path = lib[0] + '/' + lib[1]
+  cmd = 'unzip -p $(location //polygerrit-ui:polygerrit_components) %s>%s' % (path, path)
+  JS_LIBS_UNZIP_CMDS.append(cmd)
+
+# TODO(dborowitz): Putting these rules in this package avoids having to handle
+# the app/ prefix like we would have to if this were in the parent directory.
+# The only reason for the app subdirectory in the first place was convenience
+# when witing server.go; when that goes away, we can just move all the files and
+# these rules up one directory.
+genrule(
+  name = 'polygerrit_ui',
+  cmd = ' && '.join([
+    'mkdir $TMP/polygerrit_ui',
+    'cd $TMP/polygerrit_ui',
+    'mkdir -p {fonts,elements}',
+    ' && '.join(JS_LIBS_MKDIR_CMDS),
+    'unzip -qd fonts $(location //polygerrit-ui:fonts)',
+    'unzip -qd elements $(location :gr-app)',
+    'cp -rp $SRCDIR/* .',
+    ' && '.join(JS_LIBS_UNZIP_CMDS),
+    'cd $TMP',
+    'zip -9qr $OUT .',
+  ]),
+  srcs = glob([
+    'favicon.ico',
+    'index.html',
+    'styles/**/*.css'
+  ]),
+  out = 'polygerrit_ui.zip',
+  visibility = ['PUBLIC'],
+)
+
+vulcanize(
+  name = 'gr-app',
+  app = 'elements/gr-app.html',
+  srcs = APP_SRCS,
+  components = '//polygerrit-ui:polygerrit_components',
+)
+
+bower_components(
+  name = 'test_components',
+  deps = [
+    '//polygerrit-ui:polygerrit_components',
+    '//lib/js:iron-test-helpers',
+    '//lib/js:test-fixture',
+    '//lib/js:web-component-tester',
+  ],
+)
+
+genrule(
+  name = 'test_resources',
+  cmd = ' && '.join([
+    'cd $TMP',
+    'unzip -q $(location :test_components)',
+    'cp -r $SRCDIR/* .',
+    'zip -r $OUT .',
+  ]),
+  srcs = APP_SRCS + glob(WCT_TEST_PATTERNS),
+  out = 'test_resources.zip',
+)
+
+python_test(
+  name = 'polygerrit_tests',
+  srcs = glob(PY_TEST_PATTERNS),
+  resources = [':test_resources'],
+  labels = [
+    'manual',
+    'web',
+  ],
+)
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
new file mode 100644
index 0000000..6b35328
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
@@ -0,0 +1,18 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
+<script src="gr-tooltip-behavior.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
new file mode 100644
index 0000000..3702c84
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  var BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+
+  /** @polymerBehavior Gerrit.TooltipBehavior */
+  var TooltipBehavior = {
+
+    properties: {
+      hasTooltip: Boolean,
+
+      _tooltip: Element,
+      _titleText: String,
+    },
+
+    attached: function() {
+      if (!this.hasTooltip) { return; }
+
+      this.addEventListener('mouseover', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseout', this._handleHideTooltip.bind(this));
+      this.addEventListener('focusin', this._handleShowTooltip.bind(this));
+      this.addEventListener('focusout', this._handleHideTooltip.bind(this));
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
+    _handleShowTooltip: function(e) {
+      if (!this.hasAttribute('title') ||
+          this.getAttribute('title') === '' ||
+          this._tooltip) {
+        return;
+      }
+
+      // Store the title attribute text then set it to an empty string to
+      // prevent it from showing natively.
+      this._titleText = this.getAttribute('title');
+      this.setAttribute('title', '');
+
+      var tooltip = document.createElement('gr-tooltip');
+      tooltip.text = this._titleText;
+
+      // Set visibility to hidden before appending to the DOM so that
+      // calculations can be made based on the element’s size.
+      tooltip.style.visibility = 'hidden';
+      Polymer.dom(document.body).appendChild(tooltip);
+      this._positionTooltip(tooltip);
+      tooltip.style.visibility = null;
+
+      this._tooltip = tooltip;
+    },
+
+    _handleHideTooltip: function(e) {
+      if (!this.hasAttribute('title') ||
+          this._titleText == null ||
+          this === document.activeElement) { return; }
+
+      this.setAttribute('title', this._titleText);
+      if (this._tooltip && this._tooltip.parentNode) {
+        this._tooltip.parentNode.removeChild(this._tooltip);
+      }
+      this._tooltip = null;
+    },
+
+    _handleWindowScroll: function(e) {
+      if (!this._tooltip) { return; }
+
+      this._positionTooltip(this._tooltip);
+    },
+
+    _positionTooltip: function(tooltip) {
+      var rect = this.getBoundingClientRect();
+      var boxRect = tooltip.getBoundingClientRect();
+      var parentRect = tooltip.parentElement.getBoundingClientRect();
+      var top = rect.top - parentRect.top - boxRect.height - BOTTOM_OFFSET;
+      var left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+      if (left < 0) {
+        tooltip.updateStyles({
+          '--gr-tooltip-arrow-center-offset': left + 'px',
+        });
+      }
+      tooltip.style.left = Math.max(0, left) + 'px';
+      tooltip.style.top = Math.max(0, top) + 'px';
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.TooltipBehavior = TooltipBehavior;
+})(window);
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
new file mode 100644
index 0000000..17acac8
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
@@ -0,0 +1,68 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
+  var KeyboardShortcutBehavior = {
+    enabled: true,
+
+    properties: {
+      keyEventTarget: {
+        type: Object,
+        value: function() { return this; },
+      },
+
+      _boundKeyHandler: {
+        type: Function,
+        readonly: true,
+        value: function() { return this._handleKey.bind(this); },
+      },
+    },
+
+    attached: function() {
+      this.keyEventTarget.addEventListener('keydown', this._boundKeyHandler);
+    },
+
+    detached: function() {
+      this.keyEventTarget.removeEventListener('keydown', this._boundKeyHandler);
+    },
+
+    shouldSupressKeyboardShortcut: function(e) {
+      if (!KeyboardShortcutBehavior.enabled) { return true; }
+      var getModifierState = e.getModifierState ?
+          e.getModifierState.bind(e) :
+          function() { return false; };
+      var target = e.detail ? e.detail.keyboardEvent : e.target;
+      return getModifierState('Control') ||
+             getModifierState('Alt') ||
+             getModifierState('Meta') ||
+             getModifierState('Fn') ||
+             target.tagName == 'INPUT' ||
+             target.tagName == 'TEXTAREA' ||
+             target.tagName == 'SELECT' ||
+             target.tagName == 'BUTTON' ||
+             target.tagName == 'A' ||
+             target.tagName == 'GR-BUTTON';
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.KeyboardShortcutBehavior = KeyboardShortcutBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior.html
new file mode 100644
index 0000000..4def9b2
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior.html
@@ -0,0 +1,133 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.RESTClientBehavior */
+  var RESTClientBehavior = {
+    ChangeDiffType: {
+      ADDED: 'ADDED',
+      COPIED: 'COPIED',
+      DELETED: 'DELETED',
+      MODIFIED: 'MODIFIED',
+      RENAMED: 'RENAMED',
+      REWRITE: 'REWRITE',
+    },
+
+    ChangeStatus: {
+      ABANDONED: 'ABANDONED',
+      DRAFT: 'DRAFT',
+      MERGED: 'MERGED',
+      NEW: 'NEW',
+    },
+
+    // Must be kept in sync with the ListChangesOption enum and protobuf.
+    ListChangesOption: {
+      LABELS: 0,
+      DETAILED_LABELS: 8,
+
+      // Return information on the current patch set of the change.
+      CURRENT_REVISION: 1,
+      ALL_REVISIONS: 2,
+
+      // If revisions are included, parse the commit object.
+      CURRENT_COMMIT: 3,
+      ALL_COMMITS: 4,
+
+      // If a patch set is included, include the files of the patch set.
+      CURRENT_FILES: 5,
+      ALL_FILES: 6,
+
+      // If accounts are included, include detailed account info.
+      DETAILED_ACCOUNTS: 7,
+
+      // Include messages associated with the change.
+      MESSAGES: 9,
+
+      // Include allowed actions client could perform.
+      CURRENT_ACTIONS: 10,
+
+      // Set the reviewed boolean for the caller.
+      REVIEWED: 11,
+
+      // Include download commands for the caller.
+      DOWNLOAD_COMMANDS: 13,
+
+      // Include patch set weblinks.
+      WEB_LINKS: 14,
+
+      // Include consistency check results.
+      CHECK: 15,
+
+      // Include allowed change actions client could perform.
+      CHANGE_ACTIONS: 16,
+
+      // Include a copy of commit messages including review footers.
+      COMMIT_FOOTERS: 17,
+
+      // Include push certificate information along with any patch sets.
+      PUSH_CERTIFICATES: 18
+    },
+
+    listChangesOptionsToHex: function() {
+      var v = 0;
+      for (var i = 0; i < arguments.length; i++) {
+        v |= 1 << arguments[i];
+      }
+      return v.toString(16);
+    },
+
+    changeBaseURL: function(changeNum, patchNum) {
+      var v = '/changes/' + changeNum;
+      if (patchNum) {
+        v += '/revisions/' + patchNum;
+      }
+      return v;
+    },
+
+    changePath: function(changeNum) {
+      return '/c/' + changeNum;
+    },
+
+    changeIsOpen: function(status) {
+      return status === this.ChangeStatus.NEW ||
+          status === this.ChangeStatus.DRAFT;
+    },
+
+    changeStatusString: function(change) {
+      // "Closed" states should take precedence over "open" ones.
+      if (change.status === this.ChangeStatus.MERGED) {
+        return 'Merged';
+      }
+      if (change.status === this.ChangeStatus.ABANDONED) {
+        return 'Abandoned';
+      }
+      if (change.mergeable === false) {
+        return 'Merge Conflict';
+      }
+      if (change.status === this.ChangeStatus.DRAFT) {
+        return 'Draft';
+      }
+      return '';
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.RESTClientBehavior = RESTClientBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
new file mode 100644
index 0000000..9126785
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -0,0 +1,95 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-change-list-styles.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+
+<dom-module id="gr-change-list-item">
+  <template>
+    <style>
+      :host {
+        display: flex;
+        border-bottom: 1px solid #eee;
+      }
+      :host([selected]) {
+        background-color: #ebf5fb;
+      }
+      :host([needs-review]) {
+        font-weight: bold;
+      }
+      .cell {
+        flex-shrink: 0;
+        padding: .3em .5em;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      .positionIndicator {
+        visibility: hidden;
+      }
+      :host([selected]) .positionIndicator {
+        visibility: visible;
+      }
+      .u-monospace {
+        font-family: var(--monospace-font-family);
+      }
+      .u-green {
+        color: #388E3C;
+      }
+      .u-red {
+        color: #D32F2F;
+      }
+      .u-gray-background {
+        background-color: #F5F5F5;
+      }
+    </style>
+    <style include="gr-change-list-styles"></style>
+    <span class="cell keyboard">
+      <span class="positionIndicator">&#x25b6;</span>
+    </span>
+    <span class="cell star" hidden$="[[!showStar]]" hidden>
+      <gr-change-star change="{{change}}"></gr-change-star>
+    </span>
+    <a class="cell number" href$="[[changeURL]]" hidden$="[[!showNumber]]" hidden>
+      [[change._number]]
+    </a>
+    <a class="cell subject" href$="[[changeURL]]">[[change.subject]]</a>
+    <span class="cell status">[[changeStatusString(change)]]</span>
+    <span class="cell owner">
+      <gr-account-link account="[[change.owner]]"></gr-account-link>
+    </span>
+    <a class="cell project" href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+    <a class="cell branch" href$="[[_computeProjectBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
+    <gr-date-formatter class="cell updated" date-str="[[change.updated]]"></gr-date-formatter>
+    <span class="cell size u-monospace">
+      <span class="u-green"><span>+</span>[[change.insertions]]</span>,
+      <span class="u-red"><span>-</span>[[change.deletions]]</span>
+    </span>
+    <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+      <span title$="[[_computeLabelTitle(change, labelName)]]"
+          class$="[[_computeLabelClass(change, labelName)]]">[[_computeLabelValue(change, labelName)]]</span>
+    </template>
+  </template>
+  <script src="gr-change-list-item.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
new file mode 100644
index 0000000..90b2e1d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -0,0 +1,118 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-list-item',
+
+    properties: {
+      selected: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      needsReview: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      labelNames: {
+        type: Array,
+      },
+      change: Object,
+      changeURL: {
+        type: String,
+        computed: '_computeChangeURL(change._number)',
+      },
+      showStar: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    _computeChangeURL: function(changeNum) {
+      if (!changeNum) { return ''; }
+      return '/c/' + changeNum + '/';
+    },
+
+    _computeLabelTitle: function(change, labelName) {
+      var label = change.labels[labelName];
+      if (!label) { return 'Label not applicable'; }
+      var significantLabel = label.rejected || label.approved ||
+          label.disliked || label.recommended;
+      if (significantLabel && significantLabel.name) {
+        return labelName + '\nby ' + significantLabel.name;
+      }
+      return labelName;
+    },
+
+    _computeLabelClass: function(change, labelName) {
+      var label = change.labels[labelName];
+      // Mimic a Set.
+      var classes = {
+        'cell': true,
+        'label': true,
+      };
+      if (label) {
+        if (label.approved) {
+          classes['u-green'] = true;
+        }
+        if (label.value == 1) {
+          classes['u-monospace'] = true;
+          classes['u-green'] = true;
+        } else if (label.value == -1) {
+          classes['u-monospace'] = true;
+          classes['u-red'] = true;
+        }
+        if (label.rejected) {
+          classes['u-red'] = true;
+        }
+      } else {
+        classes['u-gray-background'] = true;
+      }
+      return Object.keys(classes).sort().join(' ');
+    },
+
+    _computeLabelValue: function(change, labelName) {
+      var label = change.labels[labelName];
+      if (!label) { return ''; }
+      if (label.approved) {
+        return '✓';
+      }
+      if (label.rejected) {
+        return '✕';
+      }
+      if (label.value > 0) {
+        return '+' + label.value;
+      }
+      if (label.value < 0) {
+        return label.value;
+      }
+      return '';
+    },
+
+    _computeProjectURL: function(project) {
+      return '/q/status:open+project:' + project;
+    },
+
+    _computeProjectBranchURL: function(project, branch) {
+      return '/q/status:open+project:' + project + '+branch:' + branch;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
new file mode 100644
index 0000000..b7c0853
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-list-item</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-change-list-item.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list-item></gr-change-list-item>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-list-item tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('change status', function() {
+      var getStatusForChange = function(change) {
+        element.change = change;
+        return element.$$('.cell.status').textContent;
+      };
+
+      assert.equal(getStatusForChange({mergeable: true}), '');
+      assert.equal(getStatusForChange({mergeable: false}), 'Merge Conflict');
+      assert.equal(getStatusForChange({status: 'NEW'}), '');
+      assert.equal(getStatusForChange({status: 'MERGED'}), 'Merged');
+      assert.equal(getStatusForChange({status: 'ABANDONED'}), 'Abandoned');
+      assert.equal(getStatusForChange({status: 'DRAFT'}), 'Draft');
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeLabelClass({labels: {}}),
+          'cell label u-gray-background');
+      assert.equal(element._computeLabelClass(
+          {labels: {}}, 'Verified'), 'cell label u-gray-background');
+      assert.equal(element._computeLabelClass(
+          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+          'cell label u-green u-monospace');
+      assert.equal(element._computeLabelClass(
+          {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+          'cell label u-monospace u-red');
+      assert.equal(element._computeLabelClass(
+          {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+          'cell label u-green u-monospace');
+      assert.equal(element._computeLabelClass(
+          {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+          'cell label u-monospace u-red');
+      assert.equal(element._computeLabelClass(
+          {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+          'cell label u-gray-background');
+
+      assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
+          'Label not applicable');
+      assert.equal(element._computeLabelTitle(
+          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
+          'Verified\nby Diffy');
+      assert.equal(element._computeLabelTitle(
+          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
+          'Label not applicable');
+      assert.equal(element._computeLabelTitle(
+          {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
+          'Verified\nby Diffy');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
+          'Code-Review'), 'Code-Review\nby Diffy');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
+          'Code-Review'), 'Code-Review\nby Diffy');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+          'Code-Review\nby Admin');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+          'Code-Review\nby Admin');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+          'Code-Review\nby Admin');
+      assert.equal(element._computeLabelTitle(
+          {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+          'Code-Review\nby Diffy');
+
+      assert.equal(element._computeLabelValue({labels: {}}), '');
+      assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
+      assert.equal(element._computeLabelValue(
+          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
+      assert.equal(element._computeLabelValue(
+          {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
+      assert.equal(element._computeLabelValue(
+          {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
+      assert.equal(element._computeLabelValue(
+          {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
+      assert.equal(element._computeLabelValue(
+          {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
+
+      assert.equal(element._computeProjectURL('combustible-stuff'),
+          '/q/status:open+project:combustible-stuff');
+
+      assert.equal(element._computeProjectBranchURL(
+          'combustible-stuff', 'lemons'),
+          '/q/status:open+project:combustible-stuff+branch:lemons');
+
+      element.change = {_number: 42};
+      assert.equal(element.changeURL, '/c/42/');
+      element.change = {_number: 43};
+      assert.equal(element.changeURL, '/c/43/');
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
new file mode 100644
index 0000000..1f06dff
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -0,0 +1,69 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-change-list/gr-change-list.html">
+
+<dom-module id="gr-change-list-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      .loading {
+        color: #666;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      gr-change-list {
+        width: 100%;
+      }
+      nav {
+        padding: .5em 0;
+        text-align: center;
+      }
+      nav a {
+        display: inline-block;
+      }
+      nav a:first-of-type {
+        margin-right: .5em;
+      }
+      @media only screen and (max-width: 50em) {
+        .loading,
+        .error {
+          padding: 0 var(--default-horizontal-margin);
+        }
+      }
+    </style>
+    <div class="loading" hidden$="[[!_loading]]" hidden>Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <gr-change-list
+          changes="{{_changes}}"
+          selected-index="{{viewState.selectedChangeIndex}}"
+          show-star="[[loggedIn]]"></gr-change-list>
+      <nav>
+        <a href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
+           hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
+        <a href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+           hidden$="[[_hideNextArrow(_loading, _changesPerPage)]]" hidden>
+          Next &rarr;</a>
+      </nav>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-list-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
new file mode 100644
index 0000000..7fbe455
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -0,0 +1,134 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-list-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * True when user is logged in.
+       */
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * State persisted across restamps of the element.
+       */
+      viewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+
+      _changesPerPage: Number,
+
+      /**
+       * Currently active query.
+       */
+      _query: String,
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+
+      /**
+       * Change objects loaded from the server.
+       */
+      _changes: Array,
+
+      /**
+       * For showing a "loading..." string during ajax requests.
+       */
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    },
+
+    attached: function() {
+      this.fire('title-change', {title: this._query});
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view != this.tagName.toLowerCase()) { return; }
+
+      this._loading = true;
+      this._query = value.query;
+      this._offset = value.offset || 0;
+      if (this.viewState.query != this._query ||
+          this.viewState.offset != this._offset) {
+        this.set('viewState.selectedChangeIndex', 0);
+        this.set('viewState.query', this._query);
+        this.set('viewState.offset', this._offset);
+      }
+
+      this.fire('title-change', {title: this._query});
+
+      this._getPreferences().then(function(prefs) {
+        this._changesPerPage = prefs.changes_per_page;
+        return this._getChanges();
+      }.bind(this)).then(function(changes) {
+        this._changes = changes;
+        this._loading = false;
+      }.bind(this));
+    },
+
+    _getChanges: function() {
+      return this.$.restAPI.getChanges(this._changesPerPage, this._query,
+          this._offset);
+    },
+
+    _getPreferences: function() {
+      return this.$.restAPI.getPreferences();
+    },
+
+    _computeNavLink: function(query, offset, direction, changesPerPage) {
+      // Offset could be a string when passed from the router.
+      offset = +(offset || 0);
+      var newOffset = Math.max(0, offset + (changesPerPage * direction));
+      var href = '/q/' + query;
+      if (newOffset > 0) {
+        href += ',' + newOffset;
+      }
+      return href;
+    },
+
+    _hidePrevArrow: function(offset) {
+      return offset === 0;
+    },
+
+    _hideNextArrow: function(loading, changesPerPage) {
+      return loading || !this._changes || this._changes.length < changesPerPage;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
new file mode 100644
index 0000000..bab2014
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -0,0 +1,70 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../../styles/gr-change-list-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
+
+<dom-module id="gr-change-list">
+  <template>
+    <style>
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+    </style>
+    <style include="gr-change-list-styles"></style>
+    <div class="headerRow">
+      <span class="topHeader keyboard"></span> <!-- keyboard position indicator -->
+      <span class="topHeader star" hidden$="[[!showStar]]" hidden></span>
+      <span class="topHeader number" hidden$="[[!showNumber]]" hidden>#</span>
+      <span class="topHeader subject">Subject</span>
+      <span class="topHeader status">Status</span>
+      <span class="topHeader owner">Owner</span>
+      <span class="topHeader project">Project</span>
+      <span class="topHeader branch">Branch</span>
+      <span class="topHeader updated">Updated</span>
+      <span class="topHeader size">Size</span>
+      <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+        <span class="topHeader label" title$="[[labelName]]">
+          [[_computeLabelShortcut(labelName)]]
+        </span>
+      </template>
+    </div>
+    <template is="dom-repeat" items="{{groups}}" as="changeGroup" index-as="groupIndex">
+      <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
+        <div class="groupHeader">[[_groupTitle(groupIndex)]]</div>
+      </template>
+      <template is="dom-if" if="[[!changeGroup.length]]">
+        <div class="noChanges">No changes</div>
+      </template>
+      <template is="dom-repeat" items="[[changeGroup]]" as="change">
+        <gr-change-list-item
+            selected$="[[_computeItemSelected(index, groupIndex, selectedIndex)]]"
+            needs-review="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
+            change="[[change]]"
+            show-number="[[showNumber]]"
+            show-star="[[showStar]]"
+            label-names="[[labelNames]]"></gr-change-list-item>
+      </template>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
new file mode 100644
index 0000000..4e17253
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -0,0 +1,191 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-list',
+
+    hostAttributes: {
+      tabindex: 0,
+    },
+
+    properties: {
+      /**
+       * The logged-in user's account, or an empty object if no user is logged
+       * in.
+       */
+      account: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      /**
+       * An array of ChangeInfo objects to render.
+       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+       */
+      changes: {
+        type: Array,
+        observer: '_changesChanged',
+      },
+      /**
+       * ChangeInfo objects grouped into arrays. The groups and changes
+       * properties should not be used together.
+       */
+      groups: {
+        type: Array,
+        value: function() { return []; },
+      },
+      groupTitles: {
+        type: Array,
+        value: function() { return []; },
+      },
+      labelNames: {
+        type: Array,
+        computed: '_computeLabelNames(groups)',
+      },
+      selectedIndex: {
+        type: Number,
+        notify: true,
+      },
+      showNumber: Boolean, // No default value to prevent flickering.
+      showStar: {
+        type: Boolean,
+        value: false,
+      },
+      showReviewedState: {
+        type: Boolean,
+        value: false,
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    attached: function() {
+      this._loadPreferences();
+    },
+
+    _loadPreferences: function() {
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) {
+          this.showNumber = false;
+          return;
+        }
+        return this._getPreferences().then(function(preferences) {
+          this.showNumber = !!(preferences &&
+              preferences.legacycid_in_change_table);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getPreferences: function() {
+      return this.$.restAPI.getPreferences();
+    },
+
+    _computeLabelNames: function(groups) {
+      if (!groups) { return []; }
+      var labels = [];
+      var nonExistingLabel = function(item) {
+        return labels.indexOf(item) < 0;
+      };
+      for (var i = 0; i < groups.length; i++) {
+        var group = groups[i];
+        for (var j = 0; j < group.length; j++) {
+          var change = group[j];
+          if (!change.labels) { continue; }
+          var currentLabels = Object.keys(change.labels);
+          labels = labels.concat(currentLabels.filter(nonExistingLabel));
+        }
+      }
+      return labels.sort();
+    },
+
+    _computeLabelShortcut: function(labelName) {
+      return labelName.replace(/[a-z-]/g, '');
+    },
+
+    _changesChanged: function(changes) {
+      this.groups = changes ? [changes] : [];
+    },
+
+    _groupTitle: function(groupIndex) {
+      if (groupIndex > this.groupTitles.length - 1) { return null; }
+      return this.groupTitles[groupIndex];
+    },
+
+    _computeItemSelected: function(index, groupIndex, selectedIndex) {
+      var idx = 0;
+      for (var i = 0; i < groupIndex; i++) {
+        idx += this.groups[i].length;
+      }
+      idx += index;
+      return idx == selectedIndex;
+    },
+
+    _computeItemNeedsReview: function(account, change, showReviewedState) {
+      return showReviewedState && !change.reviewed &&
+          this.changeIsOpen(change.status) &&
+          account._account_id != change.owner._account_id;
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      if (this.groups == null) { return; }
+      var len = 0;
+      this.groups.forEach(function(group) {
+        len += group.length;
+      });
+      switch (e.keyCode) {
+        case 74:  // 'j'
+          e.preventDefault();
+          if (this.selectedIndex == len - 1) { return; }
+          this.selectedIndex += 1;
+          break;
+        case 75:  // 'k'
+          e.preventDefault();
+          if (this.selectedIndex == 0) { return; }
+          this.selectedIndex -= 1;
+          break;
+        case 79:  // 'o'
+        case 13:  // 'enter'
+          e.preventDefault();
+          page.show(this._changeURLForIndex(this.selectedIndex));
+          break;
+      }
+    },
+
+    _changeURLForIndex: function(index) {
+      var changeEls = this._getListItems();
+      if (index < changeEls.length && changeEls[index]) {
+        return changeEls[index].changeURL;
+      }
+      return '';
+    },
+
+    _getListItems: function() {
+      return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
new file mode 100644
index 0000000..aa77b77
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -0,0 +1,300 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list></gr-change-list>
+  </template>
+</test-fixture>
+
+<test-fixture id="grouped">
+  <template>
+    <gr-change-list></gr-change-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-list basic tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    function stubRestAPI(preferences) {
+      var loggedInPromise = Promise.resolve(preferences !== null);
+      var preferencesPromise = Promise.resolve(preferences);
+      stub('gr-rest-api-interface', {
+        getLoggedIn: sinon.stub().returns(loggedInPromise),
+        getPreferences: sinon.stub().returns(preferencesPromise),
+      });
+      return Promise.all([loggedInPromise, preferencesPromise]);
+    }
+
+    suite('test show change number not logged in', function() {
+      setup(function(done) {
+        return stubRestAPI(null).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('show number disabled', function() {
+        assert.isFalse(element.showNumber);
+      });
+    });
+
+    suite('test show change number preference enabled', function() {
+      setup(function(done) {
+        return stubRestAPI(
+          {legacycid_in_change_table: true, time_format: 'HHMM_12'}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('show number enabled', function() {
+        assert.isTrue(element.showNumber);
+      });
+    });
+
+    suite('test show change number preference disabled', function() {
+      setup(function(done) {
+        // legacycid_in_change_table is not set when false.
+        return stubRestAPI(
+          {time_format: 'HHMM_12'}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('show number disabled', function() {
+        assert.isFalse(element.showNumber);
+      });
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeLabelNames(
+          [[{_number: 0, labels: {}}]]).length, 0);
+      assert.equal(element._computeLabelNames([[
+            {_number: 0, labels: {Verified: {approved: {}}}},
+            {_number: 1, labels: {
+              Verified: {approved: {}}, 'Code-Review': {approved: {}}}},
+            {_number: 2, labels: {
+              Verified: {approved: {}}, 'Library-Compliance': {approved: {}}}},
+          ]]).length, 3);
+
+      assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
+      assert.equal(element._computeLabelShortcut('Verified'), 'V');
+      assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
+      assert.equal(element._computeLabelShortcut(
+          'Some-Special-Label-7'), 'SSL7');
+    });
+
+    test('keyboard shortcuts', function(done) {
+      element.selectedIndex = 0;
+      element.changes = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 3);
+
+      flush(function() {
+        assert.isTrue(elementItems[0].selected);
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+
+        var showStub = sinon.stub(page, 'show');
+        assert.equal(element.selectedIndex, 2);
+        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        assert(showStub.lastCall.calledWithExactly('/c/2/'),
+            'Should navigate to /c/2/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        assert(showStub.lastCall.calledWithExactly('/c/1/'),
+            'Should navigate to /c/1/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        assert.equal(element.selectedIndex, 0);
+
+        showStub.restore();
+        done();
+      });
+    });
+
+    test('changes needing review', function() {
+      element.changes = [
+        {
+          _number: 0,
+          status: 'NEW',
+          reviewed: true,
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 1,
+          status: 'NEW',
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 2,
+          status: 'MERGED',
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 3,
+          status: 'DRAFT',
+          owner: {_account_id: 42},
+        },
+        {
+          _number: 4,
+          status: 'ABANDONED',
+          owner: {_account_id: 0},
+        }
+      ];
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 5);
+      for (var i = 0; i < elementItems.length; i++) {
+        assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+      }
+
+      element.showReviewedState = true;
+      elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 5);
+      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+      assert.isTrue(elementItems[3].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+      element.account = {_account_id: 42};
+      elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 5);
+      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+    });
+
+    test('no changes', function() {
+      element.changes = [];
+      flushAsynchronousOperations();
+      var listItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(listItems.length, 0);
+      var noChangesMsg = Polymer.dom(element.root).querySelector('.noChanges');
+      assert.ok(noChangesMsg);
+    });
+
+    test('empty groups', function() {
+      element.groups = [[], []];
+      flushAsynchronousOperations();
+      var listItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(listItems.length, 0);
+      var noChangesMsg = Polymer.dom(element.root).querySelectorAll(
+          '.noChanges');
+      assert.equal(noChangesMsg.length, 2);
+    });
+  });
+
+  suite('gr-change-list groups', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('keyboard shortcuts', function() {
+      element.selectedIndex = 0;
+      element.groups = [
+        [
+          {_number: 0},
+          {_number: 1},
+          {_number: 2},
+        ],
+        [
+          {_number: 3},
+          {_number: 4},
+          {_number: 5},
+        ],
+        [
+          {_number: 6},
+          {_number: 7},
+          {_number: 8},
+        ]
+      ];
+      element.groupTitles = ['Group 1', 'Group 2', 'Group 3'];
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 9);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+
+      var showStub = sinon.stub(page, 'show');
+      assert.equal(element.selectedIndex, 2);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/2/'),
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/1/'),
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      assert.equal(element.selectedIndex, 4);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/4/'),
+          'Should navigate to /c/4/');
+      showStub.restore();
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
new file mode 100644
index 0000000..ce413ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -0,0 +1,53 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-dashboard-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      .loading {
+        color: #666;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      gr-change-list {
+        width: 100%;
+      }
+      @media only screen and (max-width: 50em) {
+        .loading {
+          padding: 0 var(--default-horizontal-margin);
+        }
+      }
+    </style>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <gr-change-list
+          show-star
+          show-reviewed-state
+          account="[[account]]"
+          selected-index="{{viewState.selectedChangeIndex}}"
+          groups="{{_results}}"
+          group-titles="[[_groupTitles]]"></gr-change-list>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-dashboard-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
new file mode 100644
index 0000000..3ac6463
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-dashboard-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      account: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      viewState: Object,
+
+      _results: Array,
+      _groupTitles: {
+        type: Array,
+        value: [
+          'Outgoing reviews',
+          'Incoming reviews',
+          'Recently closed',
+        ],
+      },
+
+      /**
+       * For showing a "loading..." string during ajax requests.
+       */
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    },
+
+    attached: function() {
+      this.fire('title-change', {title: 'My Reviews'});
+
+      this._loading = true;
+      this._getDashboardChanges().then(function(results) {
+        this._results = results;
+        this._loading = false;
+      }.bind(this)).catch(function(err) {
+        this._loading = false;
+        console.error(err.message);
+      }.bind(this));
+    },
+
+    _getDashboardChanges: function() {
+      return this.$.restAPI.getDashboardChanges();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
new file mode 100644
index 0000000..bb4a520
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -0,0 +1,42 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-account-entry">
+  <template>
+    <style>
+      gr-autocomplete {
+        display: inline-block;
+        flex: 1;
+        overflow: hidden;
+      }
+    </style>
+    <gr-autocomplete
+        id="input"
+        borderless="[[borderless]]"
+        placeholder="[[placeholder]]"
+        threshold="[[suggestFrom]]"
+        query="[[query]]"
+        on-commit="_handleInputCommit"
+        clear-on-commit>
+    </gr-autocomplete>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-entry.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
new file mode 100644
index 0000000..c5827d0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -0,0 +1,88 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-entry',
+
+    /**
+     * Fired when an account is entered.
+     *
+     * @event add
+     */
+
+    properties: {
+      borderless: Boolean,
+      change: Object,
+      filter: Function,
+      placeholder: String,
+
+      suggestFrom: {
+        type: Number,
+        value: 3,
+      },
+
+      query: {
+        type: Function,
+        value: function() {
+          return this._getReviewerSuggestions.bind(this);
+        },
+      },
+    },
+
+    get focusStart() {
+      return this.$.input.focusStart;
+    },
+
+    focus: function() {
+      this.$.input.focus();
+    },
+
+    clear: function() {
+      this.$.input.clear();
+    },
+
+    _handleInputCommit: function(e) {
+      this.fire('add', {value: e.detail.value});
+    },
+
+    _makeSuggestion: function(reviewer) {
+      if (reviewer.account) {
+        return {
+          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
+          value: reviewer,
+        };
+      } else if (reviewer.group) {
+        return {
+          name: reviewer.group.name + ' (group)',
+          value: reviewer,
+        };
+      }
+    },
+
+    _getReviewerSuggestions: function(input) {
+      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
+          this.change._number, input);
+
+      return xhr.then(function(reviewers) {
+        if (!reviewers) { return []; }
+        if (!this.filter) { return reviewers.map(this._makeSuggestion); }
+        return reviewers
+            .filter(this.filter)
+            .map(this._makeSuggestion);
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
new file mode 100644
index 0000000..94db890
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-entry</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-entry.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-entry></gr-account-entry>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-entry tests', function() {
+    var _nextAccountId = 0;
+    var makeAccount = function() {
+      var accountId = ++_nextAccountId;
+      return {
+        _account_id: accountId,
+        name: 'name ' + accountId,
+        email: 'email ' + accountId,
+      };
+    };
+
+    var owner;
+    var existingReviewer1;
+    var existingReviewer2;
+    var suggestion1;
+    var suggestion2;
+    var suggestion3;
+    var element;
+
+    setup(function() {
+      owner = makeAccount();
+      existingReviewer1 = makeAccount();
+      existingReviewer2 = makeAccount();
+      suggestion1 = {account: makeAccount()};
+      suggestion2 = {account: makeAccount()};
+      suggestion3 = {
+        group: {
+          id: 'suggested group id',
+          name: 'suggested group',
+        },
+      };
+
+      element = fixture('basic');
+      element.change = {
+        owner: owner,
+        reviewers: {
+          CC: [existingReviewer1],
+          REVIEWER: [existingReviewer2],
+        },
+      };
+
+      stub('gr-rest-api-interface', {
+        getChangeSuggestedReviewers: function() {
+          var redundantSuggestion1 = {account: existingReviewer1};
+          var redundantSuggestion2 = {account: existingReviewer2};
+          var redundantSuggestion3 = {account: owner};
+          return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+        },
+      });
+    });
+
+    test('_makeSuggestion formats account or group accordingly', function() {
+      var account = makeAccount();
+      var suggestion = element._makeSuggestion({account: account});
+      assert.deepEqual(suggestion, {
+        name: account.name + ' (' + account.email + ')',
+        value: {account: account},
+      });
+
+      var group = {name: 'test'};
+      suggestion = element._makeSuggestion({group: group});
+      assert.deepEqual(suggestion, {
+        name: group.name + ' (group)',
+        value: {group: group},
+      });
+    });
+
+    test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
+      element._getReviewerSuggestions().then(function(reviewers) {
+        // Default is no filtering.
+        assert.equal(reviewers.length, 6);
+
+        // Set up filter that only accepts suggestion1.
+        var accountId = suggestion1.account._account_id;
+        element.filter = function(suggestion) {
+          return suggestion.account &&
+              suggestion.account._account_id === accountId;
+        };
+
+        element._getReviewerSuggestions().then(function(reviewers) {
+          assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
+        }).then(done);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
new file mode 100644
index 0000000..98f2b18
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -0,0 +1,59 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../gr-account-entry/gr-account-entry.html">
+
+<dom-module id="gr-account-list">
+  <template>
+    <style>
+      gr-account-chip {
+        display: inline-block;
+        margin: 0 .2em .2em 0;
+      }
+      gr-account-entry {
+        display: flex;
+        flex: 1;
+        min-width: 10em;
+      }
+      .group {
+        --account-label-suffix: ' (group)';
+      }
+      .pending-add {
+        font-style: italic;
+      }
+    </style>
+    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
+      <gr-account-chip
+          account="[[account]]"
+          class$="[[_computeChipClass(account)]]"
+          data-account-id$="[[account._account_id]]"
+          removable="[[_computeRemovable(account)]]">
+      </gr-account-chip>
+    </template>
+    <gr-account-entry
+        borderless
+        hidden$="[[readonly]]"
+        id="entry"
+        change="[[change]]"
+        filter="[[filter]]"
+        placeholder="[[placeholder]]"
+        on-add="_handleAdd">
+    </gr-account-entry>
+  </template>
+  <script src="gr-account-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
new file mode 100644
index 0000000..87d7116
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-list',
+
+    properties: {
+      accounts: {
+        type: Array,
+        value: function() { return []; },
+      },
+      change: Object,
+      filter: Function,
+      placeholder: String,
+      pendingConfirmation: {
+        type: Object,
+        value: null,
+        notify: true,
+      },
+      readonly: Boolean,
+    },
+
+    listeners: {
+      'remove': '_handleRemove',
+    },
+
+    get focusStart() {
+      return this.$.entry.focusStart;
+    },
+
+    _handleAdd: function(e) {
+      var reviewer = e.detail.value;
+      // Append new account or group to the accounts property. We add our own
+      // internal properties to the account/group here, so we clone the object
+      // to avoid cluttering up the shared change object.
+      // TODO(logan): Polyfill for Object.assign in IE.
+      if (reviewer.account) {
+        var account = Object.assign({}, reviewer.account, {_pendingAdd: true});
+        this.push('accounts', account);
+      } else if (reviewer.group) {
+        if (reviewer.confirm) {
+          this.pendingConfirmation = reviewer;
+          return;
+        }
+        var group = Object.assign({}, reviewer.group,
+            {_pendingAdd: true, _group: true});
+        this.push('accounts', group);
+      }
+      this.pendingConfirmation = null;
+    },
+
+    confirmGroup: function(group) {
+      group = Object.assign(
+          {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+      this.push('accounts', group);
+      this.pendingConfirmation = null;
+    },
+
+    _computeChipClass: function(account) {
+      var classes = [];
+      if (account._group) {
+        classes.push('group');
+      }
+      if (account._pendingAdd) {
+        classes.push('pendingAdd');
+      }
+      return classes.join(' ');
+    },
+
+    _computeRemovable: function(account) {
+      return !this.readonly && !!account._pendingAdd;
+    },
+
+    _handleRemove: function(e) {
+      var toRemove = e.detail.account;
+      for (var i = 0; i < this.accounts.length; i++) {
+        var matches;
+        var account = this.accounts[i];
+        if (toRemove._group) {
+          matches = toRemove.id === account.id;
+        } else {
+          matches = toRemove._account_id === account._account_id;
+        }
+        if (matches) {
+          this.splice('accounts', i, 1);
+          this.$.entry.focus();
+          return;
+        }
+      }
+      console.warn('received remove event for missing account',
+          e.detail.account);
+    },
+
+    additions: function() {
+      var result = [];
+      return this.accounts.filter(function(account) {
+        return account._pendingAdd;
+      }).map(function(account) {
+        if (account._group) {
+          return {group: account};
+        } else {
+          return {account: account};
+        }
+      });
+      return result;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
new file mode 100644
index 0000000..bb55d08
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -0,0 +1,236 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-list></gr-account-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-list tests', function() {
+    var _nextAccountId = 0;
+    var makeAccount = function() {
+      var accountId = ++_nextAccountId;
+      return {
+        _account_id: accountId,
+      };
+    };
+    var makeGroup = function() {
+      var groupId = 'group' + (++_nextAccountId);
+      return {
+        id: groupId,
+      };
+    };
+
+    var existingReviewer1;
+    var existingReviewer2;
+    var element;
+
+    function getChips() {
+      return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
+    }
+
+    setup(function() {
+      existingReviewer1 = makeAccount();
+      existingReviewer2 = makeAccount();
+
+      element = fixture('basic');
+      element.accounts = [existingReviewer1, existingReviewer2];
+
+      stub('gr-rest-api-interface', {
+        getConfig: function() {
+          return Promise.resolve({});
+        },
+      });
+    });
+
+    test('account entry only appears when editable', function() {
+      element.readonly = false;
+      assert.isFalse(element.$.entry.hasAttribute('hidden'));
+      element.readonly = true;
+      assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    });
+
+    test('addition and removal of account/group chips', function() {
+      flushAsynchronousOperations();
+
+      // Existing accounts are listed.
+      var chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isFalse(chips[1].classList.contains('pendingAdd'));
+
+      // New accounts are added to end with pendingAdd class.
+      var newAccount = makeAccount();
+      element._handleAdd({
+        detail: {
+          value: {
+            account: newAccount,
+          },
+        },
+      });
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 3);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isFalse(chips[1].classList.contains('pendingAdd'));
+      assert.isTrue(chips[2].classList.contains('pendingAdd'));
+
+      // Removed accounts are taken out of the list.
+      element.fire('remove', {account: existingReviewer1});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+      // Invalid remove is ignored.
+      element.fire('remove', {account: existingReviewer1});
+      element.fire('remove', {account: newAccount});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 1);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+
+      // New groups are added to end with pendingAdd and group classes.
+      var newGroup = makeGroup();
+      element._handleAdd({
+        detail: {
+          value: {
+            group: newGroup,
+          },
+        },
+      });
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isTrue(chips[1].classList.contains('group'));
+      assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+      // Removed groups are taken out of the list.
+      element.fire('remove', {account: newGroup});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 1);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    });
+
+    test('_computeChipClass', function() {
+      var account = makeAccount();
+      assert.equal(element._computeChipClass(account), '');
+      account._pendingAdd = true;
+      assert.equal(element._computeChipClass(account), 'pendingAdd');
+      account._group = true;
+      assert.equal(element._computeChipClass(account), 'group pendingAdd');
+      account._pendingAdd = false;
+      assert.equal(element._computeChipClass(account), 'group');
+    });
+
+    test('_computeRemovable', function() {
+      var newAccount = makeAccount();
+      newAccount._pendingAdd = true;
+      element.readonly = false;
+      assert.isFalse(element._computeRemovable(existingReviewer1));
+      assert.isTrue(element._computeRemovable(newAccount));
+
+      element.readonly = true;
+      assert.isFalse(element._computeRemovable(existingReviewer1));
+      assert.isFalse(element._computeRemovable(newAccount));
+    });
+
+    test('additions returns sanitized new accounts and groups', function() {
+      assert.equal(element.additions().length, 0);
+
+      var newAccount = makeAccount();
+      element._handleAdd({
+        detail: {
+          value: {
+            account: newAccount,
+          },
+        },
+      });
+      var newGroup = makeGroup();
+      element._handleAdd({
+        detail: {
+          value: {
+            group: newGroup,
+          },
+        },
+      });
+
+      assert.deepEqual(element.additions(), [
+        {
+          account: {
+            _account_id: newAccount._account_id,
+            _pendingAdd: true,
+          },
+        },
+        {
+          group: {
+            id: newGroup.id,
+            _group: true,
+            _pendingAdd: true,
+          },
+        },
+      ]);
+    });
+
+    test('large group confirmations', function() {
+      assert.isNull(element.pendingConfirmation);
+      assert.deepEqual(element.additions(), []);
+
+      var group = makeGroup();
+      var reviewer = {
+        group: group,
+        count: 10,
+        confirm: true,
+      };
+      element._handleAdd({
+        detail: {
+          value: reviewer,
+        },
+      });
+
+      assert.deepEqual(element.pendingConfirmation, reviewer);
+      assert.deepEqual(element.additions(), []);
+
+      element.confirmGroup(group);
+      assert.isNull(element.pendingConfirmation);
+      assert.deepEqual(element.additions(), [
+        {
+          group: {
+            id: group.id,
+            _group: true,
+            _pendingAdd: true,
+            confirmed: true,
+          },
+        },
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
new file mode 100644
index 0000000..b741784
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -0,0 +1,117 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html">
+<link rel="import" href="../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html">
+<link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
+<link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+
+<dom-module id="gr-change-actions">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      section {
+        margin-top: 1em;
+      }
+      .groupLabel {
+        color: #666;
+        margin-bottom: .15em;
+        text-align: center;
+      }
+      gr-button {
+        display: block;
+        margin-bottom: .5em;
+      }
+      gr-button:before {
+        content: attr(data-label);
+      }
+      gr-button[loading]:before {
+        content: attr(data-loading-label);
+      }
+      @media screen and (max-width: 50em) {
+        .confirmDialog {
+          width: 90vw;
+        }
+      }
+    </style>
+    <div>
+      <section hidden$="[[!_actionCount(actions.*, _additionalActions.*)]]">
+        <div class="groupLabel">Change</div>
+        <template is="dom-repeat" items="[[_changeActionValues]]" as="action">
+          <gr-button title$="[[action.title]]"
+              primary$="[[action.__primary]]"
+              hidden$="[[!action.enabled]]"
+              data-action-key$="[[action.__key]]"
+              data-action-type$="[[action.__type]]"
+              data-label$="[[action.label]]"
+              data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
+              on-tap="_handleActionTap"></gr-button>
+        </template>
+      </section>
+      <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
+        <div class="groupLabel">Revision</div>
+        <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
+          <gr-button title$="[[action.title]]"
+              primary$="[[action.__primary]]"
+              disabled$="[[!action.enabled]]"
+              data-action-key$="[[action.__key]]"
+              data-action-type$="[[action.__type]]"
+              data-label$="[[action.label]]"
+              data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
+              on-tap="_handleActionTap"></gr-button>
+        </template>
+      </section>
+    </div>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-rebase-dialog id="confirmRebase"
+          class="confirmDialog"
+          on-confirm="_handleRebaseConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-rebase-dialog>
+      <gr-confirm-cherrypick-dialog id="confirmCherrypick"
+          class="confirmDialog"
+          commit-info="[[commitInfo]]"
+          on-confirm="_handleCherrypickConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-cherrypick-dialog>
+      <gr-confirm-revert-dialog id="confirmRevertDialog"
+          class="confirmDialog"
+          commit-info="[[commitInfo]]"
+          on-confirm="_handleRevertDialogConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-revert-dialog>
+      <gr-confirm-abandon-dialog id="confirmAbandonDialog"
+          class="confirmDialog"
+          on-confirm="_handleAbandonDialogConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-abandon-dialog>
+    </gr-overlay>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-actions.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
new file mode 100644
index 0000000..3445f4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -0,0 +1,441 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // TODO(davido): Add the rest of the change actions.
+  var ChangeActions = {
+    ABANDON: 'abandon',
+    DELETE: '/',
+    RESTORE: 'restore',
+    REVERT: 'revert',
+  };
+
+  // TODO(andybons): Add the rest of the revision actions.
+  var RevisionActions = {
+    CHERRYPICK: 'cherrypick',
+    DELETE: '/',
+    PUBLISH: 'publish',
+    REBASE: 'rebase',
+    SUBMIT: 'submit',
+  };
+
+  var ActionLoadingLabels = {
+    'abandon': 'Abandoning...',
+    'cherrypick': 'Cherry-Picking...',
+    'delete': 'Deleting...',
+    'publish': 'Publishing...',
+    'rebase': 'Rebasing...',
+    'restore': 'Restoring...',
+    'revert': 'Reverting...',
+    'submit': 'Submitting...',
+  };
+
+  var ActionType = {
+    CHANGE: 'change',
+    REVISION: 'revision',
+  };
+
+  var ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+  Polymer({
+    is: 'gr-change-actions',
+
+    /**
+     * Fired when the change should be reloaded.
+     *
+     * @event reload-change
+     */
+
+    properties: {
+      change: Object,
+      actions: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      primaryActionKeys: {
+        type: Array,
+        value: function() {
+          return [
+            RevisionActions.PUBLISH,
+            RevisionActions.SUBMIT,
+          ];
+        },
+      },
+      changeNum: String,
+      patchNum: String,
+      commitInfo: Object,
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _revisionActions: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _revisionActionValues: {
+        type: Array,
+        computed: '_computeRevisionActionValues(_revisionActions.*, ' +
+            'primaryActionKeys.*, _additionalActions.*)',
+      },
+      _changeActionValues: {
+        type: Array,
+        computed: '_computeChangeActionValues(actions.*, ' +
+            'primaryActionKeys.*, _additionalActions.*)',
+      },
+      _additionalActions: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    ActionType: ActionType,
+    ChangeActions: ChangeActions,
+    RevisionActions: RevisionActions,
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_actionsChanged(actions.*, _revisionActions.*, _additionalActions.*)',
+    ],
+
+    ready: function() {
+      this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+    },
+
+    reload: function() {
+      if (!this.changeNum || !this.patchNum) {
+        return Promise.resolve();
+      }
+
+      this._loading = true;
+      return this._getRevisionActions().then(function(revisionActions) {
+        if (!revisionActions) { return; }
+
+        this._revisionActions = revisionActions;
+        this._loading = false;
+      }.bind(this)).catch(function(err) {
+        alert('Couldn’t load revision actions. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        this._loading = false;
+        throw err;
+      }.bind(this));
+    },
+
+    addActionButton: function(type, label) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error('Invalid action type: ' + type);
+      }
+      var action = {
+        enabled: true,
+        label: label,
+        __type: type,
+        __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36),
+      };
+      this.push('_additionalActions', action);
+      return action.__key;
+    },
+
+    removeActionButton: function(key) {
+      var idx = this._indexOfActionButtonWithKey(key);
+      if (idx === -1) {
+        return;
+      }
+      this.splice('_additionalActions', idx, 1);
+    },
+
+    setActionButtonProp: function(key, prop, value) {
+      this.set([
+        '_additionalActions',
+        this._indexOfActionButtonWithKey(key),
+        prop,
+      ], value);
+    },
+
+    _indexOfActionButtonWithKey: function(key) {
+      for (var i = 0; i < this._additionalActions.length; i++) {
+        if (this._additionalActions[i].__key === key) {
+          return i;
+        }
+      }
+      return -1;
+    },
+
+    _getRevisionActions: function() {
+      return this.$.restAPI.getChangeRevisionActions(this.changeNum,
+          this.patchNum);
+    },
+
+    _actionCount: function(actionsChangeRecord, additionalActionsChangeRecord) {
+      var additionalActions = (additionalActionsChangeRecord &&
+          additionalActionsChangeRecord.base) || [];
+      return this._keyCount(actionsChangeRecord) + additionalActions.length;
+    },
+
+    _keyCount: function(changeRecord) {
+      return Object.keys((changeRecord && changeRecord.base) || {}).length;
+    },
+
+    _actionsChanged: function(actionsChangeRecord, revisionActionsChangeRecord,
+        additionalActionsChangeRecord) {
+      var additionalActions = (additionalActionsChangeRecord &&
+          additionalActionsChangeRecord.base) || [];
+      this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
+          this._keyCount(revisionActionsChangeRecord) === 0 &&
+              additionalActions.length === 0;
+    },
+
+    _getValuesFor: function(obj) {
+      return Object.keys(obj).map(function(key) {
+        return obj[key];
+      });
+    },
+
+    _computeRevisionActionValues: function(actionsChangeRecord,
+        primariesChangeRecord, additionalActionsChangeRecord) {
+      return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
+          additionalActionsChangeRecord, 'revision');
+    },
+
+    _computeChangeActionValues: function(actionsChangeRecord,
+        primariesChangeRecord, additionalActionsChangeRecord) {
+      return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
+          additionalActionsChangeRecord, 'change');
+    },
+
+    _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
+        additionalActionsChangeRecord, type) {
+      if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
+
+      var actions = actionsChangeRecord.base || {};
+      var primaryActionKeys = primariesChangeRecord.base || [];
+      var result = [];
+      var values = this._getValuesFor(
+          type === ActionType.CHANGE ? ChangeActions : RevisionActions);
+      for (var a in actions) {
+        if (values.indexOf(a) === -1) { continue; }
+        actions[a].__key = a;
+        actions[a].__type = type;
+        actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        // Triggers a re-render by ensuring object inequality.
+        // TODO(andybons): Polyfill for Object.assign.
+        result.push(Object.assign({}, actions[a]));
+      }
+
+      var additionalActions = (additionalActionsChangeRecord &&
+      additionalActionsChangeRecord.base) || [];
+      additionalActions = additionalActions.filter(function(a) {
+        return a.__type === type;
+      }).map(function(a) {
+        a.__primary = primaryActionKeys.indexOf(a.__key) !== -1;
+        // Triggers a re-render by ensuring object inequality.
+        // TODO(andybons): Polyfill for Object.assign.
+        return Object.assign({}, a);
+      });
+      return result.concat(additionalActions);
+    },
+
+    _computeLoadingLabel: function(action) {
+      return ActionLoadingLabels[action] || 'Working...';
+    },
+
+    _canSubmitChange: function() {
+      return this.$.jsAPI.canSubmitChange();
+    },
+
+    _modifyRevertMsg: function() {
+      return this.$.jsAPI.modifyRevertMsg(this.change,
+                                          this.$.confirmRevertDialog.message);
+    },
+
+    _handleActionTap: function(e) {
+      e.preventDefault();
+      var el = Polymer.dom(e).rootTarget;
+      var key = el.getAttribute('data-action-key');
+      if (key.indexOf(ADDITIONAL_ACTION_KEY_PREFIX) === 0) {
+        this.fire(key + '-tap', {node: el});
+        return;
+      }
+      var type = el.getAttribute('data-action-type');
+      if (type === ActionType.REVISION) {
+        this._handleRevisionAction(key);
+      } else if (key === ChangeActions.REVERT) {
+        this.$.confirmRevertDialog.populateRevertMessage();
+        this.$.confirmRevertDialog.message = this._modifyRevertMsg();
+        this._showActionDialog(this.$.confirmRevertDialog);
+      } else if (key === ChangeActions.ABANDON) {
+        this._showActionDialog(this.$.confirmAbandonDialog);
+      } else {
+        this._fireAction(this._prependSlash(key), this.actions[key], false);
+      }
+    },
+
+    _handleRevisionAction: function(key) {
+      switch (key) {
+        case RevisionActions.REBASE:
+          this._showActionDialog(this.$.confirmRebase);
+          break;
+        case RevisionActions.CHERRYPICK:
+          this._showActionDialog(this.$.confirmCherrypick);
+          break;
+        case RevisionActions.SUBMIT:
+          if (!this._canSubmitChange()) {
+            return;
+          }
+          /* falls through */ // required by JSHint
+        default:
+          this._fireAction(this._prependSlash(key),
+              this._revisionActions[key], true);
+      }
+    },
+
+    _prependSlash: function(key) {
+      return key === '/' ? key : '/' + key;
+    },
+
+    _handleConfirmDialogCancel: function() {
+      var dialogEls =
+          Polymer.dom(this.root).querySelectorAll('.confirmDialog');
+      for (var i = 0; i < dialogEls.length; i++) {
+        dialogEls[i].hidden = true;
+      }
+      this.$.overlay.close();
+    },
+
+    _handleRebaseConfirm: function() {
+      var payload = {};
+      var el = this.$.confirmRebase;
+      if (el.clearParent) {
+        // There is a subtle but important difference between setting the base
+        // to an empty string and omitting it entirely from the payload. An
+        // empty string implies that the parent should be cleared and the
+        // change should be rebased on top of the target branch. Leaving out
+        // the base implies that it should be rebased on top of its current
+        // parent.
+        payload.base = '';
+      } else if (el.base && el.base.length > 0) {
+        payload.base = el.base;
+      }
+      this.$.overlay.close();
+      el.hidden = false;
+      this._fireAction('/rebase', this._revisionActions.rebase, true, payload);
+    },
+
+    _handleCherrypickConfirm: function() {
+      var el = this.$.confirmCherrypick;
+      if (!el.branch) {
+        // TODO(davido): Fix error handling
+        alert('The destination branch can’t be empty.');
+        return;
+      }
+      if (!el.message) {
+        alert('The commit message can’t be empty.');
+        return;
+      }
+      this.$.overlay.close();
+      el.hidden = false;
+      this._fireAction(
+          '/cherrypick',
+          this._revisionActions.cherrypick,
+          true,
+          {
+            destination: el.branch,
+            message: el.message,
+          }
+      );
+    },
+
+    _handleRevertDialogConfirm: function() {
+      var el = this.$.confirmRevertDialog;
+      this.$.overlay.close();
+      el.hidden = false;
+      this._fireAction('/revert', this.actions.revert, false,
+          {message: el.message});
+    },
+
+    _handleAbandonDialogConfirm: function() {
+      var el = this.$.confirmAbandonDialog;
+      this.$.overlay.close();
+      el.hidden = false;
+      this._fireAction('/abandon', this.actions.abandon, false,
+          {message: el.message});
+    },
+
+    _setLoadingOnButtonWithKey: function(key) {
+      var buttonEl = this.$$('[data-action-key="' + key + '"]');
+      buttonEl.setAttribute('loading', true);
+      buttonEl.disabled = true;
+      return function() {
+        buttonEl.removeAttribute('loading');
+        buttonEl.disabled = false;
+      };
+    },
+
+    _fireAction: function(endpoint, action, revAction, opt_payload) {
+      var cleanupFn = this._setLoadingOnButtonWithKey(action.__key);
+
+      this._send(action.method, opt_payload, endpoint, revAction, cleanupFn)
+          .then(this._handleResponse.bind(this, action));
+    },
+
+    _showActionDialog: function(dialog) {
+      dialog.hidden = false;
+      this.$.overlay.open();
+    },
+
+    _handleResponse: function(action, response) {
+      return this.$.restAPI.getResponseObject(response).then(function(obj) {
+        switch (action.__key) {
+          case ChangeActions.REVERT:
+          case RevisionActions.CHERRYPICK:
+            page.show(this.changePath(obj._number));
+            break;
+          case ChangeActions.DELETE:
+          case RevisionActions.DELETE:
+            if (action.__type === ActionType.CHANGE) {
+              page.show('/');
+            } else {
+              page.show(this.changePath(this.changeNum));
+            }
+            break;
+          default:
+            this.fire('reload-change', null, {bubbles: false});
+            break;
+        }
+      }.bind(this));
+    },
+
+    _handleResponseError: function(response) {
+      if (response.ok) { return response; }
+
+      return response.text().then(function(errText) {
+        alert('Could not perform action: ' + errText);
+        throw Error(errText);
+      });
+    },
+
+    _send: function(method, payload, actionEndpoint, revisionAction,
+        cleanupFn) {
+      var url = this.$.restAPI.getChangeActionURL(this.changeNum,
+          revisionAction ? this.patchNum : null, actionEndpoint);
+      return this.$.restAPI.send(method, url, payload).then(function(response) {
+        cleanupFn.call(this);
+        return response;
+      }.bind(this)).then(this._handleResponseError.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
new file mode 100644
index 0000000..80aaf3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -0,0 +1,290 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-actions</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-actions.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-actions></gr-change-actions>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-actions tests', function() {
+    var element;
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions: function() {
+          return Promise.resolve({
+            cherrypick: {
+              method: 'POST',
+              label: 'Cherry Pick',
+              title: 'Cherry pick change to a different branch',
+              enabled: true
+            },
+            rebase: {
+              method: 'POST',
+              label: 'Rebase',
+              title: 'Rebase onto tip of branch or parent change'
+            },
+            submit: {
+              method: 'POST',
+              label: 'Submit',
+              title: 'Submit patch set 1 into master',
+              enabled: true
+            }
+          });
+        },
+        send: function(method, url, payload) {
+          if (method !== 'POST') { return Promise.reject('bad method'); }
+
+          if (url === '/changes/42/revisions/2/submit') {
+            return Promise.resolve({
+              ok: true,
+              text: function() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          } else if (url === '/changes/42/revisions/2/rebase') {
+            return Promise.resolve({
+              ok: true,
+              text: function() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          }
+
+          return Promise.reject('bad url');
+        },
+      });
+
+      element = fixture('basic');
+      element.changeNum = '42';
+      element.patchNum = '2';
+      return element.reload();
+    });
+
+    test('submit, rebase, and cherry-pick buttons show', function(done) {
+      flush(function() {
+        var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button');
+        assert.equal(buttonEls.length, 3);
+        assert.isFalse(element.hidden);
+        done();
+      });
+    });
+
+    test('submit change', function(done) {
+      flush(function() {
+        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+        assert.ok(submitButton);
+        MockInteractions.tap(submitButton);
+
+        // Upon success it should fire the reload-change event.
+        element.addEventListener('reload-change', function(e) {
+          done();
+        });
+      });
+    });
+
+    test('submit change with plugin hook', function(done) {
+      var canSubmitStub = sinon.stub(element, '_canSubmitChange',
+          function() { return false; });
+      var fireActionStub = sinon.stub(element, '_fireAction');
+      flush(function() {
+        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+        assert.ok(submitButton);
+        MockInteractions.tap(submitButton);
+        assert.equal(fireActionStub.callCount, 0);
+
+        canSubmitStub.restore();
+        fireActionStub.restore();
+        done();
+      });
+    });
+
+    test('rebase change', function(done) {
+      var fireActionStub = sinon.stub(element, '_fireAction');
+      flush(function() {
+        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        MockInteractions.tap(rebaseButton);
+        var rebaseAction = {
+          __key: 'rebase',
+          __type: 'revision',
+          __primary: false,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change',
+        };
+        element.$.confirmRebase.base = '1234';
+        element._handleRebaseConfirm();
+        assert.deepEqual(fireActionStub.lastCall.args,
+          ['/rebase', rebaseAction, true, {base: '1234'}]);
+
+        element.$.confirmRebase.base = '';
+        element._handleRebaseConfirm();
+        assert.deepEqual(fireActionStub.lastCall.args,
+          ['/rebase', rebaseAction, true, {}]);
+
+        element.$.confirmRebase.base = 'does not matter';
+        element.$.confirmRebase.clearParent = true;
+        element._handleRebaseConfirm();
+        assert.deepEqual(fireActionStub.lastCall.args,
+          ['/rebase', rebaseAction, true, {base: ''}]);
+
+        fireActionStub.restore();
+        done();
+      });
+    });
+
+    suite('cherry-pick', function() {
+      var fireActionStub;
+      var alertStub;
+
+      setup(function() {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+      });
+
+      teardown(function() {
+        alertStub.restore();
+        fireActionStub.restore();
+      });
+
+      test('works', function() {
+        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        MockInteractions.tap(rebaseButton);
+        var action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry Pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master';
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0);  // Still needs a message.
+
+        element.$.confirmCherrypick.message = 'foo message';
+        element._handleCherrypickConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            message: 'foo message',
+          }
+        ]);
+      });
+    });
+
+    test('custom actions', function(done) {
+      // Add a button with the same key as a server-based one to ensure
+      // collisions are taken care of.
+      var key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', function(e) {
+        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
+        element.removeActionButton(key);
+        flush(function() {
+          assert.notOk(element.$$('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+      flush(function() {
+        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+      });
+    });
+
+    suite('revert change', function() {
+      var alertStub;
+      var fireActionStub;
+
+      setup(function() {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+        element.actions = {
+          revert: {
+            method: 'POST',
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true
+          }
+        };
+        return element.reload();
+      });
+
+      teardown(function() {
+        alertStub.restore();
+        fireActionStub.restore();
+      });
+
+      test('revert change with plugin hook', function(done) {
+        var newRevertMsg = 'Modified revert msg';
+        var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
+            function() { return newRevertMsg; });
+        var populateRevertMsgStub = sinon.stub(
+            element.$.confirmRevertDialog, 'populateRevertMessage',
+            function() { return 'original msg'; });
+        flush(function() {
+          var revertButton = element.$$('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+
+          assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
+
+          populateRevertMsgStub.restore();
+          modifyRevertMsgStub.restore();
+          done();
+        });
+      });
+
+      test('works', function() {
+        var populateRevertMsgStub = sinon.stub(
+            element.$.confirmRevertDialog, 'populateRevertMessage',
+            function() { return 'original msg'; });
+        var revertButton = element.$$('gr-button[data-action-key="revert"]');
+        MockInteractions.tap(revertButton);
+
+        element.$.confirmRevertDialog.message = 'foo message';
+        element._handleRevertDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        var action = {
+          __key: 'revert',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Revert',
+          method: 'POST',
+          title: 'Revert the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/revert', action, false, {
+            message: 'foo message',
+          }]);
+        populateRevertMsgStub.restore();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
new file mode 100644
index 0000000..8b51312
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -0,0 +1,182 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
+
+<dom-module id="gr-change-metadata">
+  <template>
+    <style>
+      section:not(:first-of-type) {
+        margin-top: 1em;
+      }
+      .title,
+      .value {
+        display: block;
+      }
+      .title {
+        color: #666;
+        font-weight: bold;
+      }
+      .labelValueContainer:not(:first-of-type) {
+        margin-top: .25em;
+      }
+      .labelValueContainer .approved,
+      .labelValueContainer .notApproved {
+        display: inline-block;
+        padding: .1em .3em;
+        border-radius: 3px;
+      }
+      .labelValue {
+        display: inline-block;
+      }
+      .approved {
+        background-color: #d4ffd4;
+      }
+      .notApproved {
+        background-color: #ffd4d4;
+      }
+      @media screen and (max-width: 50em), screen and (min-width: 75em) {
+        :host {
+          display: table;
+        }
+        section {
+          display: table-row;
+        }
+        section:not(:first-of-type) .title,
+        section:not(:first-of-type) .value {
+          padding-top: .5em;
+        }
+        .title,
+        .value {
+          display: table-cell;
+          vertical-align: top;
+        }
+        .title {
+          padding-right: .5em;
+        }
+      }
+    </style>
+    <section>
+      <span class="title">Updated</span>
+      <span class="value">
+        <gr-date-formatter
+            date-str="[[change.updated]]"></gr-date-formatter>
+      </span>
+    </section>
+    <section>
+      <span class="title">Owner</span>
+      <span class="value">
+        <gr-account-link account="[[change.owner]]"></gr-account-link>
+      </span>
+    </section>
+    <template is="dom-if" if="[[_showReviewersByState]]">
+      <section>
+        <span class="title">Reviewers</span>
+        <span class="value">
+          <gr-reviewer-list
+              change="{{change}}"
+              mutable="[[mutable]]"
+              reviewers-only></gr-reviewer-list>
+        </span>
+      </section>
+      <section>
+        <span class="title">CC</span>
+        <span class="value">
+          <gr-reviewer-list
+              change="{{change}}"
+              mutable="[[mutable]]"
+              ccs-only></gr-reviewer-list>
+        </span>
+      </section>
+    </template>
+    <template is="dom-if" if="[[!_showReviewersByState]]">
+      <section>
+        <span class="title">Reviewers</span>
+        <span class="value">
+          <gr-reviewer-list
+              change="{{change}}"
+              mutable="[[mutable]]"></gr-reviewer-list>
+        </span>
+      </section>
+    </template>
+    <section>
+      <span class="title">Project</span>
+      <span class="value">[[change.project]]</span>
+    </section>
+    <section>
+      <span class="title">Branch</span>
+      <span class="value">[[change.branch]]</span>
+    </section>
+    <section>
+      <span class="title">Commit</span>
+      <span class="value">
+        <template is="dom-if" if="[[_showWebLink]]">
+          <a target="_blank"
+             href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
+        </template>
+        <template is="dom-if" if="[[!_showWebLink]]">
+          [[_computeShortHash(commitInfo)]]
+        </template>
+      </span>
+    </section>
+    <section>
+      <span class="title">Topic</span>
+      <span class="value">
+        <gr-editable-label
+            value="{{change.topic}}"
+            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+            read-only="[[_topicReadOnly]]"
+            on-changed="_handleTopicChanged"></gr-editable-label>
+      </span>
+    </section>
+    <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+      <span class="title">Strategy</span>
+      <span class="value">[[_computeStrategy(change)]]</span>
+    </section>
+    <template is="dom-repeat"
+        items="[[_computeLabelNames(change.labels)]]" as="labelName">
+      <section>
+        <span class="title">[[labelName]]</span>
+        <span class="value">
+          <template is="dom-repeat"
+              items="[[_computeLabelValues(labelName, change.labels)]]"
+              as="label">
+            <div class="labelValueContainer">
+              <span class$="[[label.className]]">
+                <gr-label
+                    has-tooltip
+                    title="[[_computeValueTooltip(label.value, labelName)]]"
+                    class="labelValue">
+                  [[label.value]]
+                </gr-label>
+                <gr-account-link account="[[label.account]]"></gr-account-link>
+              </span>
+            </div>
+          </template>
+        </span>
+      </section>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-metadata.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
new file mode 100644
index 0000000..af19703
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -0,0 +1,146 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var SubmitTypeLabel = {
+    FAST_FORWARD_ONLY: 'Fast Forward Only',
+    MERGE_IF_NECESSARY: 'Merge if Necessary',
+    REBASE_IF_NECESSARY: 'Rebase if Necessary',
+    MERGE_ALWAYS: 'Always Merge',
+    CHERRY_PICK: 'Cherry Pick',
+  };
+
+  Polymer({
+    is: 'gr-change-metadata',
+
+    properties: {
+      change: Object,
+      commitInfo: Object,
+      mutable: Boolean,
+      serverConfig: Object,
+      _showWebLink: {
+        type: Boolean,
+        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
+      },
+      _webLink: {
+        type: String,
+        computed: '_computeWebLink(change, commitInfo, serverConfig)',
+      },
+      _topicReadOnly: {
+        type: Boolean,
+        computed: '_computeTopicReadOnly(mutable, change)',
+      },
+      _showReviewersByState: {
+        type: Boolean,
+        computed: '_computeShowReviewersByState(serverConfig)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    _computeShowWebLink: function(change, commitInfo, serverConfig) {
+      var webLink = commitInfo.web_links && commitInfo.web_links.length;
+      var gitWeb = serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision;
+      return webLink || gitWeb;
+    },
+
+    _computeWebLink: function(change, commitInfo, serverConfig) {
+      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
+        return;
+      }
+
+      if (serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
+        return serverConfig.gitweb.url +
+            serverConfig.gitweb.type.revision
+                .replace('${project}', change.project)
+                .replace('${commit}', commitInfo.commit);
+      }
+
+      var webLink = commitInfo.web_links[0].url;
+      if (!/^https?\:\/\//.test(webLink)) {
+        webLink = '../../' + webLink;
+      }
+
+      return webLink;
+    },
+
+    _computeShortHash: function(commitInfo) {
+      return commitInfo.commit.slice(0, 7);
+    },
+
+    _computeHideStrategy: function(change) {
+      return !this.changeIsOpen(change.status);
+    },
+
+    _computeStrategy: function(change) {
+      return SubmitTypeLabel[change.submit_type];
+    },
+
+    _computeLabelNames: function(labels) {
+      return Object.keys(labels).sort();
+    },
+
+    _computeLabelValues: function(labelName, labels) {
+      var result = [];
+      var t = labels[labelName];
+      if (!t) { return result; }
+      var approvals = t.all || [];
+      approvals.forEach(function(label) {
+        if (label.value && label.value != labels[labelName].default_value) {
+          var labelClassName;
+          var labelValPrefix = '';
+          if (label.value > 0) {
+            labelValPrefix = '+';
+            labelClassName = 'approved';
+          } else if (label.value < 0) {
+            labelClassName = 'notApproved';
+          }
+          result.push({
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          });
+        }
+      });
+      return result;
+    },
+
+    _computeValueTooltip: function(score, labelName) {
+      var values = this.change.labels[labelName].values;
+      return values[score];
+    },
+
+    _handleTopicChanged: function(e, topic) {
+      if (!topic.length) { topic = null; }
+      this.$.restAPI.setChangeTopic(this.change.id, topic);
+    },
+
+    _computeTopicReadOnly: function(mutable, change) {
+      return !mutable || !change.actions.topic || !change.actions.topic.enabled;
+    },
+
+    _computeTopicPlaceholder: function(_topicReadOnly) {
+      return _topicReadOnly ? 'No Topic' : 'Click to add topic';
+    },
+
+    _computeShowReviewersByState: function(serverConfig) {
+      return !!serverConfig.note_db_enabled;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
new file mode 100644
index 0000000..01f0649
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -0,0 +1,156 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-metadata</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-metadata.html">
+<script src="../../../scripts/util.js"></script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-metadata></gr-change-metadata>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+
+      element = fixture('basic');
+    });
+
+    test('computed fields', function() {
+      assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
+      assert.isFalse(element._computeHideStrategy({status: 'DRAFT'}));
+      assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
+      assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
+      assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
+          'Cherry Pick');
+    });
+
+    test('show strategy for open change', function() {
+      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
+      flushAsynchronousOperations();
+      var strategy = element.$$('.strategy');
+      assert.ok(strategy);
+      assert.isFalse(strategy.hasAttribute('hidden'));
+      assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
+    });
+
+    test('hide strategy for closed change', function() {
+      element.change = {status: 'MERGED', labels: {}};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
+    });
+
+    test('no web link when unavailable', function() {
+      element.commitInfo = {};
+      element.serverConfig = {};
+      element.change = {labels: []};
+
+      assert.isNotOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+    });
+
+    test('use web link when available', function() {
+      element.commitInfo = {web_links: [{url: 'link-url'}]};
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), '../../link-url');
+    });
+
+    test('does not relativize web links that begin with scheme', function() {
+      element.commitInfo = {web_links: [{url: 'https://link-url'}]};
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'https://link-url');
+    });
+
+    test('use gitweb when available', function() {
+      element.commitInfo = {commit: 'commit-sha'};
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
+    });
+
+    test('prefer gitweb when both are available', function() {
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [{url: 'link-url'}]
+      };
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      var link = element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig);
+
+      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
+      assert.notEqual(link, '../../link-url');
+    });
+
+    test('show CC section when NoteDb enabled', function() {
+      function hasCc() {
+        return element._showReviewersByState;
+      }
+
+      element.serverConfig = {};
+      assert.isFalse(hasCc());
+
+      element.serverConfig = {note_db_enabled: true};
+      assert.isTrue(hasCc());
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
new file mode 100644
index 0000000..e3f7fd2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -0,0 +1,334 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-change-actions/gr-change-actions.html">
+<link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
+<link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
+<link rel="import" href="../gr-file-list/gr-file-list.html">
+<link rel="import" href="../gr-messages-list/gr-messages-list.html">
+<link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
+<link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
+
+<dom-module id="gr-change-view">
+  <template>
+    <style>
+      .container:not(.loading) {
+        background-color: var(--view-background-color);
+      }
+      .container.loading {
+        color: #666;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      .headerContainer {
+        height: 4.1em;
+        margin-bottom: .5em;
+      }
+      .header {
+        align-items: center;
+        background-color: var(--view-background-color);
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        padding: 1em var(--default-horizontal-margin);
+        z-index: 99;  /* Less than gr-overlay's backdrop */
+      }
+      .header.pinned {
+        border-bottom-color: transparent;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+        position: fixed;
+        top: 0;
+        transition: box-shadow 250ms linear;
+        width: 100%;
+      }
+      .header-title {
+        flex: 1;
+        font-size: 1.2em;
+        font-weight: bold;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      gr-change-star {
+        margin-right: .25em;
+        vertical-align: -.425em;
+      }
+      .download,
+      .patchSelectLabel {
+        margin-left: 1em;
+      }
+      .header select {
+        margin-left: .5em;
+      }
+      .header .reply {
+        margin-left: var(--default-horizontal-margin);
+      }
+      gr-reply-dialog {
+        width: 50em;
+      }
+      .changeStatus {
+        color: #999;
+        text-transform: capitalize;
+      }
+      section {
+        margin: 10px 0;
+        padding: 10px var(--default-horizontal-margin);
+      }
+      /* Strong specificity here is needed due to
+         https://github.com/Polymer/polymer/issues/2531 */
+      .container section.changeInfo {
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        margin-top: 0;
+        padding-top: 0;
+      }
+      .changeInfo-column:not(:last-of-type) {
+        margin-right: 1em;
+        padding-right: 1em;
+      }
+      .changeMetadata {
+        border-right: 1px solid #ddd;
+        font-size: .9em;
+      }
+      gr-change-actions {
+        margin-top: 1em;
+      }
+      .commitMessage {
+        font-family: var(--monospace-font-family);
+        flex: 0 0 72ch;
+        margin-right: 2em;
+        margin-bottom: 1em;
+        overflow-x: hidden;
+      }
+      .commitMessage h4 {
+        font-family: var(--font-family);
+        font-weight: bold;
+        margin-bottom: .25em;
+      }
+      .commitMessage gr-linked-text {
+        --linked-text-white-space: pre;
+        overflow: auto;
+      }
+      .commitAndRelated {
+        align-content: flex-start;
+        display: flex;
+        flex: 1;
+        overflow-x: hidden;
+      }
+      .relatedChanges {
+        flex: 1 1 auto;
+        font-size: .9em;
+        overflow: hidden;
+      }
+      gr-file-list {
+        margin-bottom: 1em;
+        padding: 0 var(--default-horizontal-margin);
+      }
+      @media screen and (max-width: 50em) {
+        .headerContainer {
+          height: 5.15em;
+        }
+        .header {
+          align-items: flex-start;
+          flex-direction: column;
+          padding: .5em var(--default-horizontal-margin);
+        }
+        gr-change-star {
+          vertical-align: middle;
+        }
+        .header-title {
+          font-size: 1.1em;
+        }
+        .header-actions {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+          margin-top: .5em;
+        }
+        gr-reply-dialog {
+          min-width: initial;
+          width: 90vw;
+        }
+        .download {
+          display: none;
+        }
+        .patchSelectLabel {
+          margin-left: 0;
+          margin-right: .5em;
+        }
+        .header select {
+          margin-left: 0;
+          margin-right: .5em;
+        }
+        .header .reply {
+          margin-left: 0;
+          margin-right: .5em;
+        }
+        .changeInfo-column:not(:last-of-type) {
+          margin-right: 0;
+          padding-right: 0;
+        }
+        .changeInfo,
+        .commitAndRelated {
+          flex-direction: column;
+          flex-wrap: nowrap;
+        }
+        .relatedChanges,
+        .changeMetadata {
+          font-size: 1em;
+        }
+        .changeMetadata {
+          border-right: none;
+          margin-bottom: 1em;
+          margin-top: .25em;
+          max-width: none;
+        }
+        .commitMessage {
+          flex: initial;
+          margin-right: 0;
+        }
+      }
+    </style>
+    <div class="container loading" hidden$="{{!_loading}}">Loading...</div>
+    <div class="container" hidden$="{{_loading}}">
+      <div class="headerContainer">
+        <div class="header">
+          <span class="header-title">
+            <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+            <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><span>:</span>
+            <span>[[_change.subject]]</span>
+            <span class="changeStatus">[[_computeChangeStatus(_change, _patchRange.patchNum)]]</span>
+          </span>
+          <span class="header-actions">
+            <gr-button hidden
+                class="reply"
+                primary$="[[_computeReplyButtonHighlighted(_diffDrafts.*)]]"
+                hidden$="[[!_loggedIn]]"
+                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+            <gr-button class="download" on-tap="_handleDownloadTap">Download</gr-button>
+            <span>
+              <label class="patchSelectLabel" for="patchSetSelect">Patch set</label>
+              <select id="patchSetSelect" on-change="_handlePatchChange">
+                <template is="dom-repeat" items="{{_allPatchSets}}" as="patchNumber">
+                  <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchRange.patchNum)]]">
+                    <span>[[patchNumber]]</span>
+                    /
+                    <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
+                  </option>
+                </template>
+              </select>
+            </span>
+          </span>
+        </div>
+      </div>
+      <section class="changeInfo">
+        <div class="changeInfo-column changeMetadata">
+          <gr-change-metadata
+              change="{{_change}}"
+              commit-info="[[_commitInfo]]"
+              server-config="[[serverConfig]]"
+              mutable="[[_loggedIn]]"
+              on-show-reply-dialog="_handleShowReplyDialog">
+          </gr-change-metadata>
+          <gr-change-actions id="actions"
+              change="[[_change]]"
+              actions="[[_change.actions]]"
+              change-num="[[_changeNum]]"
+              patch-num="[[_patchRange.patchNum]]"
+              commit-info="[[_commitInfo]]"
+              on-reload-change="_handleReloadChange"></gr-change-actions>
+        </div>
+        <div class="changeInfo-column commitAndRelated">
+          <div class="commitMessage">
+            <h4>
+              Commit message
+              <gr-button link
+                  on-tap="_handleEditCommitMessage"
+                  hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
+            </h4>
+            <gr-editable-content id="commitMessageEditor"
+                editing="[[_editingCommitMessage]]"
+                content="{{_commitInfo.message}}">
+              <gr-linked-text pre
+                  content="[[_commitInfo.message]]"
+                  config="[[_projectConfig.commentlinks]]"></gr-linked-text>
+            </gr-editable-content>
+          </div>
+          <div class="relatedChanges">
+            <gr-related-changes-list id="relatedChanges"
+                change="[[_change]]"
+                patch-num="[[_patchRange.patchNum]]"></gr-related-changes-list>
+          </div>
+        </div>
+      </section>
+      <gr-file-list id="fileList"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          patch-range="[[_patchRange]]"
+          comments="[[_comments]]"
+          drafts="[[_diffDrafts]]"
+          revisions="[[_change.revisions]]"
+          projectConfig="[[_projectConfig]]"
+          selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
+      <gr-messages-list id="messageList"
+          change-num="[[_changeNum]]"
+          messages="[[_change.messages]]"
+          reviewer-updates="[[_change.reviewer_updates]]"
+          comments="[[_comments]]"
+          project-config="[[_projectConfig]]"
+          show-reply-buttons="[[_loggedIn]]"
+          on-reply="_handleMessageReply"></gr-messages-list>
+    </div>
+    <gr-overlay id="downloadOverlay" with-backdrop>
+      <gr-download-dialog
+          change="[[_change]]"
+          logged-in="[[_loggedIn]]"
+          patch-num="[[_patchRange.patchNum]]"
+          config="[[serverConfig.download]]"
+          on-close="_handleDownloadDialogClose"></gr-download-dialog>
+    </gr-overlay>
+    <gr-overlay id="replyOverlay"
+        on-iron-overlay-opened="_handleReplyOverlayOpen"
+        with-backdrop>
+      <gr-reply-dialog id="replyDialog"
+          change="[[_change]]"
+          patch-num="[[_patchRange.patchNum]]"
+          revisions="[[_change.revisions]]"
+          labels="[[_change.labels]]"
+          permitted-labels="[[_change.permitted_labels]]"
+          diff-drafts="[[_diffDrafts]]"
+          server-config="[[serverConfig]]"
+          on-send="_handleReplySent"
+          on-cancel="_handleReplyCancel"
+          on-autogrow="_handleReplyAutogrow"
+          hidden$="[[!_loggedIn]]">Reply</gr-reply-dialog>
+    </gr-overlay>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
new file mode 100644
index 0000000..14ac4d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -0,0 +1,635 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    /**
+     * Fired if an error occurs when fetching the change data.
+     *
+     * @event page-error
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      viewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+      serverConfig: Object,
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+
+      _comments: Object,
+      _change: {
+        type: Object,
+        observer: '_changeChanged',
+      },
+      _commitInfo: Object,
+      _changeNum: String,
+      _diffDrafts: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _editingCommitMessage: {
+        type: Boolean,
+        value: false,
+      },
+      _hideEditCommitMessage: {
+        type: Boolean,
+        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
+            '_editingCommitMessage, _change.*, _patchRange.patchNum)',
+      },
+      _patchRange: Object,
+      _allPatchSets: {
+        type: Array,
+        computed: '_computeAllPatchSets(_change)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: Boolean,
+      _headerContainerEl: Object,
+      _headerEl: Object,
+      _projectConfig: Object,
+      _replyButtonLabel: {
+        type: String,
+        value: 'Reply',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_labelsChanged(_change.labels.*)',
+      '_paramsAndChangeChanged(params, _change)',
+    ],
+
+    ready: function() {
+      this._headerEl = this.$$('.header');
+    },
+
+    attached: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = loggedIn;
+      }.bind(this));
+
+      this.addEventListener('comment-save', this._handleCommentSave.bind(this));
+      this.addEventListener('comment-discard',
+          this._handleCommentDiscard.bind(this));
+      this.addEventListener('editable-content-save',
+          this._handleCommitMessageSave.bind(this));
+      this.addEventListener('editable-content-cancel',
+          this._handleCommitMessageCancel.bind(this));
+      this.listen(window, 'scroll', '_handleBodyScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleBodyScroll');
+    },
+
+    _handleBodyScroll: function(e) {
+      var containerEl = this._headerContainerEl ||
+          this.$$('.headerContainer');
+
+      // Calculate where the header is relative to the window.
+      var top = containerEl.offsetTop;
+      for (var offsetParent = containerEl.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+      // The element may not be displayed yet, in which case do nothing.
+      if (top == 0) { return; }
+
+      this._headerEl.classList.toggle('pinned', window.scrollY >= top);
+    },
+
+    _resetHeaderEl: function() {
+      var el = this._headerEl || this.$$('.header');
+      this._headerEl = el;
+      el.classList.remove('pinned');
+    },
+
+    _handleEditCommitMessage: function(e) {
+      this._editingCommitMessage = true;
+      this.$.commitMessageEditor.focusTextarea();
+    },
+
+    _handleCommitMessageSave: function(e) {
+      var message = e.detail.content;
+
+      this.$.commitMessageEditor.disabled = true;
+      this._saveCommitMessage(message).then(function(resp) {
+        this.$.commitMessageEditor.disabled = false;
+        if (!resp.ok) { return; }
+
+        this.set('_commitInfo.message', message);
+        this._editingCommitMessage = false;
+        this._reloadWindow();
+      }.bind(this)).catch(function(err) {
+        this.$.commitMessageEditor.disabled = false;
+      }.bind(this));
+    },
+
+    _reloadWindow: function() {
+      window.location.reload();
+    },
+
+    _handleCommitMessageCancel: function(e) {
+      this._editingCommitMessage = false;
+    },
+
+    _saveCommitMessage: function(message) {
+      return this.$.restAPI.saveChangeCommitMessageEdit(
+          this._changeNum, message).then(function(resp) {
+            if (!resp.ok) { return resp; }
+
+            return this.$.restAPI.publishChangeEdit(this._changeNum);
+          }.bind(this));
+    },
+
+    _computeHideEditCommitMessage: function(loggedIn, editing, changeRecord,
+        patchNum) {
+      if (!changeRecord || !loggedIn || editing) { return true; }
+
+      patchNum = parseInt(patchNum, 10);
+      if (isNaN(patchNum)) { return true; }
+
+      var change = changeRecord.base;
+      if (!change.current_revision) { return true; }
+      if (change.revisions[change.current_revision]._number !== patchNum) {
+        return true;
+      }
+
+      return false;
+    },
+
+    _handleCommentSave: function(e) {
+      if (!e.target.comment.__draft) { return; }
+
+      var draft = e.target.comment;
+      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+      // The use of path-based notification helpers (set, push) can’t be used
+      // because the paths could contain dots in them. A new object must be
+      // created to satisfy Polymer’s dirty checking.
+      // https://github.com/Polymer/polymer/issues/3127
+      // TODO(andybons): Polyfill for Object.assign in IE.
+      var diffDrafts = Object.assign({}, this._diffDrafts);
+      if (!diffDrafts[draft.path]) {
+        diffDrafts[draft.path] = [draft];
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+        if (this._diffDrafts[draft.path][i].id === draft.id) {
+          diffDrafts[draft.path][i] = draft;
+          this._diffDrafts = diffDrafts;
+          return;
+        }
+      }
+      diffDrafts[draft.path].push(draft);
+      diffDrafts[draft.path].sort(function(c1, c2) {
+        // No line number means that it’s a file comment. Sort it above the
+        // others.
+        return (c1.line || -1) - (c2.line || -1);
+      });
+      this._diffDrafts = diffDrafts;
+    },
+
+    _handleCommentDiscard: function(e) {
+      if (!e.target.comment.__draft) { return; }
+
+      var draft = e.target.comment;
+      if (!this._diffDrafts[draft.path]) {
+        return;
+      }
+      var index = -1;
+      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+        if (this._diffDrafts[draft.path][i].id === draft.id) {
+          index = i;
+          break;
+        }
+      }
+      if (index === -1) {
+        // It may be a draft that hasn’t been added to _diffDrafts since it was
+        // never saved.
+        return;
+      }
+
+      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+      // The use of path-based notification helpers (set, push) can’t be used
+      // because the paths could contain dots in them. A new object must be
+      // created to satisfy Polymer’s dirty checking.
+      // https://github.com/Polymer/polymer/issues/3127
+      // TODO(andybons): Polyfill for Object.assign in IE.
+      var diffDrafts = Object.assign({}, this._diffDrafts);
+      diffDrafts[draft.path].splice(index, 1);
+      if (diffDrafts[draft.path].length === 0) {
+        delete diffDrafts[draft.path];
+      }
+      this._diffDrafts = diffDrafts;
+    },
+
+    _handlePatchChange: function(e) {
+      var patchNum = e.target.value;
+      var currentPatchNum;
+      if (this._change.current_revision) {
+        currentPatchNum =
+            this._change.revisions[this._change.current_revision]._number;
+      } else {
+        currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+      }
+      if (patchNum == currentPatchNum) {
+        page.show(this.changePath(this._changeNum));
+        return;
+      }
+      page.show(this.changePath(this._changeNum) + '/' + patchNum);
+    },
+
+    _handleReplyTap: function(e) {
+      e.preventDefault();
+      this._openReplyDialog();
+    },
+
+    _handleDownloadTap: function(e) {
+      e.preventDefault();
+      this.$.downloadOverlay.open();
+    },
+
+    _handleDownloadDialogClose: function(e) {
+      this.$.downloadOverlay.close();
+    },
+
+    _handleMessageReply: function(e) {
+      var msg = e.detail.message.message;
+      var quoteStr = msg.split('\n').map(
+          function(line) { return '> ' + line; }).join('\n') + '\n\n';
+      this.$.replyDialog.draft += quoteStr;
+      this._openReplyDialog();
+    },
+
+    _handleReplyOverlayOpen: function(e) {
+      this.$.replyDialog.focus();
+    },
+
+    _handleReplySent: function(e) {
+      this.$.replyOverlay.close();
+      this._reload();
+    },
+
+    _handleReplyCancel: function(e) {
+      this.$.replyOverlay.close();
+    },
+
+    _handleReplyAutogrow: function(e) {
+      this.$.replyOverlay.refit();
+    },
+
+    _handleShowReplyDialog: function(e) {
+      var target = this.$.replyDialog.FocusTarget.REVIEWERS;
+      if (e.detail.value && e.detail.value.ccsOnly) {
+        target = this.$.replyDialog.FocusTarget.CCS;
+      }
+      this._openReplyDialog(target);
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view !== this.tagName.toLowerCase()) { return; }
+
+      this._changeNum = value.changeNum;
+      this._patchRange = {
+        patchNum: value.patchNum,
+        basePatchNum: value.basePatchNum || 'PARENT',
+      };
+
+      this._reload().then(function() {
+        this.$.messageList.topMargin = this._headerEl.offsetHeight;
+        this.$.fileList.topMargin = this._headerEl.offsetHeight;
+
+        // Allow the message list to render before scrolling.
+        this.async(function() {
+          this._maybeScrollToMessage();
+        }.bind(this), 1);
+
+        this._maybeShowReplyDialog();
+
+        this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+          change: this._change,
+          patchNum: this._patchRange.patchNum,
+        });
+      }.bind(this));
+    },
+
+    _paramsAndChangeChanged: function(value) {
+      // If the change number or patch range is different, then reset the
+      // selected file index.
+      var patchRangeState = this.viewState.patchRange;
+      if (this.viewState.changeNum !== this._changeNum ||
+          patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+          patchRangeState.patchNum !== this._patchRange.patchNum) {
+        this._resetFileListViewState();
+      }
+    },
+
+    _maybeScrollToMessage: function() {
+      var msgPrefix = '#message-';
+      var hash = window.location.hash;
+      if (hash.indexOf(msgPrefix) === 0) {
+        this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length));
+      }
+    },
+
+    _maybeShowReplyDialog: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return; }
+
+        if (this.viewState.showReplyDialog) {
+          this._openReplyDialog();
+          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          this.set('viewState.showReplyDialog', false);
+        }
+      }.bind(this));
+    },
+
+    _resetFileListViewState: function() {
+      this.set('viewState.selectedFileIndex', 0);
+      this.set('viewState.changeNum', this._changeNum);
+      this.set('viewState.patchRange', this._patchRange);
+    },
+
+    _changeChanged: function(change) {
+      if (!change) { return; }
+      this.set('_patchRange.basePatchNum',
+          this._patchRange.basePatchNum || 'PARENT');
+      this.set('_patchRange.patchNum',
+          this._patchRange.patchNum ||
+              this._computeLatestPatchNum(this._allPatchSets));
+
+      var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+      this.fire('title-change', {title: title});
+    },
+
+    _computeChangePermalink: function(changeNum) {
+      return '/' + changeNum;
+    },
+
+    _computeChangeStatus: function(change, patchNum) {
+      var statusString;
+      if (change.status === this.ChangeStatus.NEW) {
+        var rev = this._getRevisionNumber(change, patchNum);
+        if (rev && rev.draft === true) {
+          statusString = 'Draft';
+        }
+      } else {
+        statusString = this.changeStatusString(change);
+      }
+      return statusString ? '(' + statusString + ')' : '';
+    },
+
+    _computeLatestPatchNum: function(allPatchSets) {
+      return allPatchSets[allPatchSets.length - 1];
+    },
+
+    _computeAllPatchSets: function(change) {
+      var patchNums = [];
+      for (var rev in change.revisions) {
+        patchNums.push(change.revisions[rev]._number);
+      }
+      return patchNums.sort(function(a, b) {
+        return a - b;
+      });
+    },
+
+    _getRevisionNumber: function(change, patchNum) {
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          return change.revisions[rev];
+        }
+      }
+    },
+
+    _computePatchIndexIsSelected: function(index, patchNum) {
+      return this._allPatchSets[index] == patchNum;
+    },
+
+    _computeLabelNames: function(labels) {
+      return Object.keys(labels).sort();
+    },
+
+    _computeLabelValues: function(labelName, labels) {
+      var result = [];
+      var t = labels[labelName];
+      if (!t) { return result; }
+      var approvals = t.all || [];
+      approvals.forEach(function(label) {
+        if (label.value && label.value != labels[labelName].default_value) {
+          var labelClassName;
+          var labelValPrefix = '';
+          if (label.value > 0) {
+            labelValPrefix = '+';
+            labelClassName = 'approved';
+          } else if (label.value < 0) {
+            labelClassName = 'notApproved';
+          }
+          result.push({
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          });
+        }
+      });
+      return result;
+    },
+
+    _computeReplyButtonHighlighted: function(changeRecord) {
+      var drafts = (changeRecord && changeRecord.base) || {};
+      return Object.keys(drafts).length > 0;
+    },
+
+    _computeReplyButtonLabel: function(changeRecord) {
+      var drafts = (changeRecord && changeRecord.base) || {};
+      var draftCount = Object.keys(drafts).reduce(function(count, file) {
+        return count + drafts[file].length;
+      }, 0);
+
+      var label = 'Reply';
+      if (draftCount > 0) {
+        label += ' (' + draftCount + ')';
+      }
+      return label;
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 65:  // 'a'
+          if (this._loggedIn && !e.shiftKey) {
+            e.preventDefault();
+            this._openReplyDialog();
+          }
+          break;
+        case 85:  // 'u'
+          e.preventDefault();
+          page.show('/');
+          break;
+      }
+    },
+
+    _labelsChanged: function(changeRecord) {
+      if (!changeRecord) { return; }
+      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
+        change: this._change,
+      });
+    },
+
+    _openReplyDialog: function(opt_section) {
+      this.$.replyOverlay.open().then(function() {
+        this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+        this.$.replyDialog.open(opt_section);
+      }.bind(this));
+    },
+
+    _handleReloadChange: function() {
+      page.show(this.changePath(this._changeNum));
+    },
+
+    _handleGetChangeDetailError: function(response) {
+      this.fire('page-error', {response: response});
+    },
+
+    _getDiffDrafts: function() {
+      return this.$.restAPI.getDiffDrafts(this._changeNum).then(
+          function(drafts) {
+            return this._diffDrafts = drafts;
+          }.bind(this));
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getProjectConfig: function() {
+      return this.$.restAPI.getProjectConfig(this._change.project).then(
+          function(config) {
+            this._projectConfig = config;
+          }.bind(this));
+    },
+
+    _getChangeDetail: function() {
+      return this.$.restAPI.getChangeDetail(this._changeNum,
+          this._handleGetChangeDetailError.bind(this)).then(
+              function(change) {
+                // Issue 4190: Coalesce missing topics to null.
+                if (!change.topic) { change.topic = null; }
+                if (!change.reviewer_updates) {
+                  change.reviewer_updates = null;
+                }
+                this._change = change;
+              }.bind(this));
+    },
+
+    _getComments: function() {
+      return this.$.restAPI.getDiffComments(this._changeNum).then(
+          function(comments) {
+            this._comments = comments;
+          }.bind(this));
+    },
+
+    _getCommitInfo: function() {
+      return this.$.restAPI.getChangeCommitInfo(
+          this._changeNum, this._patchRange.patchNum).then(
+              function(commitInfo) {
+                this._commitInfo = commitInfo;
+              }.bind(this));
+    },
+
+    _reloadDiffDrafts: function() {
+      this._diffDrafts = {};
+      this._getDiffDrafts().then(function() {
+        if (this.$.replyOverlay.opened) {
+          this.async(function() { this.$.replyOverlay.center(); }, 1);
+        }
+      }.bind(this));
+    },
+
+    _reload: function() {
+      this._loading = true;
+
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return; }
+
+        this._reloadDiffDrafts();
+      }.bind(this));
+
+      var detailCompletes = this._getChangeDetail().then(function() {
+        this._loading = false;
+      }.bind(this));
+      this._getComments();
+
+      var reloadPatchNumDependentResources = function() {
+        return Promise.all([
+          this._getCommitInfo(),
+          this.$.actions.reload(),
+          this.$.fileList.reload(),
+        ]);
+      }.bind(this);
+      var reloadDetailDependentResources = function() {
+        if (!this._change) { return Promise.resolve(); }
+
+        return Promise.all([
+          this.$.relatedChanges.reload(),
+          this._getProjectConfig(),
+        ]);
+      }.bind(this);
+
+      this._resetHeaderEl();
+
+      if (this._patchRange.patchNum) {
+        return reloadPatchNumDependentResources().then(function() {
+          return detailCompletes;
+        }).then(reloadDetailDependentResources);
+      } else {
+        // The patch number is reliant on the change detail request.
+        return detailCompletes.then(reloadPatchNumDependentResources).then(
+            reloadDetailDependentResources);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
new file mode 100644
index 0000000..c9a687b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -0,0 +1,335 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-view></gr-change-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-view tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(null); },
+      });
+      element = fixture('basic');
+    });
+
+    test('keyboard shortcuts', function() {
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+      assert(showStub.lastCall.calledWithExactly('/'),
+          'Should navigate to /');
+      showStub.restore();
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+      var overlayEl = element.$.replyOverlay;
+      assert.isFalse(overlayEl.opened);
+      element._loggedIn = true;
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+      assert.isFalse(overlayEl.opened);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+      assert.isTrue(overlayEl.opened);
+      overlayEl.close();
+      assert.isFalse(overlayEl.opened);
+    });
+
+    test('reply button is highlighted when there are drafts', function() {
+      var replyButton = element.$$('gr-button.reply');
+      assert.ok(replyButton);
+      assert.isFalse(replyButton.hasAttribute('primary'));
+
+      element._diffDrafts = null;
+      assert.isFalse(replyButton.hasAttribute('primary'));
+
+      element._diffDrafts = {};
+      assert.isFalse(replyButton.hasAttribute('primary'));
+
+      element._diffDrafts = {
+        'file1.txt': [{}],
+        'file2.txt': [{}, {}],
+      };
+      assert.isTrue(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply (3)');
+    });
+
+    test('comment events properly update diff drafts', function() {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      var draft = {
+        __draft: true,
+        id: 'id1',
+        path: '/foo/bar.txt',
+        text: 'hello',
+      };
+      element._handleCommentSave({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+      draft.patch_set = null;
+      draft.text = 'hello, there';
+      element._handleCommentSave({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+      var draft2 = {
+        __draft: true,
+        id: 'id2',
+        path: '/foo/bar.txt',
+        text: 'hola',
+      };
+      element._handleCommentSave({target: {comment: draft2}});
+      draft2.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+      draft.patch_set = null;
+      element._handleCommentDiscard({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+      element._handleCommentDiscard({target: {comment: draft2}});
+      assert.deepEqual(element._diffDrafts, {});
+    });
+
+    test('patch num change', function(done) {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {},
+      };
+      flushAsynchronousOperations();
+      var selectEl = element.$$('.header select');
+      assert.ok(selectEl);
+      var optionEls =
+          Polymer.dom(element.root).querySelectorAll('.header option');
+      assert.equal(optionEls.length, 4);
+      assert.isFalse(
+          element.$$('.header option[value="1"]').hasAttribute('selected'));
+      assert.isTrue(
+          element.$$('.header option[value="2"]').hasAttribute('selected'));
+      assert.isFalse(
+          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.equal(optionEls[3].value, 13);
+
+      var showStub = sinon.stub(page, 'show');
+
+      var numEvents = 0;
+      selectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+              'Should navigate to /c/42/1');
+          selectEl.value = '3';
+          element.fire('change', {}, {node: selectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly('/c/42'),
+              'Should navigate to /c/42');
+          showStub.restore();
+          done();
+        }
+      });
+      selectEl.value = '1';
+      element.fire('change', {}, {node: selectEl});
+    });
+
+    test('patch num change with missing current_revision', function(done) {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+      flushAsynchronousOperations();
+      var selectEl = element.$$('.header select');
+      assert.ok(selectEl);
+      var optionEls =
+          Polymer.dom(element.root).querySelectorAll('.header option');
+      assert.equal(optionEls.length, 4);
+      assert.isFalse(
+          element.$$('.header option[value="1"]').hasAttribute('selected'));
+      assert.isTrue(
+          element.$$('.header option[value="2"]').hasAttribute('selected'));
+      assert.isFalse(
+          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.equal(optionEls[3].value, 13);
+
+      var showStub = sinon.stub(page, 'show');
+
+      var numEvents = 0;
+      selectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+              'Should navigate to /c/42/1');
+          selectEl.value = '3';
+          element.fire('change', {}, {node: selectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/3'),
+              'Should navigate to /c/42/3');
+          showStub.restore();
+          done();
+        }
+      });
+      selectEl.value = '1';
+      element.fire('change', {}, {node: selectEl});
+    });
+
+    test('change status new', function() {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, '');
+    });
+
+    test('change status draft', function() {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'DRAFT',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, '(Draft)');
+    });
+
+    test('revision status draft', function() {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {
+            _number: 2,
+            draft: true,
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '2');
+      assert.equal(status, '(Draft)');
+    });
+
+    test('show commit message edit button', function() {
+      var changeRecord = {
+        base: {
+          revisions: {
+            rev1: {_number: 1},
+            rev2: {_number: 2},
+          },
+          current_revision: 'rev2',
+        },
+      };
+      assert.isTrue(element._computeHideEditCommitMessage(
+          false, false, changeRecord, '2'));
+      assert.isTrue(element._computeHideEditCommitMessage(
+          true, true, changeRecord, '2'));
+      assert.isTrue(element._computeHideEditCommitMessage(
+          true, false, changeRecord, '1'));
+      assert.isFalse(element._computeHideEditCommitMessage(
+          true, false, changeRecord, '2'));
+    });
+
+    test('topic is coalesced to null', function() {
+      sinon.stub(element, '_changeChanged');
+      sinon.stub(element.$.restAPI, 'getChangeDetail', function(num) {
+        return Promise.resolve({id: '123456789', labels: {}});
+      });
+
+      element._getChangeDetail().then(function() {
+        assert.isNull(element._change.topic);
+      });
+    });
+
+    test('reply dialog focus can be controlled', function() {
+      var FocusTarget = element.$.replyDialog.FocusTarget;
+      var openSpy = sinon.spy(element, '_openReplyDialog');
+
+      var e = {detail: {}};
+      element._handleShowReplyDialog(e);
+      assert(openSpy.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+          '_openReplyDialog should have been passed REVIEWERS');
+
+      e.detail.value = {ccsOnly: true};
+      element._handleShowReplyDialog(e);
+      assert(openSpy.lastCall.calledWithExactly(FocusTarget.CCS),
+          '_openReplyDialog should have been passed CCS');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
new file mode 100644
index 0000000..a7d99a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -0,0 +1,69 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-comment-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+        font-family: var(--monospace-font-family);
+      }
+      .file {
+        border-top: 1px solid #ddd;
+        font-weight: bold;
+        margin: 10px 0 3px;
+        padding: 10px 0 5px;
+      }
+      .container {
+        display: flex;
+        margin: 5px 0;
+      }
+      .lineNum {
+        margin-right: .35em;
+        min-width: 7em;
+      }
+      .message {
+        flex: 1;
+        white-space: pre-wrap;
+        word-wrap: break-word;
+      }
+    </style>
+    <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
+      <div class="file">
+        <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>:
+      </div>
+      <template is="dom-repeat"
+                items="[[_computeCommentsForFile(comments, file)]]" as="comment">
+        <div class="container">
+          <a class="lineNum"
+             href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
+             <span hidden$="[[!comment.line]]">
+               <span>[[_computePatchDisplayName(comment)]]</span>
+               Line <span>[[comment.line]]</span>:
+             </span>
+             <span hidden$="[[comment.line]]">
+               File comment:
+             </span>
+          </a>
+          <div class="message">[[comment.message]]</div>
+        </div>
+      </template>
+    </template>
+  </template>
+  <script src="gr-comment-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
new file mode 100644
index 0000000..eaafc447
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -0,0 +1,60 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-comment-list',
+
+    properties: {
+      changeNum: Number,
+      comments: Object,
+      patchNum: Number,
+    },
+
+    _computeFilesFromComments: function(comments) {
+      return Object.keys(comments || {}).sort();
+    },
+
+    _computeFileDiffURL: function(file, changeNum, patchNum) {
+      return '/c/' + changeNum + '/' + patchNum + '/' + file;
+    },
+
+    _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
+      var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
+      if (comment.line) {
+        diffURL += '#';
+        if (comment.side === 'PARENT') { diffURL += 'b'; }
+        diffURL += comment.line;
+      }
+      return diffURL;
+    },
+
+    _computeCommentsForFile: function(comments, file) {
+      // Changes are not picked up by the dom-repeat due to the array instance
+      // identity not changing even when it has elements added/removed from it.
+      return (comments[file] || []).slice();
+    },
+
+    _computePatchDisplayName: function(comment) {
+      if (comment.side == 'PARENT') {
+        return 'Base, ';
+      }
+      if (comment.patch_set != this.patchNum) {
+        return 'PS' + comment.patch_set + ', ';
+      }
+      return '';
+    }
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
new file mode 100644
index 0000000..56a927b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="gr-comment-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment-list></gr-comment-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-comment-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('_computeFilesFromComments', function() {
+      var comments = {'file_b.html': [], 'file_c.css': [], 'file_a.js': []};
+      var expected = ['file_a.js', 'file_b.html', 'file_c.css'];
+      var actual = element._computeFilesFromComments(comments);
+      assert.deepEqual(actual, expected);
+
+      assert.deepEqual(element._computeFilesFromComments(null), []);
+    });
+
+    test('_computeFileDiffURL', function() {
+      var expected = '/c/<change>/<patch>/<file>';
+      var actual = element._computeFileDiffURL('<file>', '<change>', '<patch>');
+      assert.equal(actual, expected);
+    });
+
+    test('_computeDiffLineURL', function() {
+      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var expected = '/c/<change>/<patch>/<file>#123';
+      var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
+          comment);
+      assert.equal(actual, expected);
+
+      comment.line = 321;
+      comment.side = 'PARENT';
+
+      expected = '/c/<change>/<patch>/<file>#b321';
+      actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
+          comment);
+    });
+
+    test('_computePatchDisplayName', function() {
+      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+
+      element.patchNum = 10;
+      assert.equal(element._computePatchDisplayName(comment), '');
+
+      element.patchNum = 9;
+      assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
+
+      comment.side = 'PARENT';
+      assert.equal(element._computePatchDisplayName(comment), 'Base, ');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
new file mode 100644
index 0000000..7366d74
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -0,0 +1,63 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-abandon-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid #ddd;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Abandon"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Abandon Change</div>
+      <div class="main">
+        <label for="messageInput">Abandon Message</label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            placeholder="<Insert reasoning here>"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-abandon-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
new file mode 100644
index 0000000..0ce1cbb
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -0,0 +1,46 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-abandon-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      message: String,
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', {reason: this.message}, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
new file mode 100644
index 0000000..b21575b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -0,0 +1,74 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-cherrypick-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      iron-autogrow-textarea {
+        padding: 0;
+      }
+      .main label,
+      .main input[type="text"] {
+        display: block;
+        font: inherit;
+        width: 100%;
+      }
+      .main .message {
+        border: groove;
+        width: 100%;
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Cherry Pick"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Cherry Pick Change to Another Branch</div>
+      <div class="main">
+        <label for="branchInput">
+          Cherry Pick to branch
+        </label>
+        <input is="iron-input"
+            type="text"
+            id="branchInput"
+            bind-value="{{branch}}"
+            placeholder="Destination branch">
+        <label for="messageInput">
+          Cherry Pick Commit Message
+        </label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-cherrypick-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
new file mode 100644
index 0000000..97342d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-cherrypick-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      branch: String,
+      message: String,
+      commitInfo: {
+        type: Object,
+        readOnly: true,
+        observer: '_commitInfoChanged',
+      },
+    },
+
+    _commitInfoChanged: function(commitInfo) {
+      // Pre-populate cherry-pick message for editing from commit info.
+      this.message = commitInfo.message;
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
new file mode 100644
index 0000000..3896ffa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -0,0 +1,75 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-rebase-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .parentRevisionContainer label,
+      .parentRevisionContainer input[type="text"] {
+        display: block;
+        font: inherit;
+        width: 100%;
+      }
+      .parentRevisionContainer label {
+        margin-bottom: .2em;
+      }
+      .clearParentContainer {
+        margin: .5em 0;
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Rebase"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Confirm rebase</div>
+      <div class="main">
+        <div class="parentRevisionContainer">
+          <label for="parentInput">
+            Parent revision (optional)
+          </label>
+          <input is="iron-input"
+              type="text"
+              id="parentInput"
+              bind-value="{{base}}"
+              placeholder="Change number">
+        </div>
+        <div class="clearParentContainer">
+          <input id="clearParent"
+              type="checkbox"
+              on-tap="_handleClearParentTap">
+          <label for="clearParent">
+            Rebase on top of current branch (clear parent revision).
+          </label>
+        </div>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-rebase-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
new file mode 100644
index 0000000..42f2167
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -0,0 +1,56 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-rebase-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      base: String,
+      clearParent: Boolean,
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _handleClearParentTap: function(e) {
+      var clear = Polymer.dom(e).rootTarget.checked;
+      if (clear) {
+        this.base = '';
+      }
+      this.$.parentInput.disabled = clear;
+      this.clearParent = clear;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
new file mode 100644
index 0000000..c02e11e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-rebase-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-rebase-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-rebase-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('controls', function() {
+      assert.isFalse(element.$.parentInput.hasAttribute('disabled'));
+      assert.isFalse(element.$.clearParent.checked);
+      element.base = 'something great';
+      MockInteractions.tap(element.$.clearParent);
+      assert.isTrue(element.$.parentInput.hasAttribute('disabled'));
+      assert.isTrue(element.$.clearParent.checked);
+      assert.equal(element.base, '');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
new file mode 100644
index 0000000..979a06a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -0,0 +1,64 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-revert-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid #ddd;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Revert"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Revert Merged Change</div>
+      <div class="main">
+        <label for="messageInput">
+          Revert Commit Message
+        </label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-revert-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
new file mode 100644
index 0000000..b4baa26
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -0,0 +1,67 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-revert-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      branch: String,
+      message: String,
+      commitInfo: Object,
+    },
+
+    populateRevertMessage: function() {
+      // Figure out what the revert title should be.
+      var originalTitle = this.commitInfo.message.split('\n')[0];
+      var revertTitle = 'Revert of ' + originalTitle;
+      if (originalTitle.startsWith('Revert of ')) {
+        revertTitle = 'Reland of ' +
+                      originalTitle.substring('Revert of '.length);
+      } else if (originalTitle.startsWith('Reland of ')) {
+        revertTitle = 'Revert of ' +
+                      originalTitle.substring('Reland of '.length);
+      }
+      // Add '> ' in front of the original commit text.
+      var originalCommitText = this.commitInfo.message.replace(/^/gm, '> ');
+
+      this.message = revertTitle + '\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
+                     'Original issue\'s description:\n' + originalCommitText;
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
new file mode 100644
index 0000000..1d53eef
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-revert-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-revert-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-revert-dialog></gr-confirm-revert-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-revert-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('single line', function() {
+      assert.isNotOk(element.message);
+      element.commitInfo = {message: 'one line commit'};
+      assert.isNotOk(element.message);
+      element.populateRevertMessage();
+      var expected = 'Revert of one line commit\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
+                     'Original issue\'s description:\n' +
+                     '> one line commit';
+      assert.equal(element.message, expected);
+    });
+
+    test('multi line', function() {
+      assert.isNotOk(element.message);
+      element.commitInfo = {message: 'many lines\ncommit\n\nmessage\n'};
+      assert.isNotOk(element.message);
+      element.populateRevertMessage();
+      var expected = 'Revert of many lines\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
+                     'Original issue\'s description:\n' +
+                     '> many lines\n> commit\n> \n> message\n> ';
+      assert.equal(element.message, expected);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
new file mode 100644
index 0000000..b1e5c01
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -0,0 +1,146 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-download-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        padding: 1em;
+      }
+      ul {
+        list-style: none;
+        margin-bottom: .5em;
+      }
+      li {
+        display: inline-block;
+        margin: 0;
+        padding: 0;
+      }
+      li gr-button {
+        margin-right: 1em;
+      }
+      label,
+      input {
+        display: block;
+      }
+      label {
+        font-weight: bold;
+      }
+      input {
+        font-family: var(--monospace-font-family);
+        font-size: inherit;
+        margin-bottom: .5em;
+        width: 60em;
+      }
+      li[selected] gr-button {
+        color: #000;
+        font-weight: bold;
+        text-decoration: none;
+      }
+      header {
+        display: flex;
+        justify-content: space-between;
+      }
+      main {
+        border-bottom: 1px solid #ddd;
+        border-top: 1px solid #ddd;
+        padding: .5em;
+      }
+      footer {
+        display: flex;
+        justify-content: space-between;
+        padding-top: .75em;
+      }
+      .closeButtonContainer {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .patchFiles {
+        margin-right: 2em;
+      }
+      .patchFiles a,
+      .archives a {
+        display: inline-block;
+        margin-right: 1em;
+      }
+      .patchFiles a:last-of-type,
+      .archives a:last-of-type {
+        margin-right: 0;
+      }
+    </style>
+    <header>
+      <ul hidden$="[[!_schemes.length]]" hidden>
+        <template is="dom-repeat" items="[[_schemes]]" as="scheme">
+          <li selected$="[[_computeSchemeSelected(scheme, _selectedScheme)]]">
+            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
+              [[scheme]]
+            </gr-button>
+          </li>
+        </template>
+      </ul>
+      <span class="closeButtonContainer">
+        <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+      </span>
+    </header>
+    <main hidden$="[[!_schemes.length]]" hidden>
+      <template is="dom-repeat"
+          items="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
+          as="command">
+        <div class="command">
+          <label>[[command.title]]</label>
+          <input is="iron-input"
+              type="text"
+              bind-value="[[command.command]]"
+              on-tap="_handleInputTap"
+              readonly>
+        </div>
+      </template>
+    </main>
+    <footer>
+      <div class="patchFiles">
+        <label>Patch file</label>
+        <div>
+          <a href$="[[_computeDownloadLink(change, patchNum)]]">
+            [[_computeDownloadFilename(change, patchNum)]]
+          </a>
+          <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
+            [[_computeZipDownloadFilename(change, patchNum)]]
+          </a>
+        </div>
+      </div>
+      <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
+        <label>Archive</label>
+        <div class="archives">
+          <template is="dom-repeat" items="[[config.archives]]" as="format">
+            <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
+              [[format]]
+            </a>
+          </template>
+        </div>
+      </div>
+    </footer>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-download-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
new file mode 100644
index 0000000..2f3e8e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -0,0 +1,151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-download-dialog',
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      change: Object,
+      patchNum: String,
+      config: Object,
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      _schemes: {
+        type: Array,
+        value: function() { return []; },
+        computed: '_computeSchemes(change, patchNum)',
+        observer: '_schemesChanged',
+      },
+      _selectedScheme: String,
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    attached: function() {
+      if (!this.loggedIn) { return; }
+      this.$.restAPI.getPreferences().then(function(prefs) {
+        if (prefs.download_scheme) {
+          this._selectedScheme = prefs.download_scheme;
+        }
+      }.bind(this));
+    },
+
+    _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
+      var commandObj;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          commandObj = change.revisions[rev].fetch[_selectedScheme].commands;
+          break;
+        }
+      }
+      var commands = [];
+      for (var title in commandObj) {
+        commands.push({
+          title: title,
+          command: commandObj[title],
+        });
+      }
+      return commands;
+    },
+
+    _computeZipDownloadLink: function(change, patchNum) {
+      return this._computeDownloadLink(change, patchNum, true);
+    },
+
+    _computeZipDownloadFilename: function(change, patchNum) {
+      return this._computeDownloadFilename(change, patchNum, true);
+    },
+
+    _computeDownloadLink: function(change, patchNum, zip) {
+      return this.changeBaseURL(change._number, patchNum) + '/patch?' +
+          (zip ? 'zip' : 'download');
+    },
+
+    _computeDownloadFilename: function(change, patchNum, zip) {
+      var shortRev;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          shortRev = rev.substr(0, 7);
+          break;
+        }
+      }
+      return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+    },
+
+    _computeArchiveDownloadLink: function(change, patchNum, format) {
+      return this.changeBaseURL(change._number, patchNum) +
+          '/archive?format=' + format;
+    },
+
+    _computeSchemes: function(change, patchNum) {
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          var fetch = change.revisions[rev].fetch;
+          if (fetch) {
+            return Object.keys(fetch).sort();
+          }
+          break;
+        }
+      }
+      return [];
+    },
+
+    _computeSchemeSelected: function(scheme, selectedScheme) {
+      return scheme == selectedScheme;
+    },
+
+    _handleSchemeTap: function(e) {
+      e.preventDefault();
+      var el = Polymer.dom(e).rootTarget;
+      this._selectedScheme = el.getAttribute('data-scheme');
+      if (this.loggedIn) {
+        this.$.restAPI.savePreferences({download_scheme: this._selectedScheme});
+      }
+    },
+
+    _handleInputTap: function(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.select();
+    },
+
+    _handleCloseTap: function(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+
+    _schemesChanged: function(schemes) {
+      if (schemes.length == 0) { return; }
+      if (schemes.indexOf(this._selectedScheme) == -1) {
+        this._selectedScheme = schemes.sort()[0];
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
new file mode 100644
index 0000000..70e934d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -0,0 +1,206 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-download-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-download-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-download-dialog></gr-download-dialog>
+  </template>
+</test-fixture>
+
+<test-fixture id="loggedIn">
+  <template>
+    <gr-download-dialog logged-in></gr-download-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  function getChangeObject() {
+    return {
+      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+      revisions: {
+        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+          _number: 1,
+          fetch: {
+            repo: {
+              commands: {
+                repo: 'repo download test-project 5/1'
+              }
+            },
+            ssh: {
+              commands: {
+                'Checkout':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                'Cherry Pick':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                'Format Patch':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 ' +
+                  '&& git format-patch -1 --stdout FETCH_HEAD',
+                'Pull':
+                  'git pull ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1'
+              }
+            },
+            http: {
+              commands: {
+                'Checkout':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                'Cherry Pick':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                'Format Patch':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && ' +
+                  'git format-patch -1 --stdout FETCH_HEAD',
+                'Pull':
+                  'git pull ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1'
+              }
+            }
+          }
+        }
+      }
+    };
+  }
+
+  suite('gr-download-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.change = getChangeObject();
+      element.patchNum = 1;
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          http: {},
+          repo: {},
+          ssh: {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+    });
+
+    test('element visibility', function() {
+      assert.isFalse(element.$$('ul').hasAttribute('hidden'));
+      assert.isFalse(element.$$('main').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.archivesContainer').hasAttribute('hidden'));
+
+      element.set('config.archives', []);
+      assert.isTrue(element.$$('.archivesContainer').hasAttribute('hidden'));
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeArchiveDownloadLink(
+          {_number: 123}, 2, 'tgz'),
+          '/changes/123/revisions/2/archive?format=tgz');
+    });
+
+    test('close event', function(done) {
+      element.addEventListener('close', function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
+    });
+
+    test('tab selection', function() {
+      flushAsynchronousOperations();
+      var el = element.$$('[data-scheme="http"]').parentElement;
+      assert.isTrue(el.hasAttribute('selected'));
+      ['repo', 'ssh'].forEach(function(scheme) {
+        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+        assert.isFalse(el.hasAttribute('selected'));
+      });
+
+      MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
+      el = element.$$('[data-scheme="ssh"]').parentElement;
+      assert.isTrue(el.hasAttribute('selected'));
+      ['http', 'repo'].forEach(function(scheme) {
+        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+        assert.isFalse(el.hasAttribute('selected'));
+      });
+    });
+
+  });
+
+  suite('gr-download-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getPreferences: function() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
+      });
+
+      element = fixture('loggedIn');
+      element.change = getChangeObject();
+      element.patchNum = 1;
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          http: {},
+          repo: {},
+          ssh: {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+    });
+
+    test('loads scheme from preferences', function(done) {
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+        assert.equal(element._selectedScheme, 'repo');
+        done();
+      });
+    });
+
+    test('saves scheme to preferences', function() {
+      var savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+          function() { return Promise.resolve(); });
+
+      Polymer.dom.flush();
+
+      var firstSchemeButton = element.$$('li gr-button[data-scheme]');
+
+      MockInteractions.tap(firstSchemeButton);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+          firstSchemeButton.getAttribute('data-scheme'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
new file mode 100644
index 0000000..ef5ceed
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -0,0 +1,189 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../diff/gr-diff/gr-diff.html">
+<link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-file-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .row {
+        display: flex;
+        padding: .1em .25em;
+      }
+      header {
+        display: flex;
+        font-weight: bold;
+        justify-content: space-between;
+        margin-bottom: .5em;
+      }
+      .rightControls {
+        font-weight: normal;
+      }
+      .reviewed,
+      .status {
+        align-items: center;
+        display: inline-flex;
+      }
+      .reviewed,
+      .status {
+        display: inline-block;
+        text-align: center;
+        width: 1.5em;
+      }
+      .row:not(.header):hover {
+        background-color: #f5fafd;
+      }
+      .row[selected] {
+        background-color: #ebf5fb;
+      }
+      .path {
+        flex: 1;
+        padding-left: .35em;
+        text-decoration: none;
+        white-space: nowrap;
+      }
+      .path:hover :first-child {
+        text-decoration: underline;
+      }
+      .path,
+      .path div {
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .oldPath {
+        color: #999;
+      }
+      .comments,
+      .stats {
+        text-align: right;
+      }
+      .comments {
+        min-width: 10em;
+      }
+      .stats {
+        min-width: 7em;
+      }
+      .invisible {
+        visibility: hidden;
+      }
+      .row:not(.header) .stats {
+        font-family: var(--monospace-font-family);
+      }
+      .added {
+        color: #388E3C;
+      }
+      .removed {
+        color: #D32F2F;
+      }
+      .drafts {
+        color: #C62828;
+        font-weight: bold;
+      }
+      gr-diff {
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        display: block;
+        margin: .25em 0 1em;
+      }
+      @media screen and (max-width: 50em) {
+        .row[selected] {
+          background-color: transparent;
+        }
+        .stats {
+          display: none;
+        }
+        .reviewed,
+        .status {
+          justify-content: flex-start;
+        }
+        .comments {
+          min-width: initial;
+        }
+      }
+    </style>
+    <header>
+      <div>Files</div>
+      <div class="rightControls">
+        <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
+        /
+        <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+        /
+        <label>
+          Diff against
+          <select on-change="_handlePatchChange">
+            <option value="PARENT">Base</option>
+            <template is="dom-repeat" items="[[_computePatchSets(revisions, patchRange.*)]]" as="patchNum">
+              <option
+                  value$="[[patchNum]]"
+                  selected$="[[_computePatchSetSelected(patchNum, patchRange.basePatchNum)]]"
+                  disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">[[patchNum]]</option>
+            </template>
+          </select>
+        </label>
+      </div>
+    </header>
+    <template is="dom-repeat" items="[[_files]]" as="file">
+      <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
+        <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
+          <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
+              data-path$="[[file.__path]]" on-change="_handleReviewedChange">
+        </div>
+        <div class$="[[_computeClass('status', file.__path)]]">
+          [[_computeFileStatus(file.status)]]
+        </div>
+        <a class="path" href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
+          <div title$="[[_computeFileDisplayName(file.__path)]]">
+            [[_computeFileDisplayName(file.__path)]]
+          </div>
+          <div class="oldPath" hidden$="[[!file.old_path]]" hidden
+              title$="[[file.old_path]]">
+            [[file.old_path]]
+          </div>
+        </a>
+        <div class="comments">
+          <span class="drafts">[[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]</span>
+          [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
+        </div>
+        <div class$="[[_computeClass('stats', file.__path)]]">
+          <span class="added">+[[file.lines_inserted]]</span>
+          <span class="removed">-[[file.lines_deleted]]</span>
+        </div>
+      </div>
+      <gr-diff hidden
+          project="[[change.project]]"
+          commit="[[change.current_revision]]"
+          change-num="[[changeNum]]"
+          patch-range="[[patchRange]]"
+          path="[[file.__path]]"
+          prefs="[[_diffPrefs]]"
+          project-config="[[projectConfig]]"
+          view-mode="[[_userPrefs.diff_view]]"></gr-diff>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+    <gr-diff-cursor
+        id="cursor"
+        fold-offset-top="[[topMargin]]"></gr-diff-cursor>
+  </template>
+  <script src="gr-file-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
new file mode 100644
index 0000000..225d8b3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -0,0 +1,408 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+
+  Polymer({
+    is: 'gr-file-list',
+
+    properties: {
+      patchRange: Object,
+      patchNum: String,
+      changeNum: String,
+      comments: Object,
+      drafts: Object,
+      revisions: Object,
+      projectConfig: Object,
+      topMargin: Number,
+      selectedIndex: {
+        type: Number,
+        notify: true,
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      change: Object,
+
+      _files: {
+        type: Array,
+        observer: '_filesChanged',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _reviewed: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _diffPrefs: Object,
+      _userPrefs: Object,
+      _localPrefs: Object,
+      _showInlineDiffs: Boolean,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    reload: function() {
+      if (!this.changeNum || !this.patchRange.patchNum) {
+        return Promise.resolve();
+      }
+
+      this._collapseAllDiffs();
+
+      var promises = [];
+      var _this = this;
+
+      promises.push(this._getFiles().then(function(files) {
+        _this._files = files;
+      }));
+      promises.push(this._getLoggedIn().then(function(loggedIn) {
+        return _this._loggedIn = loggedIn;
+      }).then(function(loggedIn) {
+        if (!loggedIn) { return; }
+
+        return _this._getReviewedFiles().then(function(reviewed) {
+          _this._reviewed = reviewed;
+        });
+      }));
+
+      this._localPrefs = this.$.storage.getPreferences();
+      promises.push(this._getDiffPreferences().then(function(prefs) {
+        this._diffPrefs = prefs;
+      }.bind(this)));
+
+      promises.push(this._getPreferences().then(function(prefs) {
+        this._userPrefs = prefs;
+      }.bind(this)));
+    },
+
+    get diffs() {
+      return Polymer.dom(this.root).querySelectorAll('gr-diff');
+    },
+
+    _getDiffPreferences: function() {
+      return this.$.restAPI.getDiffPreferences();
+    },
+
+    _getPreferences: function() {
+      return this.$.restAPI.getPreferences();
+    },
+
+    _computePatchSets: function(revisions) {
+      var patchNums = [];
+      for (var commit in revisions) {
+        patchNums.push(revisions[commit]._number);
+      }
+      return patchNums.sort(function(a, b) { return a - b; });
+    },
+
+    _computePatchSetDisabled: function(patchNum, currentPatchNum) {
+      return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
+    },
+
+    _computePatchSetSelected: function(patchNum, basePatchNum) {
+      return parseInt(patchNum, 10) === parseInt(basePatchNum, 10);
+    },
+
+    _handlePatchChange: function(e) {
+      this.set('patchRange.basePatchNum', Polymer.dom(e).rootTarget.value);
+      page.show('/c/' + encodeURIComponent(this.changeNum) + '/' +
+          encodeURIComponent(this._patchRangeStr(this.patchRange)));
+    },
+
+    _forEachDiff: function(fn) {
+      var diffs = this.diffs;
+      for (var i = 0; i < diffs.length; i++) {
+        fn(diffs[i]);
+      }
+    },
+
+    _expandAllDiffs: function(e) {
+      this._showInlineDiffs = true;
+      this._forEachDiff(function(diff) {
+        diff.hidden = false;
+        diff.reload();
+      });
+      if (e && e.target) {
+        e.target.blur();
+      }
+    },
+
+    _collapseAllDiffs: function(e) {
+      this._showInlineDiffs = false;
+      this._forEachDiff(function(diff) {
+        diff.hidden = true;
+      });
+      this.$.cursor.handleDiffUpdate();
+      if (e && e.target) {
+        e.target.blur();
+      }
+    },
+
+    _computeCommentsString: function(comments, patchNum, path) {
+      return this._computeCountString(comments, patchNum, path, 'comment');
+    },
+
+    _computeDraftsString: function(drafts, patchNum, path) {
+      return this._computeCountString(drafts, patchNum, path, 'draft');
+    },
+
+    _computeCountString: function(comments, patchNum, path, noun) {
+      if (!comments) { return ''; }
+
+      var patchComments = (comments[path] || []).filter(function(c) {
+        return parseInt(c.patch_set, 10) === parseInt(patchNum, 10);
+      });
+      var num = patchComments.length;
+      if (num === 0) { return ''; }
+      return num + ' ' + noun + (num > 1 ? 's' : '');
+    },
+
+    _computeReviewed: function(file, _reviewed) {
+      return _reviewed.indexOf(file.__path) !== -1;
+    },
+
+    _handleReviewedChange: function(e) {
+      var path = Polymer.dom(e).rootTarget.getAttribute('data-path');
+      var index = this._reviewed.indexOf(path);
+      var reviewed = index !== -1;
+      if (reviewed) {
+        this.splice('_reviewed', index, 1);
+      } else {
+        this.push('_reviewed', path);
+      }
+
+      this._saveReviewedState(path, !reviewed).catch(function(err) {
+        alert('Couldn’t change file review status. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _saveReviewedState: function(path, reviewed) {
+      return this.$.restAPI.saveFileReviewed(this.changeNum,
+          this.patchRange.patchNum, path, reviewed);
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getReviewedFiles: function() {
+      return this.$.restAPI.getReviewedFiles(this.changeNum,
+          this.patchRange.patchNum);
+    },
+
+    _getFiles: function() {
+      return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
+          this.changeNum, this.patchRange);
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 37: // left
+          if (e.shiftKey && this._showInlineDiffs) {
+            e.preventDefault();
+            this.$.cursor.moveLeft();
+          }
+          break;
+        case 39: // right
+          if (e.shiftKey && this._showInlineDiffs) {
+            e.preventDefault();
+            this.$.cursor.moveRight();
+          }
+          break;
+        case 73:  // 'i'
+          if (!e.shiftKey) { return; }
+          e.preventDefault();
+          this._toggleInlineDiffs();
+          break;
+        case 40:  // down
+        case 74:  // 'j'
+          e.preventDefault();
+          if (this._showInlineDiffs) {
+            this.$.cursor.moveDown();
+          } else {
+            this.selectedIndex =
+                Math.min(this._files.length - 1, this.selectedIndex + 1);
+            this._scrollToSelectedFile();
+          }
+          break;
+        case 38:  // up
+        case 75:  // 'k'
+          e.preventDefault();
+          if (this._showInlineDiffs) {
+            this.$.cursor.moveUp();
+          } else {
+            this.selectedIndex = Math.max(0, this.selectedIndex - 1);
+            this._scrollToSelectedFile();
+          }
+          break;
+        case 67: // 'c'
+          var isRangeSelected = this.diffs.some(function(diff) {
+            return diff.isRangeSelected();
+          }, this);
+          if (this._showInlineDiffs && !isRangeSelected) {
+            e.preventDefault();
+            this._addDraftAtTarget();
+          }
+          break;
+        case 219:  // '['
+          e.preventDefault();
+          this._openSelectedFile(this._files.length - 1);
+          break;
+        case 221:  // ']'
+          e.preventDefault();
+          this._openSelectedFile(0);
+          break;
+        case 13:  // <enter>
+        case 79:  // 'o'
+          e.preventDefault();
+          if (this._showInlineDiffs) {
+            this._openCursorFile();
+          } else {
+            this._openSelectedFile();
+          }
+          break;
+        case 78:  // 'n'
+          if (this._showInlineDiffs) {
+            e.preventDefault();
+            if (e.shiftKey) {
+              this.$.cursor.moveToNextCommentThread();
+            } else {
+              this.$.cursor.moveToNextChunk();
+            }
+          }
+          break;
+        case 80:  // 'p'
+          if (this._showInlineDiffs) {
+            e.preventDefault();
+            if (e.shiftKey) {
+              this.$.cursor.moveToPreviousCommentThread();
+            } else {
+              this.$.cursor.moveToPreviousChunk();
+            }
+          }
+          break;
+        case 65:  // 'a'
+          if (e.shiftKey) { // Hide left diff.
+            e.preventDefault();
+            this._forEachDiff(function(diff) {
+              diff.toggleLeftDiff();
+            });
+          }
+          break;
+      }
+    },
+
+    _toggleInlineDiffs: function() {
+      if (this._showInlineDiffs) {
+        this._collapseAllDiffs();
+      } else {
+        this._expandAllDiffs();
+      }
+    },
+
+    _openCursorFile: function() {
+      var diff = this.$.cursor.getTargetDiffElement();
+      page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
+          diff.path));
+    },
+
+    _openSelectedFile: function(opt_index) {
+      if (opt_index != null) {
+        this.selectedIndex = opt_index;
+      }
+      page.show(this._computeDiffURL(this.changeNum, this.patchRange,
+          this._files[this.selectedIndex].__path));
+    },
+
+    _addDraftAtTarget: function() {
+      var diff = this.$.cursor.getTargetDiffElement();
+      var target = this.$.cursor.getTargetLineElement();
+      if (diff && target) {
+        diff.addDraftAtLine(target);
+      }
+    },
+
+    _scrollToSelectedFile: function() {
+      var el = this.$$('.row[selected]');
+      var top = 0;
+      for (var node = el; node; node = node.offsetParent) {
+        top += node.offsetTop;
+      }
+
+      // Don't scroll if it's already in view.
+      if (top > window.pageYOffset + this.topMargin &&
+          top < window.pageYOffset + window.innerHeight - el.clientHeight) {
+        return;
+      }
+
+      window.scrollTo(0, top - document.body.clientHeight / 2);
+    },
+
+    _computeFileSelected: function(index, selectedIndex) {
+      return index === selectedIndex;
+    },
+
+    _computeFileStatus: function(status) {
+      return status || 'M';
+    },
+
+    _computeDiffURL: function(changeNum, patchRange, path) {
+      return '/c/' +
+          encodeURIComponent(changeNum) +
+          '/' +
+          encodeURIComponent(this._patchRangeStr(patchRange)) +
+          '/' +
+          path;
+    },
+
+    _patchRangeStr: function(patchRange) {
+      return patchRange.basePatchNum !== 'PARENT' ?
+          patchRange.basePatchNum + '..' + patchRange.patchNum :
+          patchRange.patchNum + '';
+    },
+
+    _computeFileDisplayName: function(path) {
+      return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeClass: function(baseClass, path) {
+      var classes = [baseClass];
+      if (path === COMMIT_MESSAGE_PATH) {
+        classes.push('invisible');
+      }
+      return classes.join(' ');
+    },
+
+    _filesChanged: function() {
+      this.async(function() {
+        var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
+
+        // Overwrite the cursor's list of diffs:
+        this.$.cursor.splice.apply(this.$.cursor,
+            ['diffs', 0, this.$.cursor.diffs.length].concat(diffElements));
+      }.bind(this), 1);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
new file mode 100644
index 0000000..f61566a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -0,0 +1,271 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-file-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-file-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-file-list></gr-file-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    test('get file list', function(done) {
+      var getChangeFilesStub = sinon.stub(element.$.restAPI, 'getChangeFiles',
+          function() {
+            return Promise.resolve({
+              '/COMMIT_MSG': {lines_inserted: 9},
+              'tags.html': {lines_deleted: 123},
+              'about.txt': {},
+            });
+          });
+
+      element._getFiles().then(function(files) {
+        var filenames = files.map(function(f) { return f.__path; });
+        assert.deepEqual(filenames, ['/COMMIT_MSG', 'about.txt', 'tags.html']);
+        assert.deepEqual(files[0], {
+          lines_inserted: 9,
+          lines_deleted: 0,
+          __path: '/COMMIT_MSG',
+        });
+        assert.deepEqual(files[1], {
+          lines_inserted: 0,
+          lines_deleted: 0,
+          __path: 'about.txt',
+        });
+        assert.deepEqual(files[2], {
+          lines_inserted: 0,
+          lines_deleted: 123,
+          __path: 'tags.html',
+        });
+
+        getChangeFilesStub.restore();
+        done();
+      });
+    });
+
+    test('toggle left diff via shortcut', function() {
+      var toggleLeftDiffStub = sinon.stub();
+      sinon.stub(element, 'diffs', {get: function() {
+        return [{toggleLeftDiff: toggleLeftDiffStub}];
+      }});
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('keyboard shortcuts', function() {
+      var toggleInlineDiffsStub = sinon.stub(element, '_toggleInlineDiffs');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
+      assert.isTrue(toggleInlineDiffsStub.calledOnce);
+      toggleInlineDiffsStub.restore();
+
+      element._files = [
+        {__path: '/COMMIT_MSG'},
+        {__path: 'file_added_in_rev2.txt'},
+        {__path: 'myfile.txt'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.selectedIndex = 0;
+
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          '.row:not(.header)');
+      assert.equal(elementItems.length, 3);
+      assert.isTrue(elementItems[0].hasAttribute('selected'));
+      assert.isFalse(elementItems[1].hasAttribute('selected'));
+      assert.isFalse(elementItems[2].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+
+      var showStub = sinon.stub(page, 'show');
+      assert.equal(element.selectedIndex, 2);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
+      assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
+          'Should navigate to /c/42/2/myfile.txt');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'O'
+      assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
+          'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+      assert.equal(element.selectedIndex, 0);
+
+      showStub.restore();
+    });
+
+    test('comment filtering', function() {
+      var comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done'},
+          {patch_set: 1, message: 'oh hay'},
+          {patch_set: 2, message: 'hello'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!'},
+          {patch_set: 2, message: 'wat!?'},
+          {patch_set: 2, message: 'hi'},
+        ],
+      };
+      assert.equal(
+          element._computeCountString(comments, '1', '/COMMIT_MSG', 'comment'),
+          '2 comments');
+      assert.equal(
+          element._computeCountString(comments, '1', 'myfile.txt', 'comment'),
+          '1 comment');
+      assert.equal(
+          element._computeCountString(comments, '1',
+              'file_added_in_rev2.txt', 'comment'),
+          '');
+      assert.equal(
+          element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
+          '1 comment');
+      assert.equal(
+          element._computeCountString(comments, '2', 'myfile.txt', 'comment'),
+          '2 comments');
+      assert.equal(
+          element._computeCountString(comments, '2',
+              'file_added_in_rev2.txt', 'comment'),
+          '');
+    });
+
+    test('computed properties', function() {
+      assert.equal(element._computeFileStatus('A'), 'A');
+      assert.equal(element._computeFileStatus(undefined), 'M');
+      assert.equal(element._computeFileStatus(null), 'M');
+
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+
+      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
+          'clazz invisible');
+    });
+
+    test('file review status', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG'},
+        {__path: 'file_added_in_rev2.txt'},
+        {__path: 'myfile.txt'},
+      ];
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.selectedIndex = 0;
+
+      flushAsynchronousOperations();
+      var fileRows =
+          Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      var commitMsg = fileRows[0].querySelector('input[type="checkbox"]');
+      var fileAdded = fileRows[1].querySelector('input[type="checkbox"]');
+      var myFile = fileRows[2].querySelector('input[type="checkbox"]');
+
+      assert.isTrue(commitMsg.checked);
+      assert.isFalse(fileAdded.checked);
+      assert.isTrue(myFile.checked);
+
+      var saveStub = sinon.stub(element, '_saveReviewedState',
+          function() { return Promise.resolve(); });
+
+      MockInteractions.tap(commitMsg);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      MockInteractions.tap(commitMsg);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+
+      saveStub.restore();
+    });
+
+    test('patch set from revisions', function() {
+      var patchNums = element._computePatchSets({
+        rev3: {_number: 3},
+        rev1: {_number: 1},
+        rev4: {_number: 4},
+        rev2: {_number: 2},
+      });
+      assert.deepEqual(patchNums, [1, 2, 3, 4]);
+    });
+
+    test('patch range string', function() {
+      assert.equal(
+          element._patchRangeStr({basePatchNum: 'PARENT', patchNum: '1'}),
+          '1');
+      assert.equal(
+          element._patchRangeStr({basePatchNum: '1', patchNum: '3'}),
+          '1..3');
+    });
+
+    test('diff against dropdown', function(done) {
+      var showStub = sinon.stub(page, 'show');
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+      element.revisions = {
+        rev1: {_number: 1},
+        rev2: {_number: 2},
+        rev3: {_number: 3},
+      };
+      flush(function() {
+        var selectEl = element.$$('select');
+        assert.equal(selectEl.value, 'PARENT');
+        assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
+        selectEl.addEventListener('change', function() {
+          assert.equal(selectEl.value, '2');
+          assert(showStub.lastCall.calledWithExactly('/c/42/2..3'),
+              'Should navigate to /c/42/2..3');
+          showStub.restore();
+          done();
+        });
+        selectEl.value = '2';
+        element.fire('change', {}, {node: selectEl});
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
new file mode 100644
index 0000000..66254d0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -0,0 +1,139 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-comment-list/gr-comment-list.html">
+
+<dom-module id="gr-message">
+  <template>
+    <style>
+      :host {
+        border-top: 1px solid #ddd;
+        display: block;
+        position: relative;
+      }
+      :host(:not([expanded])) {
+        cursor: pointer;
+      }
+      gr-avatar {
+        position: absolute;
+        left: var(--default-horizontal-margin);
+      }
+      .collapsed .contentContainer {
+        color: #777;
+        white-space: nowrap;
+        overflow-x: hidden;
+        text-overflow: ellipsis;
+      }
+      .showAvatar.expanded .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 2.5em);
+        padding: 10px 0;
+      }
+      .showAvatar.collapsed .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 1.75em);
+        padding: .75em 2em .75em 0;
+      }
+      .hideAvatar.collapsed .contentContainer,
+      .hideAvatar.expanded .contentContainer {
+        margin-left: 0;
+        padding: .75em 2em .75em 0;
+      }
+      .collapsed gr-avatar {
+        top: .5em;
+        height: 1.75em;
+        width: 1.75em;
+      }
+      .expanded gr-avatar {
+        top: 12px;
+        height: 2.5em;
+        width: 2.5em;
+      }
+      .name {
+        font-weight: bold;
+      }
+      .content {
+        font-family: var(--monospace-font-family);
+      }
+      .collapsed .name,
+      .collapsed .content,
+      .collapsed .message,
+      gr-account-chip {
+        display: inline;
+      }
+      .collapsed gr-comment-list,
+      .collapsed .replyContainer {
+        display: none;
+      }
+      .collapsed .name {
+        color: var(--default-text-color);
+      }
+      .expanded .name {
+        cursor: pointer;
+      }
+      .date {
+        color: #666;
+        position: absolute;
+        right: var(--default-horizontal-margin);
+        top: 10px;
+      }
+      .replyContainer {
+        padding: .5em 0 1em;
+      }
+    </style>
+    <div class$="[[_computeClass(expanded, showAvatar)]]">
+      <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
+      <div class="contentContainer">
+        <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
+        <template is="dom-if" if="[[message.message]]">
+          <div class="content">
+            <gr-linked-text
+                class="message"
+                pre="[[expanded]]"
+                content="[[message.message]]"
+                disabled="[[!expanded]]"
+                config="[[projectConfig.commentlinks]]"></gr-linked-text>
+            <gr-comment-list
+                comments="[[comments]]"
+                change-num="[[changeNum]]"
+                patch-num="[[message._revision_number]]"></gr-comment-list>
+          </div>
+          <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
+            <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
+          </a>
+        </template>
+        <template is="dom-if" if="[[message.reviewer]]">
+          set reviewer status for
+          <gr-account-chip account="[[message.reviewer]]">
+          </gr-account-chip>
+          to [[message.state]].
+          <gr-date-formatter class="date" date-str="[[message.updated]]">
+          </gr-date-formatter>
+        </template>
+      </div>
+      <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
+        <gr-button small on-tap="_handleReplyTap">Reply</gr-button>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-message.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
new file mode 100644
index 0000000..c92ad07
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -0,0 +1,127 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-message',
+
+    /**
+     * Fired when this message's permalink is tapped.
+     *
+     * @event scroll-to
+     */
+
+    /**
+     * Fired when this message's reply link is tapped.
+     *
+     * @event reply
+     */
+
+    listeners: {
+      'tap': '_handleTap',
+    },
+
+    properties: {
+      changeNum: Number,
+      message: Object,
+      author: {
+        type: Object,
+        computed: '_computeAuthor(message)',
+      },
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      config: Object,
+      expanded: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+      showAvatar: {
+        type: Boolean,
+        computed: '_computeShowAvatar(author, config)',
+      },
+      showReplyButton: {
+        type: Boolean,
+        computed: '_computeShowReplyButton(message)',
+      },
+      projectConfig: Object,
+    },
+
+    ready: function() {
+      this.$.restAPI.getConfig().then(function(config) {
+        this.config = config;
+      }.bind(this));
+    },
+
+    _computeAuthor: function(message) {
+      return message.author || message.updated_by;
+    },
+
+    _computeShowAvatar: function(author, config) {
+      return !!(author && config && config.plugin && config.plugin.has_avatars);
+    },
+
+    _computeShowReplyButton: function(message) {
+      return !!message.message;
+    },
+
+    _commentsChanged: function(value) {
+      this.expanded = Object.keys(value || {}).length > 0;
+    },
+
+    _handleTap: function(e) {
+      if (this.expanded) { return; }
+      this.expanded = true;
+    },
+
+    _handleNameTap: function(e) {
+      if (!this.expanded) { return; }
+      e.stopPropagation();
+      this.expanded = false;
+    },
+
+    _computeClass: function(expanded, showAvatar) {
+      var classes = [];
+      classes.push(expanded ? 'expanded' : 'collapsed');
+      classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
+      return classes.join(' ');
+    },
+
+    _computeMessageHash: function(message) {
+      return '#message-' + message.id;
+    },
+
+    _handleLinkTap: function(e) {
+      e.preventDefault();
+
+      this.fire('scroll-to', {message: this.message}, {bubbles: false});
+
+      var hash = this._computeMessageHash(this.message);
+      // Don't add the hash to the window history if it's already there.
+      // Otherwise you mess up expected back button behavior.
+      if (window.location.hash == hash) { return; }
+      // Change the URL but don’t trigger a nav event. Otherwise it will
+      // reload the page.
+      page.show(window.location.pathname + hash, null, false);
+    },
+
+    _handleReplyTap: function(e) {
+      e.preventDefault();
+      this.fire('reply', {message: this.message});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
new file mode 100644
index 0000000..c90f58a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-message</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-message.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-message></gr-message>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-message tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('reply event', function(done) {
+      element.message = {
+        'id': '47c43261_55aa2c41',
+        'author': {
+          '_account_id': 1115495,
+          'name': 'Andrew Bonventre',
+          'email': 'andybons@chromium.org',
+        },
+        'date': '2016-01-12 20:24:49.448000000',
+        'message': 'Uploaded patch set 1.',
+        '_revision_number': 1
+      };
+
+      element.addEventListener('reply', function(e) {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$$('.replyContainer gr-button'));
+    });
+
+    test('reviewer update', function() {
+      var updatedBy = {
+        _account_id: 1115495,
+        name: 'Andrew Bonventre',
+        email: 'andybons@chromium.org',
+      };
+      var reviewer = {
+        _account_id: 123456,
+        name: 'Foo Bar',
+        email: 'barbar@chromium.org',
+      };
+      element.message = {
+        updated_by: updatedBy,
+        reviewer: reviewer,
+        state: 'CC',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
+      flushAsynchronousOperations();
+      var content = element.$$('.contentContainer');
+      assert.isOk(content);
+      assert.strictEqual(
+          content.querySelector('gr-account-chip').account, reviewer);
+      assert.equal(0, content.textContent.trim().indexOf(updatedBy.name));
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
new file mode 100644
index 0000000..3ae6b44
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -0,0 +1,65 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../gr-message/gr-message.html">
+
+<dom-module id="gr-messages-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .header {
+        display: flex;
+        justify-content: space-between;
+        margin-bottom: .35em;
+      }
+      .header,
+      gr-message {
+        padding: 0 var(--default-horizontal-margin);
+      }
+      .highlighted {
+        animation: 3s fadeOut;
+      }
+      @keyframes fadeOut {
+        0% { background-color: #fff9c4; }
+        100% { background-color: #fff; }
+      }
+    </style>
+    <div class="header">
+      <h3>Messages</h3>
+      <gr-button link on-tap="_handleExpandCollapseTap">
+        [[_computeExpandCollapseMessage(_expanded)]]
+      </gr-button>
+    </div>
+    <template
+        is="dom-repeat"
+        items="[[_computeItems(messages, reviewerUpdates)]]"
+        as="message">
+      <gr-message
+          change-num="[[changeNum]]"
+          message="[[message]]"
+          comments="[[_computeCommentsForMessage(comments, message)]]"
+          project-config="[[projectConfig]]"
+          show-reply-button="[[showReplyButtons]]"
+          on-scroll-to="_handleScrollTo"
+          data-message-id$="[[message.id]]"></gr-message>
+    </template>
+  </template>
+  <script src="gr-messages-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
new file mode 100644
index 0000000..e7a0573
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -0,0 +1,151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-messages-list',
+
+    properties: {
+      changeNum: Number,
+      messages: {
+        type: Array,
+        value: function() { return []; },
+      },
+      reviewerUpdates: {
+        type: Array,
+        value: function() { return []; },
+      },
+      comments: Object,
+      projectConfig: Object,
+      topMargin: Number,
+      showReplyButtons: {
+        type: Boolean,
+        value: false,
+      },
+
+      _expanded: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    scrollToMessage: function(messageID) {
+      var el = this.$$('[data-message-id="' + messageID + '"]');
+      if (!el) { return; }
+
+      el.expanded = true;
+      var top = el.offsetTop;
+      for (var offsetParent = el.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+      window.scrollTo(0, top - this.topMargin);
+      this._highlightEl(el);
+    },
+
+    _computeItems: function(messages, reviewerUpdates) {
+      messages = messages || [];
+      reviewerUpdates = reviewerUpdates || [];
+      var mi = 0;
+      var ri = 0;
+      var result = [];
+      var mDate;
+      var rDate;
+      for (var i = 0; i < messages.length; i++) {
+        messages[i]._index = i;
+      }
+      while (mi < messages.length || ri < reviewerUpdates.length) {
+        if (mi >= messages.length) {
+          result = result.concat(reviewerUpdates.slice(ri));
+          break;
+        }
+        if (ri >= reviewerUpdates.length) {
+          result = result.concat(messages.slice(mi));
+          break;
+        }
+        mDate = mDate || util.parseDate(messages[mi].date);
+        rDate = rDate || util.parseDate(reviewerUpdates[ri].updated);
+        if (rDate < mDate) {
+          result.push(reviewerUpdates[ri++]);
+          rDate = null;
+        } else {
+          result.push(messages[mi++]);
+          mDate = null;
+        }
+      }
+      return result;
+    },
+
+    _highlightEl: function(el) {
+      var highlightedEls =
+          Polymer.dom(this.root).querySelectorAll('.highlighted');
+      for (var i = 0; i < highlightedEls.length; i++) {
+        highlightedEls[i].classList.remove('highlighted');
+      }
+      function handleAnimationEnd() {
+        el.removeEventListener('animationend', handleAnimationEnd);
+        el.classList.remove('highlighted');
+      }
+      el.addEventListener('animationend', handleAnimationEnd);
+      el.classList.add('highlighted');
+    },
+
+    _handleExpandCollapseTap: function(e) {
+      e.preventDefault();
+      this._expanded = !this._expanded;
+      var messageEls = Polymer.dom(this.root).querySelectorAll('gr-message');
+      for (var i = 0; i < messageEls.length; i++) {
+        messageEls[i].expanded = this._expanded;
+      }
+    },
+
+    _handleScrollTo: function(e) {
+      this.scrollToMessage(e.detail.message.id);
+    },
+
+    _computeExpandCollapseMessage: function(expanded) {
+      return expanded ? 'Collapse all' : 'Expand all';
+    },
+
+    _computeCommentsForMessage: function(comments, message) {
+      if (message._index === undefined || !comments || !this.messages) {
+        return [];
+      }
+      var index = message._index;
+      var messages = this.messages || [];
+      var msgComments = {};
+      var mDate = util.parseDate(message.date);
+      var nextMDate;
+      if (index < messages.length - 1) {
+        nextMDate = util.parseDate(messages[index + 1].date);
+      }
+      for (var file in comments) {
+        var fileComments = comments[file];
+        for (var i = 0; i < fileComments.length; i++) {
+          var cDate = util.parseDate(fileComments[i].updated);
+          if (cDate >= mDate) {
+            if (nextMDate && cDate >= nextMDate) {
+              continue;
+            }
+            msgComments[file] = msgComments[file] || [];
+            msgComments[file].push(fileComments[i]);
+          }
+        }
+      }
+      return msgComments;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
new file mode 100644
index 0000000..3cda480
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -0,0 +1,131 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-messages-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-messages-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-messages-list></gr-messages-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-messages-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.messages = [
+        {
+          id: '47c43261_55aa2c41',
+          author: {
+            _account_id: 1115495,
+            name: 'Andrew Bonventre',
+            email: 'andybons@chromium.org',
+          },
+          date: '2016-01-12 20:24:49.448000000',
+          message: 'Uploaded patch set 1.',
+          _revision_number: 1
+        },
+        {
+          id: '47c43261_9593e420',
+          author: {
+            _account_id: 1115495,
+            name: 'Andrew Bonventre',
+            email: 'andybons@chromium.org',
+          },
+          date: '2016-01-12 20:28:33.038000000',
+          message: 'Patch Set 1:\n\n(1 comment)',
+          _revision_number: 1
+        },
+        {
+          id: '87b2aaf4_f73260c5',
+          author: {
+            _account_id: 1143760,
+            name: 'Mark Mentovai',
+            email: 'mark@chromium.org',
+          },
+          date: '2016-01-12 21:17:07.554000000',
+          message: 'Patch Set 1:\n\n(3 comments)',
+          _revision_number: 1
+        }
+      ];
+      flushAsynchronousOperations();
+    });
+
+    test('expand/collapse all', function() {
+      var allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i].expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1].expanded);
+
+      MockInteractions.tap(element.$$('.header gr-button'));
+      allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isTrue(allMessageEls[i].expanded);
+      }
+
+      MockInteractions.tap(element.$$('.header gr-button'));
+      allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i].expanded);
+      }
+    });
+
+    test('scroll to message', function() {
+      var allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i].expanded = false;
+      }
+
+      var scrollToStub = sinon.stub(window, 'scrollTo');
+      var highlightStub = sinon.stub(element, '_highlightEl');
+
+      element.scrollToMessage('invalid');
+
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i].expanded,
+            'expected gr-message ' + i + ' to not be expanded');
+      }
+
+      var messageID = '47c43261_9593e420';
+      element.scrollToMessage(messageID);
+      assert.isTrue(
+          element.$$('[data-message-id="' + messageID + '"]').expanded);
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+
+      scrollToStub.restore();
+      highlightStub.restore();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
new file mode 100644
index 0000000..5d2d20d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -0,0 +1,133 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-related-changes-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      h3 {
+        margin: .5em 0 0;
+      }
+      section {
+        margin-bottom: 1em;
+      }
+      a {
+        display: block;
+      }
+      .changeContainer,
+      a {
+        max-width: 100%;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .changeContainer {
+        display: flex;
+      }
+      .changeContainer.thisChange:before {
+        content: '➔';
+        position: absolute;
+        transform: translateX(-1.2em);
+      }
+      .relatedChanges a {
+        display: inline-block;
+      }
+      .strikethrough {
+        color: #666;
+        text-decoration: line-through;
+      }
+      .status {
+        color: #666;
+        font-weight: bold;
+        margin-left: .25em;
+      }
+      .notCurrent {
+        color: #e65100;
+      }
+      .indirectAncestor {
+        color: #33691e;
+      }
+      .submittable {
+        color: #1b5e20;
+      }
+      .hidden {
+        display: none;
+      }
+    </style>
+    <div hidden$="[[!_loading]]">Loading...</div>
+    <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
+      <h4>Relation chain</h4>
+      <template
+          is="dom-repeat"
+          items="[[_relatedResponse.changes]]"
+          as="related">
+        <div class$="[[_computeChangeContainerClass(change, related)]]">
+          <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
+              class$="[[_computeLinkClass(related)]]">
+            [[related.commit.subject]]
+          </a>
+          <span class$="[[_computeChangeStatusClass(related)]]">
+            ([[_computeChangeStatus(related)]])
+          </span>
+        </div>
+      </template>
+    </section>
+    <section hidden$="[[!_submittedTogether.length]]" hidden>
+      <h4>Submitted together</h4>
+      <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.project]]: [[change.branch]]: [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_sameTopic.length]]" hidden>
+      <h4>Same topic</h4>
+      <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.project]]: [[change.branch]]: [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_conflicts.length]]" hidden>
+      <h4>Merge conflicts</h4>
+      <template is="dom-repeat" items="[[_conflicts]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_cherryPicks.length]]" hidden>
+      <h4>Cherry picks</h4>
+      <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-related-changes-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
new file mode 100644
index 0000000..f4ee53a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -0,0 +1,219 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-related-changes-list',
+
+    properties: {
+      change: Object,
+      patchNum: String,
+      hidden: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+
+      _loading: Boolean,
+      _connectedRevisions: {
+        type: Array,
+        computed: '_computeConnectedRevisions(change, patchNum, ' +
+            '_relatedResponse.changes)',
+      },
+      _relatedResponse: Object,
+      _submittedTogether: Array,
+      _conflicts: Array,
+      _cherryPicks: Array,
+      _sameTopic: Array,
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_resultsChanged(_relatedResponse.changes, _submittedTogether, ' +
+          '_conflicts, _cherryPicks, _sameTopic)',
+    ],
+
+    reload: function() {
+      if (!this.change || !this.patchNum) {
+        return Promise.resolve();
+      }
+      this._loading = true;
+      var promises = [
+        this._getRelatedChanges().then(function(response) {
+          this._relatedResponse = response;
+        }.bind(this)),
+        this._getSubmittedTogether().then(function(response) {
+          this._submittedTogether = response;
+        }.bind(this)),
+        this._getConflicts().then(function(response) {
+          this._conflicts = response;
+        }.bind(this)),
+        this._getCherryPicks().then(function(response) {
+          this._cherryPicks = response;
+        }.bind(this)),
+      ];
+
+      return this._getServerConfig().then(function(config) {
+        if (this.change.topic && !config.change.submit_whole_topic) {
+          return this._getChangesWithSameTopic().then(function(response) {
+            this._sameTopic = response;
+          }.bind(this));
+        } else {
+          this._sameTopic = [];
+        }
+        return this._sameTopic;
+      }.bind(this)).then(Promise.all(promises)).then(function() {
+        this._loading = false;
+      }.bind(this));
+    },
+
+    _getRelatedChanges: function() {
+      return this.$.restAPI.getRelatedChanges(this.change._number,
+          this.patchNum);
+    },
+
+    _getSubmittedTogether: function() {
+      return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
+    },
+
+    _getServerConfig: function() {
+      return this.$.restAPI.getConfig();
+    },
+
+    _getConflicts: function() {
+      return this.$.restAPI.getChangeConflicts(this.change._number);
+    },
+
+    _getCherryPicks: function() {
+      return this.$.restAPI.getChangeCherryPicks(this.change.project,
+          this.change.change_id, this.change._number);
+    },
+
+    _getChangesWithSameTopic: function() {
+      return this.$.restAPI.getChangesWithSameTopic(this.change.topic);
+    },
+
+    _computeChangeURL: function(changeNum, patchNum) {
+      var urlStr = '/c/' + changeNum;
+      if (patchNum != null) {
+        urlStr += '/' + patchNum;
+      }
+      return urlStr;
+    },
+
+    _computeChangeContainerClass: function(currentChange, relatedChange) {
+      var classes = ['changeContainer'];
+      if (relatedChange.change_id === currentChange.change_id) {
+        classes.push('thisChange');
+      }
+      return classes.join(' ');
+    },
+
+    _computeLinkClass: function(change) {
+      if (change.status == this.ChangeStatus.ABANDONED) {
+        return 'strikethrough';
+      }
+    },
+
+    _computeChangeStatusClass: function(change) {
+      var classes = ['status'];
+      if (change._revision_number != change._current_revision_number) {
+        classes.push('notCurrent');
+      } else if (this._isIndirectAncestor(change)) {
+        classes.push('indirectAncestor');
+      } else if (change.submittable) {
+        classes.push('submittable');
+      } else if (change.status == this.ChangeStatus.NEW) {
+        classes.push('hidden');
+      }
+      return classes.join(' ');
+    },
+
+    _computeChangeStatus: function(change) {
+      switch (change.status) {
+        case this.ChangeStatus.MERGED:
+          return 'Merged';
+        case this.ChangeStatus.ABANDONED:
+          return 'Abandoned';
+        case this.ChangeStatus.DRAFT:
+          return 'Draft';
+      }
+      if (change._revision_number != change._current_revision_number) {
+        return 'Not current';
+      } else if (this._isIndirectAncestor(change)) {
+        return 'Indirect ancestor';
+      } else if (change.submittable) {
+        return 'Submittable';
+      }
+      return '';
+    },
+
+    _resultsChanged: function(related, submittedTogether, conflicts,
+        cherryPicks, sameTopic) {
+      var results = [
+        related,
+        submittedTogether,
+        conflicts,
+        cherryPicks,
+        sameTopic
+      ];
+      for (var i = 0; i < results.length; i++) {
+        if (results[i].length > 0) {
+          this.hidden = false;
+          return;
+        }
+      }
+      this.hidden = true;
+    },
+
+    _isIndirectAncestor: function(change) {
+      return this._connectedRevisions.indexOf(change.commit.commit) == -1;
+    },
+
+    _computeConnectedRevisions: function(change, patchNum, relatedChanges) {
+      var connected = [];
+      var changeRevision;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          changeRevision = rev;
+        }
+      }
+      var commits = relatedChanges.map(function(c) { return c.commit; });
+      var pos = commits.length - 1;
+
+      while (pos >= 0) {
+        var commit = commits[pos].commit;
+        connected.push(commit);
+        if (commit == changeRevision) {
+          break;
+        }
+        pos--;
+      }
+      while (pos >= 0) {
+        for (var i = 0; i < commits[pos].parents.length; i++) {
+          if (connected.indexOf(commits[pos].parents[i].commit) != -1) {
+            connected.push(commits[pos].commit);
+            break;
+          }
+        }
+        --pos;
+      }
+      return connected;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
new file mode 100644
index 0000000..f7864ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -0,0 +1,227 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-related-changes-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-related-changes-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-related-changes-list></gr-related-changes-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-related-changes-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('connected revisions', function() {
+      var change = {
+        revisions: {
+          'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
+            _number: 1,
+          },
+          '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
+            _number: 2,
+          },
+          'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
+            _number: 7,
+          },
+          'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
+            _number: 5,
+          },
+          'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
+            _number: 6,
+          },
+          'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
+            _number: 3,
+          },
+          '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
+            _number: 4,
+          }
+        }
+      };
+      var patchNum = 7;
+      var relatedChanges = [
+        {
+          commit: {
+            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+            parents: [
+              {
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            parents: [
+              {
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            parents: [
+              {
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            parents: [
+              {
+                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+            parents: [
+              {
+                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+            parents: [
+              {
+                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75'
+              }
+            ],
+          },
+        }
+      ];
+
+      var connectedChanges =
+          element._computeConnectedRevisions(change, patchNum, relatedChanges);
+      assert.deepEqual(connectedChanges, [
+        '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+        'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+        '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+        '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+        '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+      ]);
+
+      patchNum = 4;
+      relatedChanges = [
+        {
+          commit: {
+            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+            parents: [
+              {
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            parents: [
+              {
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            parents: [
+              {
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+            parents: [
+              {
+                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+            parents: [
+              {
+                commit: 'af815dac54318826b7f1fa468acc76349ffc588e'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+            parents: [
+              {
+                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c'
+              }
+            ],
+          },
+        }
+      ];
+
+      connectedChanges =
+          element._computeConnectedRevisions(change, patchNum, relatedChanges);
+      assert.deepEqual(connectedChanges, [
+        'af815dac54318826b7f1fa468acc76349ffc588e',
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+        'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+      ]);
+    });
+
+    test('_computeChangeContainerClass', function() {
+      var change1 = {change_id: 123};
+      var change2 = {change_id: 456};
+
+      assert.notEqual(element._computeChangeContainerClass(
+          change1, change1).indexOf('thisChange'), -1);
+      assert.equal(element._computeChangeContainerClass(
+          change1, change2).indexOf('thisChange'), -1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
new file mode 100644
index 0000000..cec1e90
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -0,0 +1,258 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../gr-account-list/gr-account-list.html">
+
+<dom-module id="gr-reply-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        max-height: 90vh;
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: .5;
+      }
+      .container {
+        display: flex;
+        flex-direction: column;
+        max-height: 90vh;
+      }
+      section {
+        border-top: 1px solid #ddd;
+        padding: .5em .75em;
+        width: 100%;
+      }
+      .peopleContainer,
+      .labelsContainer,
+      .actionsContainer {
+        flex-shrink: 0;
+      }
+      .peopleContainer {
+        display: table;
+      }
+      .peopleList {
+        display: flex;
+        padding-top: .1em;
+      }
+      .peopleListLabel {
+        color: #666;
+        min-width: 7em;
+        padding-right: .5em;
+      }
+      gr-account-list {
+        display: flex;
+        flex-wrap: wrap;
+      }
+      #reviewerConfirmationOverlay {
+        padding: 1em;
+        text-align: center;
+      }
+      .reviewerConfirmationButtons {
+        margin-top: 1em;
+      }
+      .groupName {
+        font-weight: bold;
+      }
+      .groupSize {
+        font-style: italic;
+      }
+      .textareaContainer {
+        display: flex;
+        flex: 1;
+        min-height: 6em;
+        position: relative;
+      }
+      iron-autogrow-textarea {
+        padding: 0;
+        font-family: var(--monospace-font-family);
+      }
+      .message {
+        border: none;
+        width: 100%;
+      }
+      .labelsNotShown {
+        color: #666;
+      }
+      .labelContainer:not(:first-of-type) {
+        margin-top: .5em;
+      }
+      .labelName {
+        display: inline-block;
+        min-width: 7em;
+        margin-right: .5em;
+        white-space: nowrap;
+      }
+      iron-selector {
+        display: inline-flex;
+      }
+      iron-selector > gr-button {
+        margin-right: .25em;
+      }
+      iron-selector > gr-button:first-of-type {
+        border-top-left-radius: 2px;
+        border-bottom-left-radius: 2px;
+      }
+      iron-selector > gr-button:last-of-type {
+        border-top-right-radius: 2px;
+        border-bottom-right-radius: 2px;
+      }
+      iron-selector > gr-button.iron-selected {
+        background-color: #ddd;
+      }
+      .draftsContainer {
+        flex: 1;
+        overflow-y: auto;
+      }
+      .draftsContainer h3 {
+        margin-top: .25em;
+      }
+      .actionsContainer {
+        display: flex;
+        justify-content: space-between;
+      }
+      .action:link,
+      .action:visited {
+        color: #00e;
+      }
+    </style>
+    <div class="container">
+      <section class="peopleContainer">
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <gr-account-chip account="[[_owner]]">
+          <gr-account-chip>
+        </div>
+      </section>
+      <section class="peopleContainer">
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <gr-account-list
+              id="reviewers"
+              accounts="[[_reviewers]]"
+              change="[[change]]"
+              filter="[[filterReviewerSuggestion]]"
+              pending-confirmation="{{_reviewerPendingConfirmation}}"
+              placeholder="Add reviewer...">
+          </gr-account-list>
+        </div>
+        <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
+          <div class="peopleList">
+            <div class="peopleListLabel">CC</div>
+            <gr-account-list
+                id="ccs"
+                accounts="[[_ccs]]"
+                change="[[change]]"
+                filter="[[filterReviewerSuggestion]]"
+                pending-confirmation="{{_ccPendingConfirmation}}"
+                placeholder="Add CC...">
+            </gr-account-list>
+          </div>
+        </template>
+        <gr-overlay
+            id="reviewerConfirmationOverlay"
+            on-iron-overlay-canceled="_cancelPendingReviewer"
+            with-backdrop>
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName">
+              {{_reviewerPendingConfirmation.group.name}}
+            </span>
+            has
+            <span class="groupSize">
+              {{_reviewerPendingConfirmation.count}}
+            </span>
+            members.
+            <br>
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button on-tap="_confirmPendingReviewer">Yes</gr-button>
+            <gr-button on-tap="_cancelPendingReviewer">No</gr-button>
+          </div>
+        </gr-overlay>
+      </section>
+      <section class="textareaContainer">
+        <iron-autogrow-textarea
+            id="textarea"
+            class="message"
+            placeholder="Say something..."
+            disabled="{{disabled}}"
+            rows="4"
+            max-rows="15"
+            bind-value="{{draft}}"
+            on-bind-value-changed="_handleTextareaChanged">
+        </iron-autogrow-textarea>
+      </section>
+      <section class="labelsContainer">
+        <template is="dom-if" if="[[_computeShowLabels(patchNum, revisions)]]">
+          <template is="dom-repeat"
+              items="[[_computeLabelArray(permittedLabels)]]" as="label">
+            <div class="labelContainer">
+              <span class="labelName">[[label]]</span>
+              <iron-selector data-label$="[[label]]"
+                  selected="[[_computeIndexOfLabelValue(labels, permittedLabels, label, _account)]]">
+                <template is="dom-repeat"
+                    items="[[_computePermittedLabelValues(permittedLabels, label)]]"
+                    as="value">
+                  <gr-button has-tooltip data-value$="[[value]]"
+                      title$="[[_computeLabelValueTitle(labels, label, value)]]">[[value]]</gr-button>
+                </template>
+              </iron-selector>
+            </div>
+          </template>
+        </template>
+        <template is="dom-if" if="[[!_computeShowLabels(patchNum, revisions)]]">
+          <span class="labelsNotShown">
+            Labels are not shown because this is not the most recent patch set.
+            <a href$="/c/[[change._number]]">Go to the latest patch set.</a>
+          </span>
+        </template>
+      </section>
+      <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
+        <h3>[[_computeDraftsTitle(diffDrafts)]]</h3>
+        <gr-comment-list
+            comments="[[diffDrafts]]"
+            change-num="[[change._number]]"
+            patch-num="[[patchNum]]"></gr-comment-list>
+      </section>
+      <section class="actionsContainer">
+        <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button>
+        <gr-button
+            id="cancelButton"
+            class="action cancel"
+            on-tap="_cancelTapHandler">Cancel</gr-button>
+      </section>
+    </div>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+  </template>
+  <script src="gr-reply-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
new file mode 100644
index 0000000..d2b279d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -0,0 +1,467 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+  var FocusTarget = {
+    ANY: 'any',
+    BODY: 'body',
+    CCS: 'cc',
+    REVIEWERS: 'reviewers',
+  };
+
+  Polymer({
+    is: 'gr-reply-dialog',
+
+    /**
+     * Fired when a reply is successfully sent.
+     *
+     * @event send
+     */
+
+    /**
+     * Fired when the user presses the cancel button.
+     *
+     * @event cancel
+     */
+
+    /**
+     * Fired when the main textarea's value changes, which may have triggered
+     * a change in size for the dialog.
+     *
+     * @event autogrow
+     */
+
+    properties: {
+      change: Object,
+      patchNum: String,
+      revisions: Object,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: String,
+        value: '',
+        observer: '_draftChanged',
+      },
+      diffDrafts: Object,
+      filterReviewerSuggestion: {
+        type: Function,
+        value: function() {
+          return this._filterReviewerSuggestion.bind(this);
+        },
+      },
+      labels: Object,
+      permittedLabels: Object,
+      serverConfig: Object,
+
+      _account: Object,
+      _ccs: Array,
+      _ccPendingConfirmation: {
+        type: Object,
+        observer: '_reviewerPendingConfirmationUpdated',
+      },
+      _owner: Object,
+      _reviewers: Array,
+      _reviewerPendingConfirmation: {
+        type: Object,
+        observer: '_reviewerPendingConfirmationUpdated',
+      },
+    },
+
+    FocusTarget: FocusTarget,
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
+    ],
+
+    attached: function() {
+      this._getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this));
+    },
+
+    ready: function() {
+      this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
+    },
+
+    open: function(opt_focusTarget) {
+      this._focusOn(opt_focusTarget);
+      if (!this.draft || !this.draft.length) {
+        this.draft = this._loadStoredDraft();
+      }
+    },
+
+    focus: function() {
+      this._focusOn(FocusTarget.ANY);
+    },
+
+    getFocusStops: function() {
+      return {
+        start: this.$.reviewers.focusStart,
+        end: this.$.cancelButton,
+      };
+    },
+
+    setLabelValue: function(label, value) {
+      var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+      // The selector may not be present if it’s not at the latest patch set.
+      if (!selectorEl) { return; }
+      var item = selectorEl.$$('gr-button[data-value="' + value + '"]');
+      if (!item) { return; }
+      selectorEl.selectIndex(selectorEl.indexOf(item));
+    },
+
+    _mapReviewer: function(reviewer) {
+      var reviewerId;
+      var confirmed;
+      if (reviewer.account) {
+        reviewerId = reviewer.account._account_id;
+      } else if (reviewer.group) {
+        reviewerId = reviewer.group.id;
+        confirmed = reviewer.group.confirmed;
+      }
+      return {reviewer: reviewerId, confirmed: confirmed};
+    },
+
+    send: function() {
+      var obj = {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {},
+      };
+      for (var label in this.permittedLabels) {
+        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+
+        var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+
+        // The selector may not be present if it’s not at the latest patch set.
+        if (!selectorEl) { continue; }
+
+        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
+        selectedVal = parseInt(selectedVal, 10);
+        obj.labels[label] = selectedVal;
+      }
+      if (this.draft != null) {
+        obj.message = this.draft;
+      }
+
+      obj.reviewers = this.$.reviewers.additions().map(this._mapReviewer);
+      if (this.serverConfig.note_db_enabled) {
+        this.$$('#ccs').additions().forEach(function(reviewer) {
+          reviewer = this._mapReviewer(reviewer);
+          reviewer.state = 'CC';
+          obj.reviewers.push(reviewer);
+        }.bind(this));
+      }
+
+      this.disabled = true;
+
+      var errFn = this._handle400Error.bind(this);
+      return this._saveReview(obj, errFn).then(function(response) {
+        if (!response || !response.ok) {
+          return response;
+        }
+        this.disabled = false;
+        this.draft = '';
+        this.fire('send', null, {bubbles: false});
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
+      }.bind(this));
+    },
+
+    _focusOn: function(section) {
+      if (section === FocusTarget.ANY) {
+        section = this._chooseFocusTarget();
+      }
+      if (section === FocusTarget.BODY) {
+        var textarea = this.$.textarea;
+        textarea.async(textarea.textarea.focus.bind(textarea.textarea));
+      } else if (section === FocusTarget.REVIEWERS) {
+        var reviewerEntry = this.$.reviewers.focusStart;
+        reviewerEntry.async(reviewerEntry.focus);
+      } else if (section === FocusTarget.CCS) {
+        var ccEntry = this.$$('#ccs').focusStart;
+        ccEntry.async(ccEntry.focus);
+      }
+    },
+
+    _chooseFocusTarget: function() {
+      // If we are the owner and the reviewers field is empty, focus on that.
+      if (this._account && this.change.owner &&
+          this._account._account_id === this.change.owner._account_id &&
+          (!this._reviewers || this._reviewers.length === 0)) {
+        return FocusTarget.REVIEWERS;
+      }
+
+      // Default to BODY.
+      return FocusTarget.BODY;
+    },
+
+    _handle400Error: function(response) {
+      // A call to _saveReview could fail with a server error if erroneous
+      // reviewers were requested. This is signalled with a 400 Bad Request
+      // status. The default gr-rest-api-interface error handling would
+      // result in a large JSON response body being displayed to the user in
+      // the gr-error-manager toast.
+      //
+      // We can modify the error handling behavior by passing this function
+      // through to restAPI as a custom error handling function. Since we're
+      // short-circuiting restAPI we can do our own response parsing and fire
+      // the server-error ourselves.
+      //
+      this.disabled = false;
+
+      if (response.status !== 400) {
+        // This is all restAPI does when there is no custom error handling.
+        this.fire('server-error', {response: response});
+        return response;
+      }
+
+      // Process the response body, format a better error message, and fire
+      // an event for gr-event-manager to display.
+      var jsonPromise = this.$.restAPI.getResponseObject(response);
+      return jsonPromise.then(function(result) {
+        var errors = [];
+        ['reviewers', 'ccs'].forEach(function(state) {
+          for (var input in result[state]) {
+            var reviewer = result[state][input];
+            if (!!reviewer.error) {
+              errors.push(reviewer.error);
+            }
+          }
+        });
+        response = {
+          ok: false,
+          status: response.status,
+          text: function() { return Promise.resolve(errors.join(', ')); },
+        };
+        this.fire('server-error', {response: response});
+      }.bind(this));
+    },
+
+    _computeShowLabels: function(patchNum, revisions) {
+      var num = parseInt(patchNum, 10);
+      for (var rev in revisions) {
+        if (revisions[rev]._number > num) {
+          return false;
+        }
+      }
+      return true;
+    },
+
+    _computeHideDraftList: function(drafts) {
+      return Object.keys(drafts || {}).length == 0;
+    },
+
+    _computeDraftsTitle: function(drafts) {
+      var total = 0;
+      for (var file in drafts) {
+        total += drafts[file].length;
+      }
+      if (total == 0) { return ''; }
+      if (total == 1) { return '1 Draft'; }
+      if (total > 1) { return total + ' Drafts'; }
+    },
+
+    _computeLabelValueTitle: function(labels, label, value) {
+      return labels[label] && labels[label].values[value];
+    },
+
+    _computeLabelArray: function(labelsObj) {
+      return Object.keys(labelsObj).sort();
+    },
+
+    _computeIndexOfLabelValue: function(
+        labels, permittedLabels, labelName, account) {
+      var t = labels[labelName];
+      if (!t) { return null; }
+      var labelValue = t.default_value;
+
+      // Is there an existing vote for the current user? If so, use that.
+      var votes = labels[labelName];
+      if (votes.all && votes.all.length > 0) {
+        for (var i = 0; i < votes.all.length; i++) {
+          if (votes.all[i]._account_id == account._account_id) {
+            labelValue = votes.all[i].value;
+            break;
+          }
+        }
+      }
+
+      var len = permittedLabels[labelName] != null ?
+          permittedLabels[labelName].length : 0;
+      for (var i = 0; i < len; i++) {
+        var val = parseInt(permittedLabels[labelName][i], 10);
+        if (val == labelValue) {
+          return i;
+        }
+      }
+      return null;
+    },
+
+    _computePermittedLabelValues: function(permittedLabels, label) {
+      return permittedLabels[label];
+    },
+
+    _changeUpdated: function(changeRecord, owner, serverConfig) {
+      this._owner = owner;
+
+      var reviewers = [];
+      var ccs = [];
+
+      for (var key in changeRecord.base) {
+        if (key !== 'REVIEWER' && key !== 'CC') {
+          console.warn('unexpected reviewer state:', key);
+          continue;
+        }
+        changeRecord.base[key].forEach(function(entry) {
+          if (entry._account_id === owner._account_id) {
+            return;
+          }
+          switch (key) {
+            case 'REVIEWER':
+              reviewers.push(entry);
+              break;
+            case 'CC':
+              ccs.push(entry);
+              break;
+          }
+        });
+      }
+
+      if (serverConfig.note_db_enabled) {
+        this._ccs = ccs;
+      } else {
+        this._ccs = [];
+        reviewers = reviewers.concat(ccs);
+      }
+      this._reviewers = reviewers;
+    },
+
+    _accountOrGroupKey: function(entry) {
+      return entry.id || entry._account_id;
+    },
+
+    _filterReviewerSuggestion: function(suggestion) {
+      var entry;
+      if (suggestion.account) {
+        entry = suggestion.account;
+      } else if (suggestion.group) {
+        entry = suggestion.group;
+      } else {
+        console.warn('received suggestion that was neither account nor group:',
+            suggestion);
+      }
+      if (entry._account_id === this._owner._account_id) {
+        return false;
+      }
+
+      var key = this._accountOrGroupKey(entry);
+      var finder = function(entry) {
+        return this._accountOrGroupKey(entry) === key;
+      }.bind(this);
+
+      return this._reviewers.find(finder) === undefined &&
+          this._ccs.find(finder) === undefined;
+    },
+
+    _getAccount: function() {
+      return this.$.restAPI.getAccount();
+    },
+
+    _cancelTapHandler: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _sendTapHandler: function(e) {
+      e.preventDefault();
+      this.send();
+    },
+
+    _saveReview: function(review, opt_errFn) {
+      return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
+          review, opt_errFn);
+    },
+
+    _reviewerPendingConfirmationUpdated: function(reviewer) {
+      if (reviewer === null) {
+        this.$.reviewerConfirmationOverlay.close();
+      } else {
+        this.$.reviewerConfirmationOverlay.open();
+      }
+    },
+
+    _confirmPendingReviewer: function() {
+      if (this._ccPendingConfirmation) {
+        this.$$('#ccs').confirmGroup(this._ccPendingConfirmation.group);
+        this._focusOn(FocusTarget.CCS);
+      } else {
+        this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
+        this._focusOn(FocusTarget.REVIEWERS);
+      }
+    },
+
+    _cancelPendingReviewer: function() {
+      this._ccPendingConfirmation = null;
+      this._reviewerPendingConfirmation = null;
+
+      var target =
+          this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
+      this._focusOn(target);
+    },
+
+    _getStorageLocation: function() {
+      return {
+        changeNum: this.change._number,
+        patchNum: this.patchNum,
+        path: '@change',
+      };
+    },
+
+    _loadStoredDraft: function() {
+      var draft = this.$.storage.getDraftComment(this._getStorageLocation());
+      return draft ? draft.message : '';
+    },
+
+    _draftChanged: function(newDraft, oldDraft) {
+      this.debounce('store', function() {
+        if (!newDraft.length && oldDraft) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(this._getStorageLocation());
+        } else if (newDraft.length) {
+          this.$.storage.setDraftComment(this._getStorageLocation(),
+              this.draft);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL_MS);
+    },
+
+    _handleTextareaChanged: function(e) {
+      // If the textarea resizes, we need to re-fit the overlay.
+      this.debounce('autogrow', function() {
+        this.fire('autogrow');
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
new file mode 100644
index 0000000..8fb4e45
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -0,0 +1,393 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reply-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-reply-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dialog></gr-reply-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reply-dialog tests', function() {
+    var element;
+    var changeNum;
+    var patchNum;
+
+    var sandbox;
+    var getDraftCommentStub;
+    var setDraftCommentStub;
+    var eraseDraftCommentStub;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+
+      changeNum = 42;
+      patchNum = 1;
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve({}); },
+      });
+
+      element = fixture('basic');
+      element.change = { _number: changeNum };
+      element.patchNum = patchNum;
+      element.labels = {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified'
+          },
+          default_value: 0
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved'
+          },
+          default_value: 0
+        }
+      };
+      element.permittedLabels = {
+        'Code-Review': [
+          '-1',
+          ' 0',
+          '+1'
+        ],
+        Verified: [
+          '-1',
+          ' 0',
+          '+1'
+        ]
+      };
+      element.serverConfig = {};
+
+      getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
+      eraseDraftCommentStub = sandbox.stub(element.$.storage,
+          'eraseDraftComment');
+
+      // Allow the elements created by dom-repeat to be stamped.
+      flushAsynchronousOperations();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('cancel event', function(done) {
+      element.addEventListener('cancel', function() { done(); });
+      MockInteractions.tap(element.$$('.cancel'));
+    });
+
+    test('show/hide labels', function() {
+      var revisions = {
+        rev1: {_number: 1},
+        rev2: {_number: 2},
+      };
+      assert.isFalse(element._computeShowLabels('1', revisions));
+      assert.isTrue(element._computeShowLabels('2', revisions));
+    });
+
+    test('label picker', function(done) {
+      var showLabelsStub = sinon.stub(element, '_computeShowLabels',
+          function() { return true; });
+      element.revisions = {};
+      element.patchNum = '';
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      flush(function() {
+        for (var label in element.permittedLabels) {
+          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+              label);
+        }
+        element.draft = 'I wholeheartedly disapprove';
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Code-Review"] > ' +
+            'gr-button[data-value="-1"]'));
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Verified"] > ' +
+            'gr-button[data-value="-1"]'));
+
+        var saveReviewStub = sinon.stub(element, '_saveReview',
+            function(review) {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': -1,
+              'Verified': -1
+            },
+            message: 'I wholeheartedly disapprove',
+            reviewers: [],
+          });
+          return Promise.resolve({ok: true});
+        });
+
+        element.addEventListener('send', function() {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done sending reply.');
+          assert.equal(element.draft.length, 0);
+          saveReviewStub.restore();
+          showLabelsStub.restore();
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(function() {
+          MockInteractions.tap(element.$$('.send'));
+          assert.isTrue(element.disabled);
+        });
+      });
+    });
+
+    function getActiveElement() {
+      return Polymer.IronOverlayManager.deepActiveElement;
+    }
+
+    function isVisible(el) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') != 'none';
+    }
+
+    function overlayObserver(mode) {
+      return new Promise(function(resolve) {
+        function listener() {
+          element.removeEventListener('iron-overlay-' + mode, listener);
+          resolve();
+        }
+        element.addEventListener('iron-overlay-' + mode, listener);
+      });
+    }
+
+    test('reviewer confirmation', function(done) {
+      var yesButton =
+          element.$$('.reviewerConfirmationButtons gr-button:first-child');
+      var noButton =
+          element.$$('.reviewerConfirmationButtons gr-button:last-child');
+
+      element._reviewerPendingConfirmation = null;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+
+      // Cause the confirmation dialog to display.
+      var observer = overlayObserver('opened');
+      var group = {
+        id: 'id',
+        name: 'name',
+        count: 10,
+      };
+      element._reviewerPendingConfirmation = {
+        group: group,
+      };
+
+      observer.then(function() {
+        assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+        observer = overlayObserver('closed');
+        MockInteractions.tap(noButton); // close the overlay
+        return observer;
+      }).then(function() {
+        assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+
+        // We should be focused on account entry input.
+        assert.equal(getActiveElement().id, 'input');
+
+        // No reviewer should have been added.
+        assert.deepEqual(element.$.reviewers.additions(), []);
+
+        // Reopen confirmation dialog.
+        observer = overlayObserver('opened');
+        element._reviewerPendingConfirmation = {
+          group: group,
+        };
+        return observer;
+      }).then(function() {
+        assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+        observer = overlayObserver('closed');
+        MockInteractions.tap(yesButton); // confirm the group
+        return observer;
+      }).then(function() {
+        assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+        assert.deepEqual(
+            element.$.reviewers.additions(),
+            [
+              {
+                group: {
+                  id: 'id',
+                  name: 'name',
+                  count: 10,
+                  confirmed: true,
+                  _group: true,
+                  _pendingAdd: true,
+                },
+              },
+            ]);
+
+        // We should be focused on account entry input.
+        assert.equal(getActiveElement().id, 'input');
+      }).then(done);
+    });
+
+    test('_getStorageLocation', function() {
+      var actual = element._getStorageLocation();
+      assert.equal(actual.changeNum, changeNum);
+      assert.equal(actual.patchNum, patchNum);
+      assert.equal(actual.path, '@change');
+    });
+
+    test('gets draft from storage on open', function() {
+      var storedDraft = 'hello world';
+      getDraftCommentStub.returns({message: storedDraft});
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, storedDraft);
+    });
+
+    test('blank if no stored draft', function() {
+      getDraftCommentStub.returns(null);
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, '');
+    });
+
+    test('updates stored draft on edits', function() {
+      var firstEdit = 'hello';
+      var location = element._getStorageLocation();
+
+      element.draft = firstEdit;
+      element.flushDebouncer('store');
+
+      assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+      element.draft = '';
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseDraftCommentStub.calledWith(location));
+    });
+
+    test('400 converts to human-readable server-error', function(done) {
+      sandbox.stub(window, 'fetch', function() {
+        var text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+          '"ccs":{"id2":{"error":"second error"}}}';
+        return Promise.resolve({
+          ok: false,
+          status: 400,
+          text: function() { return Promise.resolve(text); },
+        });
+      });
+
+      element.addEventListener('server-error', function(event) {
+        if (event.target !== element) {
+          return;
+        }
+        event.detail.response.text().then(function(body) {
+          assert.equal(body, 'first error, second error');
+        });
+      });
+      element.send().then(done);
+    });
+
+    test('ccs are displayed if NoteDb is enabled', function() {
+      function hasCc() {
+        flushAsynchronousOperations();
+        return !!element.$$('#ccs');
+      }
+
+      element.serverConfig = {};
+      assert.isFalse(hasCc());
+
+      element.serverConfig = {note_db_enabled: true};
+      assert.isTrue(hasCc());
+    });
+
+    test('filterReviewerSuggestion', function() {
+      var counter = 0;
+      function makeAccount() {
+        return {_account_id: counter++};
+      }
+      function makeGroup() {
+        return {id: counter++};
+      }
+
+      var owner = makeAccount();
+      var reviewer1 = makeAccount();
+      var reviewer2 = makeGroup();
+      var cc1 = makeAccount();
+      var cc2 = makeGroup();
+
+      element._owner = owner;
+      element._reviewers = [reviewer1, reviewer2];
+      element._ccs = [cc1, cc2];
+
+      assert.isTrue(
+          element._filterReviewerSuggestion({account: makeAccount()}));
+      assert.isTrue(element._filterReviewerSuggestion({group: makeGroup()}));
+
+      // Owner should be excluded.
+      assert.isFalse(element._filterReviewerSuggestion({account: owner}));
+
+      // Existing and pending reviewers should be excluded.
+      assert.isFalse(element._filterReviewerSuggestion({account: reviewer1}));
+      assert.isFalse(element._filterReviewerSuggestion({group: reviewer2}));
+
+      // Existing and pending CCs should be excluded.
+      assert.isFalse(element._filterReviewerSuggestion({account: cc1}));
+      assert.isFalse(element._filterReviewerSuggestion({group: cc2}));
+    });
+
+    test('_chooseFocusTarget', function() {
+      element._account = null;
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+      element._account = {_account_id: 1};
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+      element.change.owner = {_account_id: 2};
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+      element.change.owner._account_id = 1;
+      element.change._reviewers = null;
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+      element._reviewers = [];
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+      element._reviewers.push({});
+      assert.strictEqual(
+          element._chooseFocusTarget(), element.FocusTarget.BODY);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
new file mode 100644
index 0000000..435b7de
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -0,0 +1,76 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-reviewer-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .8;
+        pointer-events: none;
+      }
+      .autocompleteContainer {
+        position: relative;
+      }
+      .inputContainer {
+        display: flex;
+        margin-top: .25em;
+      }
+      .inputContainer input {
+        flex: 1;
+        font: inherit;
+      }
+      gr-account-chip {
+        margin-top: .3em;
+      }
+      .remove {
+        color: #999;
+      }
+      .remove {
+        font-size: .9em;
+      }
+      @media screen and (max-width: 50em), screen and (min-width: 75em) {
+        gr-account-chip:first-of-type {
+          margin-top: 0;
+        }
+      }
+    </style>
+    <template is="dom-repeat" items="[[_reviewers]]" as="reviewer">
+      <gr-account-chip class="reviewer" account="[[reviewer]]"
+          on-remove="_handleRemove"
+          data-account-id$="[[reviewer._account_id]]"
+          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
+      </gr-account-chip>
+    </template>
+    <div class="controlsContainer" hidden$="[[!mutable]]">
+      <gr-button
+          link
+          id="addReviewer"
+          class="addReviewer"
+          on-tap="_handleAddTap">[[_addLabel]]</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-reviewer-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
new file mode 100644
index 0000000..72a7c9b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -0,0 +1,145 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-reviewer-list',
+
+    /**
+     * Fired when the "Add reviewer..." button is tapped.
+     *
+     * @event show-reply-dialog
+     */
+
+    properties: {
+      change: Object,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      mutable: {
+        type: Boolean,
+        value: false,
+      },
+      reviewersOnly: {
+        type: Boolean,
+        value: false,
+      },
+      ccsOnly: {
+        type: Boolean,
+        value: false,
+      },
+
+      _reviewers: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _showInput: {
+        type: Boolean,
+        value: false,
+      },
+      _addLabel: {
+        type: String,
+        computed: '_computeAddLabel(ccsOnly)',
+      },
+
+      // Used for testing.
+      _lastAutocompleteRequest: Object,
+      _xhrPromise: Object,
+    },
+
+    observers: [
+      '_reviewersChanged(change.reviewers.*, change.owner)',
+    ],
+
+    _reviewersChanged: function(changeRecord, owner) {
+      var result = [];
+      var reviewers = changeRecord.base;
+      for (var key in reviewers) {
+        if (this.reviewersOnly && key !== 'REVIEWER') {
+          continue;
+        }
+        if (this.ccsOnly && key !== 'CC') {
+          continue;
+        }
+        if (key === 'REVIEWER' || key === 'CC') {
+          result = result.concat(reviewers[key]);
+        }
+      }
+      this._reviewers = result.filter(function(reviewer) {
+        return reviewer._account_id != owner._account_id;
+      });
+    },
+
+    _computeCanRemoveReviewer: function(reviewer, mutable) {
+      if (!mutable) { return false; }
+
+      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+        if (this.change.removable_reviewers[i]._account_id ==
+            reviewer._account_id) {
+          return true;
+        }
+      }
+      return false;
+    },
+
+    _handleRemove: function(e) {
+      e.preventDefault();
+      var target = Polymer.dom(e).rootTarget;
+      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this.disabled = true;
+      this._xhrPromise =
+          this._removeReviewer(accountID).then(function(response) {
+        this.disabled = false;
+        if (!response.ok) { return response; }
+
+        var reviewers = this.change.reviewers;
+        ['REVIEWER', 'CC'].forEach(function(type) {
+          reviewers[type] = reviewers[type] || [];
+          for (var i = 0; i < reviewers[type].length; i++) {
+            if (reviewers[type][i]._account_id == accountID) {
+              this.splice('change.reviewers.' + type, i, 1);
+              break;
+            }
+          }
+        }, this);
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
+      }.bind(this));
+    },
+
+    _handleAddTap: function(e) {
+      e.preventDefault();
+      var value = {};
+      if (this.reviewersOnly) {
+        value.reviewersOnly = true;
+      }
+      if (this.ccsOnly) {
+        value.ccsOnly = true;
+      }
+      this.fire('show-reply-dialog', {value: value});
+    },
+
+    _removeReviewer: function(id) {
+      return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+    },
+
+    _computeAddLabel: function(ccsOnly) {
+      return ccsOnly ? 'Add CC' : 'Add reviewer';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
new file mode 100644
index 0000000..6c6125c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -0,0 +1,183 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-reviewer-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reviewer-list></gr-reviewer-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reviewer-list tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        removeChangeReviewer: function() {
+          return Promise.resolve({ok: true});
+        },
+      });
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('controls hidden on immutable element', function() {
+      element.mutable = false;
+      assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden'));
+      element.mutable = true;
+      assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
+    });
+
+    test('add reviewer button opens reply dialog', function(done) {
+      element.addEventListener('show-reply-dialog', function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('.addReviewer'));
+    });
+
+    test('only show remove for removable reviewers', function() {
+      element.mutable = true;
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          'REVIEWER': [
+            {
+              _account_id: 2,
+              name: 'Bojack Horseman',
+              email: 'SecretariatRulez96@hotmail.com',
+            },
+            {
+              _account_id: 3,
+              name: 'Pinky Penguin',
+            },
+          ],
+          'CC': [
+            {
+              _account_id: 4,
+              name: 'Diane Nguyen',
+              email: 'macarthurfellow2B@juno.com',
+            },
+          ]
+        },
+        removable_reviewers: [
+          {
+            _account_id: 3,
+            name: 'Pinky Penguin',
+          },
+          {
+            _account_id: 4,
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com',
+          },
+        ]
+      };
+      flushAsynchronousOperations();
+      var chips =
+          Polymer.dom(element.root).querySelectorAll('gr-account-chip');
+      assert.equal(chips.length, 3);
+      Array.from(chips).forEach(function(el) {
+        var accountID = parseInt(el.getAttribute('data-account-id'), 10);
+        assert.ok(accountID);
+
+        var buttonEl = el.$$('gr-button');
+        assert.isNotNull(buttonEl);
+        if (accountID == 2) {
+          assert.isTrue(buttonEl.hasAttribute('hidden'));
+        } else {
+          assert.isFalse(buttonEl.hasAttribute('hidden'));
+        }
+      });
+    });
+
+    test('tracking reviewers and ccs', function() {
+      var counter = 0;
+      function makeAccount() {
+        return {_account_id: counter++};
+      }
+
+      var owner = makeAccount();
+      var reviewer = makeAccount();
+      var cc = makeAccount();
+      var reviewers = {
+        REMOVED: [makeAccount()],
+        REVIEWER: [owner, reviewer],
+        CC: [owner, cc],
+      };
+
+      element.ccsOnly = false;
+      element.reviewersOnly = false;
+      element.change = {
+        owner: owner,
+        reviewers: reviewers,
+      };
+      assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+      element.reviewersOnly = true;
+      element.change = {
+        owner: owner,
+        reviewers: reviewers,
+      };
+      assert.deepEqual(element._reviewers, [reviewer]);
+
+      element.ccsOnly = true;
+      element.reviewersOnly = false;
+      element.change = {
+        owner: owner,
+        reviewers: reviewers,
+      };
+      assert.deepEqual(element._reviewers, [cc]);
+    });
+
+    test('_handleAddTap passes mode with event', function() {
+      var fireStub = sandbox.stub(element, 'fire');
+      var e = {preventDefault: function() {}};
+
+      element.ccsOnly = false;
+      element.reviewersOnly = false;
+      element._handleAddTap(e);
+      assert.isTrue(fireStub.calledWith('show-reply-dialog', {value: {}}));
+
+      element.reviewersOnly = true;
+      element._handleAddTap(e);
+      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
+          {value: {reviewersOnly: true}}));
+
+      element.ccsOnly = true;
+      element.reviewersOnly = false;
+      element._handleAddTap(e);
+      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
+          {value: {ccsOnly: true}}));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
new file mode 100644
index 0000000..1d31c12
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -0,0 +1,94 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-account-dropdown">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      button {
+        background: none;
+        border: none;
+        font: inherit;
+        padding: .3em 0;
+      }
+      gr-avatar {
+        height: 2em;
+        width: 2em;
+        vertical-align: middle;
+      }
+      ul {
+        list-style: none;
+      }
+      ul .accountName {
+        font-weight: bold;
+      }
+      li .accountInfo,
+      li a {
+        display: block;
+        padding: .85em 1em;
+      }
+      li a:link,
+      li a:visited {
+        color: #00e;
+        text-decoration: none;
+      }
+      li a:hover {
+        background-color: #6B82D6;
+        color: #fff;
+      }
+    </style>
+    <gr-button link class="dropdown-trigger" id="trigger"
+        on-tap="_showDropdownTapHandler">
+      <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
+      <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
+          image-size="56"></gr-avatar>
+    </gr-button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="25"
+        horizontal-align="right">
+      <div class="dropdown-content">
+        <ul>
+          <li>
+            <div class="accountInfo">
+              <div class="accountName">[[account.name]]</div>
+              <div>[[account.email]]</div>
+            </div>
+          </li>
+          <li><a href$="[[_computeRelativeURL('/settings')]]">Settings</a></li>
+          <li><a href$="[[_computeRelativeURL('/switch-account')]]">Switch account</a></li>
+          <li><a href$="[[_computeRelativeURL('/logout')]]">Sign out</a></li>
+        </ul>
+      </div>
+    </iron-dropdown>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
new file mode 100644
index 0000000..ad944dc
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-dropdown',
+
+    properties: {
+      account: Object,
+      _hasAvatars: Boolean,
+    },
+
+    attached: function() {
+      this.$.restAPI.getConfig().then(function(cfg) {
+        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }.bind(this));
+
+      this.listen(this.$.dropdown, 'tap', '_handleDropdownTap');
+    },
+
+    _handleDropdownTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _computeRelativeURL: function(path) {
+      return '//' + window.location.host + path;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
new file mode 100644
index 0000000..3ae3b14
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-dropdown.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-dropdown></gr-account-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-dropdown tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('tap on trigger opens menu', function() {
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
new file mode 100644
index 0000000..80f293d
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -0,0 +1,27 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-alert/gr-alert.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-error-manager">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-error-manager.js"></script>
+</dom-module>
+
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
new file mode 100644
index 0000000..7a9c4f9
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -0,0 +1,154 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var HIDE_ALERT_TIMEOUT_MS = 5000;
+  var CHECK_SIGN_IN_INTERVAL_MS = 60000;
+  var SIGN_IN_WIDTH_PX = 690;
+  var SIGN_IN_HEIGHT_PX = 500;
+
+  Polymer({
+    is: 'gr-error-manager',
+
+    properties: {
+      _alertElement: Element,
+      _hideAlertHandle: Number,
+    },
+
+    attached: function() {
+      this.listen(document, 'server-error', '_handleServerError');
+      this.listen(document, 'network-error', '_handleNetworkError');
+    },
+
+    detached: function() {
+      this._clearHideAlertHandle();
+      this.unlisten(document, 'server-error', '_handleServerError');
+      this.unlisten(document, 'network-error', '_handleNetworkError');
+    },
+
+    _handleServerError: function(e) {
+      if (e.detail.response.status === 403) {
+        this._getLoggedIn().then(function(loggedIn) {
+          if (loggedIn) {
+            // The app was logged at one point and is now getting auth errors.
+            // This indicates the auth token is no longer valid.
+            this._showAuthErrorAlert();
+          }
+        }.bind(this));
+      } else {
+        e.detail.response.text().then(function(text) {
+          this._showAlert('Server error: ' + text);
+        }.bind(this));
+      }
+    },
+
+    _handleNetworkError: function(e) {
+      this._showAlert('Server unavailable');
+      console.error(e.detail.error.message);
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _showAlert: function(text) {
+      if (this._alertElement) { return; }
+
+      this._clearHideAlertHandle();
+      this._hideAlertHandle =
+        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
+      var el = this._createToastAlert();
+      el.show(text);
+      this._alertElement = el;
+    },
+
+    _hideAlert: function() {
+      if (!this._alertElement) { return; }
+
+      this._alertElement.hide();
+      this._alertElement = null;
+    },
+
+    _clearHideAlertHandle: function() {
+      if (this._hideAlertHandle != null) {
+        this.cancelAsync(this._hideAlertHandle);
+        this._hideAlertHandle = null;
+      }
+    },
+
+    _showAuthErrorAlert: function() {
+      // TODO(viktard): close alert if it's not for auth error.
+      if (this._alertElement) { return; }
+
+      this._alertElement = this._createToastAlert();
+      this._alertElement.show('Auth error', 'Refresh credentials.');
+      this.listen(this._alertElement, 'action', '_createLoginPopup');
+
+      if (typeof document.hidden !== undefined) {
+        this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      }
+      this._requestCheckLoggedIn();
+      if (!document.hidden) {
+        this._handleVisibilityChange();
+      }
+    },
+
+    _createToastAlert: function() {
+      var el = document.createElement('gr-alert');
+      el.toast = true;
+      return el;
+    },
+
+    _handleVisibilityChange: function() {
+      if (!document.hidden) {
+        this.flushDebouncer('checkLoggedIn');
+      }
+    },
+
+    _requestCheckLoggedIn: function() {
+      this.debounce(
+        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+    },
+
+    _checkSignedIn: function() {
+      this.$.restAPI.refreshCredentials().then(function(isLoggedIn) {
+        if (isLoggedIn) {
+          this._handleCredentialRefresh();
+        } else {
+          this._requestCheckLoggedIn();
+        }
+      }.bind(this));
+    },
+
+    _createLoginPopup: function(e) {
+      var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+      var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+      var options = [
+        'width=' + SIGN_IN_WIDTH_PX,
+        'height=' + SIGN_IN_HEIGHT_PX,
+        'left=' + left,
+        'top=' + top,
+      ];
+      window.open('/login/%3FcloseAfterLogin', '_blank', options.join(','));
+    },
+
+    _handleCredentialRefresh: function() {
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+      this.unlisten(this._alertElement, 'action', '_createLoginPopup');
+      this._hideAlert();
+      this._showAlert('Credentials refreshed.');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
new file mode 100644
index 0000000..f633a7e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-error-manager</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-error-manager.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-error-manager></gr-error-manager>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-error-manager tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('show auth error', function(done) {
+      var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('server-error', {response: {status: 403}});
+      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+        assert.isTrue(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('show normal server error', function(done) {
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      textSpy.lastCall.returnValue.then(function() {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server error: ZOMG'));
+        done();
+      });
+    });
+
+    test('show network error', function(done) {
+      var consoleErrorStub = sandbox.stub(console, 'error');
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      element.fire('network-error', {error: new Error('ZOMG')});
+      flush(function() {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server unavailable'));
+        assert.isTrue(consoleErrorStub.calledOnce);
+        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+        done();
+      });
+    });
+
+    test('show auth refresh toast', function(done) {
+      var refreshStub = sandbox.stub(element.$.restAPI, 'refreshCredentials',
+          function() { return Promise.resolve(true); });
+      var toastSpy = sandbox.spy(element, '_createToastAlert');
+      var windowOpen = sandbox.stub(window, 'open');
+      element.fire('server-error', {response: {status: 403}});
+      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+        assert.isTrue(toastSpy.called);
+        var toast = toastSpy.lastCall.returnValue;
+        assert.isOk(toast);
+        assert.include(
+            Polymer.dom(toast.root).textContent, 'Auth error');
+        assert.include(
+            Polymer.dom(toast.root).textContent, 'Refresh credentials.');
+
+        assert.isFalse(windowOpen.called);
+        toast.fire('action');
+        assert.isTrue(windowOpen.called);
+
+        var hideToastSpy = sandbox.spy(toast, 'hide');
+
+        assert.isTrue(refreshStub.called);
+        element.flushDebouncer('checkLoggedIn');
+        flush(function() {
+          assert.isTrue(refreshStub.called);
+          assert.isTrue(hideToastSpy.called);
+
+          assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+          toast = toastSpy.lastCall.returnValue;
+          assert.isOk(toast);
+          assert.include(
+              Polymer.dom(toast.root).textContent, 'Credentials refreshed');
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
new file mode 100644
index 0000000..7291199
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -0,0 +1,312 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-keyboard-shortcuts-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      header{
+        padding: 1em;
+      }
+      main {
+        display: flex;
+        padding: 0 2em 2em;
+      }
+      header {
+        align-items: center;
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        justify-content: space-between;
+      }
+      table:last-of-type {
+        margin-left: 3em;
+      }
+      td {
+        padding: .2em 0;
+      }
+      td:first-child {
+        padding-right: .5em;
+        text-align: right;
+      }
+      .header {
+        font-weight: bold;
+        padding-top: 1em;
+      }
+      .key {
+        display: inline-block;
+        font-weight: bold;
+        border-radius: 3px;
+        background-color: #f1f2f3;
+        padding: .1em .5em;
+        text-align: center;
+      }
+      .modifier {
+        font-weight: normal;
+      }
+    </style>
+    <header>
+      <h3>Keyboard shortcuts</h3>
+      <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+    </header>
+    <main>
+      <table>
+        <tbody>
+          <tr>
+            <td></td><td class="header">Everywhere</td>
+          </tr>
+          <tr>
+            <td><span class="key">/</span></td>
+            <td>Search</td>
+          </tr>
+          <tr>
+            <td><span class="key">?</span></td>
+            <td>Show this dialog</td>
+          </tr>
+        </tbody>
+        <!-- Change View -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-change-view')]]" hidden>
+          <tr>
+            <td></td><td class="header">Navigation</td>
+          </tr>
+          <tr>
+            <td><span class="key">]</span></td>
+            <td>Show first file</td>
+          </tr>
+          <tr>
+            <td><span class="key">[</span></td>
+            <td>Show last file</td>
+          </tr>
+          <tr>
+            <td><span class="key">u</span></td>
+            <td>Up to change list</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">i</span>
+            </td>
+            <td>Show/hide inline diffs</td>
+          </tr>
+        </tbody>
+        <!-- Diff View -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
+          <tr>
+            <td></td><td class="header">Navigation</td>
+          </tr>
+          <tr>
+            <td><span class="key">]</span></td>
+            <td>Show next file</td>
+          </tr>
+          <tr>
+            <td><span class="key">[</span></td>
+            <td>Show previous file</td>
+          </tr>
+          <tr>
+            <td><span class="key">u</span></td>
+            <td>Up to change</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <table>
+        <!-- Change List and Dashboard -->
+        <tbody hidden$="[[!_computeInChangeListView(view)]]" hidden>
+          <tr>
+            <td></td><td class="header">Change list</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span></td>
+            <td>Select next change</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span></td>
+            <td>Show previous change</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">Enter</span> or
+              <span class="key">o</span>
+            </td>
+            <td>Show selected change</td>
+          </tr>
+        </tbody>
+        <!-- Change View -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-change-view')]]" hidden>
+          <tr>
+            <td></td><td class="header">Actions</td>
+          </tr>
+          <tr>
+            <td><span class="key">a</span></td>
+            <td>Review and publish comments</td>
+          </tr>
+          <tr>
+            <td></td><td class="header">File list</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
+            <td>Select next file</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
+            <td>Select previous file</td>
+          </tr>
+          <tr>
+            <td><span class="key">Enter</span> or <span class="key">o</span></td>
+            <td>Show selected file</td>
+          </tr>
+          <tr>
+            <td></td><td class="header">Diffs</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
+            <td>Go to next line</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
+            <td>Go to previous line</td>
+          </tr>
+          <tr>
+            <td><span class="key">n</span></td>
+            <td>Go to next diff chunk</td>
+          </tr>
+          <tr>
+            <td><span class="key">p</span></td>
+            <td>Go to previous diff chunk</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">n</span>
+            </td>
+            <td>Go to next comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">p</span>
+            </td>
+            <td>Go to previous comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">←</span>
+            </td>
+            <td>Select left pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">→</span>
+            </td>
+            <td>Select right pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">a</span>
+            </td>
+            <td>Hide/show left diff</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">c</span>
+            </td>
+            <td>Draft new comment</td>
+          </tr>
+        </tbody>
+        <!-- Diff View -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
+          <tr>
+            <td></td><td class="header">Actions</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
+            <td>Show next line</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
+            <td>Show previous line</td>
+          </tr>
+          <tr>
+            <td><span class="key">n</span></td>
+            <td>Show next diff chunk</td>
+          </tr>
+          <tr>
+            <td><span class="key">p</span></td>
+            <td>Show previous diff chunk</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">n</span>
+            </td>
+            <td>Show next comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">p</span>
+            </td>
+            <td>Show previous comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">←</span>
+            </td>
+            <td>Select left pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">→</span>
+            </td>
+            <td>Select right pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">a</span>
+            </td>
+            <td>Hide/show left diff</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">c</span>
+            </td>
+            <td>Draft new comment</td>
+          </tr>
+          <tr>
+            <td><span class="key">a</span></td>
+            <td>Review and publish comments</td>
+          </tr>
+          <tr>
+            <td><span class="key">,</span></td>
+            <td>Show diff preferences</td>
+          </tr>
+        </tbody>
+      </table>
+    </main>
+    <footer></footer>
+  </template>
+  <script src="gr-keyboard-shortcuts-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
new file mode 100644
index 0000000..7ed5012
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-keyboard-shortcuts-dialog',
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      view: String,
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    _computeInView: function(currentView, view) {
+      return view == currentView;
+    },
+
+    _computeInChangeListView: function(currentView) {
+      return currentView == 'gr-change-list-view' ||
+          currentView == 'gr-dashboard-view';
+    },
+
+    _handleCloseTap: function(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
new file mode 100644
index 0000000..930c8cf
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -0,0 +1,159 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
+<link rel="import" href="../gr-search-bar/gr-search-bar.html">
+
+<dom-module id="gr-main-header">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      nav {
+        align-items: center;
+        display: flex;
+      }
+      .bigTitle {
+        color: var(--primary-text-color);
+        font-size: 1.75em;
+        text-decoration: none;
+      }
+      .bigTitle:hover {
+        text-decoration: underline;
+      }
+      ul {
+        list-style: none;
+      }
+      .links {
+        margin-left: 1em;
+      }
+      .links ul {
+        display: none;
+      }
+      .links > li {
+        cursor: default;
+        display: inline-block;
+        margin-left: 1em;
+        padding: .4em 0;
+        position: relative;
+      }
+      .links li:hover ul {
+        background-color: #fff;
+        box-shadow: 0 1px 1px rgba(0, 0, 0, .3);
+        display: block;
+        left: -.75em;
+        position: absolute;
+        top: 2em;
+        z-index: 1000;
+      }
+      .links li ul li a:link,
+      .links li ul li a:visited {
+        color: #00e;
+        display: block;
+        padding: .5em .75em;
+        text-decoration: none;
+        white-space: nowrap;
+      }
+      .links li ul li:hover a {
+        background-color: var(--selection-background-color);
+      }
+      .linksTitle {
+        display: inline-block;
+        padding-right: 1em;
+        position: relative;
+      }
+      .downArrow {
+        border-left: .36em solid transparent;
+        border-right: .36em solid transparent;
+        border-top: .36em solid #ccc;
+        height: 0;
+        position: absolute;
+        right: 0;
+        top: calc(50% - .1em);
+        width: 0;
+      }
+      .links li:hover .downArrow {
+        border-top-color: #666;
+      }
+      .rightItems {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      gr-search-bar {
+        margin-left: .5em;
+        width: 500px;
+      }
+      .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
+      .accountContainer:not(.loggedIn):not(.loggedOut) gr-account-dropdown,
+      .accountContainer.loggedIn .loginButton,
+      .accountContainer.loggedOut gr-account-dropdown {
+        display: none;
+      }
+      .accountContainer {
+        align-items: center;
+        display: flex;
+        margin-left: var(--default-horizontal-margin);
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      @media screen and (max-width: 50em) {
+        .bigTitle {
+          font-size: 14px;
+          font-weight: bold;
+        }
+        gr-search-bar {
+          display: none;
+        }
+        .accountContainer {
+          margin-left: .5em !important;
+        }
+      }
+    </style>
+    <nav>
+      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">PolyGerrit</a>
+      <ul class="links">
+        <template is="dom-repeat" items="[[_links]]" as="linkGroup">
+          <li>
+            <span class="linksTitle">
+              [[linkGroup.title]] <i class="downArrow"></i>
+            </span>
+            <ul>
+              <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
+                <li><a href$="[[link.url]]">[[link.name]]</a></li>
+              </template>
+            </ul>
+          </li>
+        </template>
+      </ul>
+      <div class="rightItems">
+        <gr-search-bar value="{{searchQuery}}" role="search"></gr-search-bar>
+        <div class="accountContainer" id="accountContainer">
+          <a class="loginButton" href$="[[_loginURL]]" on-tap="_loginTapHandler">Sign in</a>
+          <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+        </div>
+      </div>
+    </nav>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-main-header.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
new file mode 100644
index 0000000..6fc3cc1
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var DEFAULT_LINKS = [{
+    title: 'Changes',
+    links: [
+      {
+        url: '/q/status:open',
+        name: 'Open',
+      },
+      {
+        url: '/q/status:merged',
+        name: 'Merged',
+      },
+      {
+        url: '/q/status:abandoned',
+        name: 'Abandoned',
+      },
+    ],
+  }];
+
+  Polymer({
+    is: 'gr-main-header',
+
+    hostAttributes: {
+      role: 'banner'
+    },
+
+    properties: {
+      searchQuery: {
+        type: String,
+        notify: true,
+      },
+
+      _account: Object,
+      _defaultLinks: {
+        type: Array,
+        value: function() {
+          return DEFAULT_LINKS;
+        },
+      },
+      _links: {
+        type: Array,
+        computed: '_computeLinks(_defaultLinks, _userLinks)',
+      },
+      _loginURL: {
+        type: String,
+        value: '/login',
+      },
+      _userLinks: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    observers: [
+      '_accountLoaded(_account)',
+    ],
+
+    attached: function() {
+      this._loadAccount();
+      this.listen(window, 'location-change', '_handleLocationChange');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _handleLocationChange: function(e) {
+      this._loginURL = '/login/' + encodeURIComponent(
+          window.location.pathname +
+          window.location.search +
+          window.location.hash);
+    },
+
+    _computeRelativeURL: function(path) {
+      return '//' + window.location.host + path;
+    },
+
+    _computeLinks: function(defaultLinks, userLinks) {
+      var links = defaultLinks.slice();
+      if (userLinks && userLinks.length > 0) {
+        links.push({
+          title: 'Your',
+          links: userLinks,
+        });
+      }
+      return links;
+    },
+
+    _loadAccount: function() {
+      this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+        this.$.accountContainer.classList.toggle('loggedIn', account != null);
+        this.$.accountContainer.classList.toggle('loggedOut', account == null);
+      }.bind(this));
+    },
+
+    _accountLoaded: function(account) {
+      if (!account) { return; }
+
+      this.$.restAPI.getPreferences().then(function(prefs) {
+        this._userLinks =
+            prefs.my.map(this._stripHashPrefix).filter(this._isSupportedLink);
+      }.bind(this));
+    },
+
+    _stripHashPrefix: function(linkObj) {
+      if (linkObj.url.indexOf('#') === 0) {
+        linkObj.url = linkObj.url.slice(1);
+      }
+      return linkObj;
+    },
+
+    _isSupportedLink: function(linkObj) {
+      // Groups are not yet supported.
+      return linkObj.url.indexOf('/groups') !== 0;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
new file mode 100644
index 0000000..0b40d87
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-main-header</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-main-header.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-main-header></gr-main-header>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-main-header tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-main-header', {
+        _loadAccount: function() {},
+      });
+      element = fixture('basic');
+    });
+
+    test('strip hash prefix', function() {
+      assert.deepEqual([
+        {url: '#/q/owner:self+is:draft'},
+        {url: 'https://awesometown.com/#hashyhash'},
+      ].map(element._stripHashPrefix),
+      [
+        {url: '/q/owner:self+is:draft'},
+        {url: 'https://awesometown.com/#hashyhash'},
+      ]);
+    });
+
+    test('filter unsupported urls', function() {
+      assert.deepEqual([
+        {url: '/q/owner:self+is:draft'},
+        {url: '/c/331788/'},
+        {url: '/groups/self'},
+        {url: 'https://awesometown.com/#hashyhash'},
+      ].filter(element._isSupportedLink),
+      [
+        {url: '/q/owner:self+is:draft'},
+        {url: '/c/331788/'},
+        {url: 'https://awesometown.com/#hashyhash'},
+      ]);
+    });
+
+    test('user links', function() {
+      var defaultLinks = [{
+        title: 'Faves',
+        links: [{
+          name: 'Pinterest',
+          url: 'https://pinterest.com',
+        }],
+      }];
+      var userLinks = [{
+        name: 'Facebook',
+        url: 'https://facebook.com',
+      }];
+      assert.deepEqual(element._computeLinks(defaultLinks, []), defaultLinks);
+      assert.deepEqual(element._computeLinks(defaultLinks, userLinks),
+          defaultLinks.concat({
+            title: 'Your',
+            links: userLinks,
+          }));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
new file mode 100644
index 0000000..2971ed2
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -0,0 +1,20 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="gr-router.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
new file mode 100644
index 0000000..d11d438
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -0,0 +1,151 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // Polymer makes `app` intrinsically defined on the window by virtue of the
+  // custom element having the id "app", but it is made explicit here.
+  var app = document.querySelector('#app');
+  var restAPI = document.createElement('gr-rest-api-interface');
+
+  window.addEventListener('WebComponentsReady', function() {
+    // Middleware
+    page(function(ctx, next) {
+      document.body.scrollTop = 0;
+
+      // Fire asynchronously so that the URL is changed by the time the event
+      // is processed.
+      app.async(function() {
+        app.fire('location-change');
+      }, 1);
+      next();
+    });
+
+    function loadUser(ctx, next) {
+      restAPI.getLoggedIn().then(function() {
+        next();
+      });
+    }
+
+    // Routes.
+    page('/', loadUser, function(data) {
+      if (data.querystring.match(/^closeAfterLogin/)) {
+        // Close child window on redirect after login.
+        window.close();
+      }
+      // For backward compatibility with GWT links.
+      if (data.hash) {
+        page.redirect(data.hash);
+        return;
+      }
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          page.redirect('/dashboard/self');
+        } else {
+          page.redirect('/q/status:open');
+        }
+      });
+    });
+
+    page('/dashboard/(.*)', loadUser, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          data.params.view = 'gr-dashboard-view';
+          app.params = data.params;
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    function queryHandler(data) {
+      data.params.view = 'gr-change-list-view';
+      app.params = data.params;
+    }
+
+    page('/q/:query,:offset', queryHandler);
+    page('/q/:query', queryHandler);
+
+    page(/^\/(\d+)\/?/, function(ctx) {
+      page.redirect('/c/' + encodeURIComponent(ctx.params[0]));
+    });
+
+    function normalizePatchRangeParams(params) {
+      if (params.basePatchNum && !params.patchNum) {
+        params.patchNum = params.basePatchNum;
+        params.basePatchNum = null;
+      }
+    }
+
+    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>].
+    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?$/, function(ctx) {
+      // Parameter order is based on the regex group number matched.
+      var params = {
+        changeNum: ctx.params[0],
+        basePatchNum: ctx.params[3],
+        patchNum: ctx.params[5],
+        view: 'gr-change-view',
+      };
+
+      // Don't allow diffing the same patch number against itself.
+      if (params.basePatchNum != null &&
+          params.basePatchNum === params.patchNum) {
+        page.redirect('/c/' +
+            encodeURIComponent(params.changeNum) +
+            '/' +
+            encodeURIComponent(params.patchNum) +
+            '/');
+        return;
+      }
+      normalizePatchRangeParams(params);
+      app.params = params;
+    });
+
+    // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+    page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) {
+      // Parameter order is based on the regex group number matched.
+      var params = {
+        changeNum: ctx.params[0],
+        basePatchNum: ctx.params[2],
+        patchNum: ctx.params[4],
+        path: ctx.params[5],
+        view: 'gr-diff-view',
+      };
+      // Don't allow diffing the same patch number against itself.
+      if (params.basePatchNum === params.patchNum) {
+        page.redirect('/c/' +
+            encodeURIComponent(params.changeNum) +
+            '/' +
+            encodeURIComponent(params.patchNum) +
+            '/' +
+            encodeURIComponent(params.path));
+        return;
+      }
+      normalizePatchRangeParams(params);
+      app.params = params;
+    });
+
+    page(/^\/settings\/?/, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          app.params = {view: 'gr-settings-view'};
+        } else {
+          page.show('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    page.start();
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
new file mode 100644
index 0000000..fecb376
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -0,0 +1,59 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-search-bar">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      form {
+        display: flex;
+      }
+      gr-autocomplete {
+        background-color: white;
+        border: 1px solid #d1d2d3;
+        border-radius: 2px 0 0 2px;
+        flex: 1;
+        font: inherit;
+        outline: none;
+        padding: 0 .25em 0 .25em;
+      }
+      gr-button {
+        background-color: #f1f2f3;
+        border-radius: 0 2px 2px 0;
+        border-left-width: 0;
+      }
+    </style>
+    <form>
+      <gr-autocomplete
+          id="searchInput"
+          text="{{_inputVal}}"
+          query="[[query]]"
+          on-commit="_handleInputCommit"
+          allowNonSuggestedValues
+          multi
+          borderless></gr-autocomplete>
+      <gr-button id="searchButton">Search</gr-button>
+    </form>
+  </template>
+  <script src="gr-search-bar.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
new file mode 100644
index 0000000..8e52f8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -0,0 +1,171 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // Possible static search options for auto complete.
+  var SEARCH_OPERATORS = [
+    'added',
+    'age',
+    'age:1week', // Give an example age
+    'author',
+    'branch',
+    'bug',
+    'change',
+    'comment',
+    'commentby',
+    'commit',
+    'committer',
+    'conflicts',
+    'deleted',
+    'delta',
+    'file',
+    'from',
+    'has',
+    'has:draft',
+    'has:edit',
+    'has:star',
+    'has:stars',
+    'intopic',
+    'is',
+    'is:abandoned',
+    'is:closed',
+    'is:draft',
+    'is:mergeable',
+    'is:merged',
+    'is:open',
+    'is:owner',
+    'is:pending',
+    'is:reviewed',
+    'is:reviewer',
+    'is:starred',
+    'is:watched',
+    'label',
+    'message',
+    'owner',
+    'ownerin',
+    'parentproject',
+    'project',
+    'projects',
+    'query',
+    'ref',
+    'reviewedby',
+    'reviewer',
+    'reviewer:self',
+    'reviewerin',
+    'size',
+    'star',
+    'status',
+    'status:abandoned',
+    'status:closed',
+    'status:draft',
+    'status:merged',
+    'status:open',
+    'status:pending',
+    'status:reviewed',
+    'topic',
+    'tr',
+  ];
+
+  Polymer({
+    is: 'gr-search-bar',
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    listeners: {
+      'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
+    },
+
+    properties: {
+      value: {
+        type: String,
+        value: '',
+        notify: true,
+        observer: '_valueChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      query: {
+        type: Function,
+        value: function() {
+          return this._getSearchSuggestions.bind(this);
+        },
+      },
+      _inputVal: String,
+    },
+
+    _valueChanged: function(value) {
+      this._inputVal = value;
+    },
+
+    _handleInputCommit: function(e) {
+      this._preventDefaultAndNavigateToInputVal(e);
+    },
+
+    _preventDefaultAndNavigateToInputVal: function(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.blur();
+      // @see Issue 4255.
+      page.show('/q/' + encodeURIComponent(encodeURIComponent(this._inputVal)));
+    },
+
+    // TODO(kaspern): Flesh this out better.
+    _makeSuggestion: function(str) {
+      return {
+        name: str,
+        value: str,
+      };
+    },
+
+    // TODO(kaspern): Expand support for more complicated autocomplete features.
+    _getSearchSuggestions: function(input) {
+      return Promise.resolve(SEARCH_OPERATORS).then(function(operators) {
+        if (!operators) { return []; }
+        var lowerCaseInput = input
+            .substring(input.lastIndexOf(' ') + 1)
+            .toLowerCase();
+        return operators
+            .filter(function(operator) {
+              // Disallow autocomplete values that exactly match the whole str.
+              var opContainsInput = operator.indexOf(lowerCaseInput) !== -1;
+              var inputContainsOp = lowerCaseInput.indexOf(operator) !== -1;
+              return opContainsInput && !inputContainsOp;
+            })
+            // Prioritize results that start with the input.
+            .sort(function(operator) {
+              return operator.indexOf(lowerCaseInput);
+            })
+            .map(this._makeSuggestion);
+      }.bind(this));
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      switch (e.keyCode) {
+        case 191:  // '/' or '?' with shift key.
+          // TODO(andybons): Localization using e.key/keypress event.
+          if (e.shiftKey) { break; }
+          e.preventDefault();
+          var s = this.$.searchInput;
+          s.focus();
+          s.setSelectionRange(0, s.value.length);
+          break;
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
new file mode 100644
index 0000000..0c16774
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-search-bar</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-search-bar.html">
+<script src="../../../scripts/util.js"></script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-search-bar></gr-search-bar>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-search-bar tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('value is propagated to _inputVal', function() {
+      element.value = 'foo';
+      assert.equal(element._inputVal, 'foo');
+    });
+
+    function getActiveElement() {
+      return document.activeElement.shadowRoot ?
+          document.activeElement.shadowRoot.activeElement :
+          document.activeElement;
+    }
+
+    test('tap on search button triggers nav', function(done) {
+      sinon.stub(page, 'show', function() {
+        page.show.restore();
+        assert.notEqual(getActiveElement(), element.$.searchInput);
+        assert.notEqual(getActiveElement(), element.$.searchButton);
+        done();
+      });
+      MockInteractions.tap(element.$.searchButton);
+    });
+
+    test('enter in search input triggers nav', function(done) {
+      sinon.stub(page, 'show', function() {
+        page.show.restore();
+        assert.notEqual(getActiveElement(), element.$.searchInput);
+        assert.notEqual(getActiveElement(), element.$.searchButton);
+        done();
+      });
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+    });
+
+    test('search query should be double-escaped', function() {
+      var showStub = sinon.stub(page, 'show');
+      element.$.searchInput.text = 'fate/stay';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
+      showStub.restore();
+    });
+
+    test('_getSearchSuggestions returns proper set of suggestions',
+        function(done) {
+      element._getSearchSuggestions('is:o')
+          .then(function(suggestions) {
+            assert.equal(suggestions[0].name, 'is:open');
+            assert.equal(suggestions[0].value, 'is:open');
+            assert.equal(suggestions[1].name, 'is:owner');
+            assert.equal(suggestions[1].value, 'is:owner');
+          })
+          .then(function() {
+            element._getSearchSuggestions('asdasdasdasd')
+                .then(function(suggestions) {
+                  assert.equal(suggestions.length, 0);
+                  done();
+                });
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
new file mode 100644
index 0000000..6b1e59e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -0,0 +1,101 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window, GrDiffBuilderSideBySide) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffBuilderImage) { return; }
+
+  function GrDiffBuilderImage(diff, comments, prefs, outputEl, baseImage,
+      revisionImage) {
+    GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl, []);
+    this._baseImage = baseImage;
+    this._revisionImage = revisionImage;
+  }
+
+  GrDiffBuilderImage.prototype = Object.create(
+      GrDiffBuilderSideBySide.prototype);
+  GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
+
+  GrDiffBuilderImage.prototype.renderDiffImages = function() {
+    var section = this._createElement('tbody', 'image-diff');
+
+    this._emitImagePair(section);
+    this._emitImageLabels(section);
+
+    this._outputEl.appendChild(section);
+  };
+
+  GrDiffBuilderImage.prototype._emitImagePair = function(section) {
+    var tr = this._createElement('tr');
+
+    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createImageCell(this._baseImage, 'left'));
+
+    tr.appendChild(this._createElement('td'));
+    tr.appendChild(this._createImageCell(this._revisionImage, 'right'));
+
+    section.appendChild(tr);
+  };
+
+  GrDiffBuilderImage.prototype._createImageCell = function(image, className) {
+    var td = this._createElement('td', className);
+    if (image) {
+      var imageEl = this._createElement('img');
+      imageEl.src = 'data:' + image.type + ';base64, ' + image.body;
+      image._height = imageEl.naturalHeight;
+      image._width = imageEl.naturalWidth;
+      imageEl.addEventListener('error', function(e) {
+        imageEl.remove();
+        td.textContent = '[Image failed to load]';
+      });
+      td.appendChild(imageEl);
+    }
+    return td;
+  };
+
+  GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
+    var tr = this._createElement('tr');
+
+    tr.appendChild(this._createElement('td'));
+    var td = this._createElement('td', 'left');
+    var label = this._createElement('label');
+    label.textContent = this._getImageLabel(this._baseImage);
+    td.appendChild(label);
+    tr.appendChild(td);
+
+    tr.appendChild(this._createElement('td'));
+    td = this._createElement('td', 'right');
+    label = this._createElement('label');
+    label.textContent = this._getImageLabel(this._revisionImage);
+    td.appendChild(label);
+    tr.appendChild(td);
+
+    section.appendChild(tr);
+  };
+
+  GrDiffBuilderImage.prototype._getImageLabel = function(image) {
+    if (image) {
+      var type = image.type || image._expectedType;
+      if (image._width && image._height) {
+        return image._width + '⨉' + image._height + ' ' + type;
+      } else {
+        return type;
+      }
+    }
+    return 'No image';
+  };
+
+  window.GrDiffBuilderImage = GrDiffBuilderImage;
+})(window, GrDiffBuilderSideBySide);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
new file mode 100644
index 0000000..1cb8cc7
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -0,0 +1,82 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window, GrDiffBuilder) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffBuilderSideBySide) { return; }
+
+  function GrDiffBuilderSideBySide(diff, comments, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
+  }
+  GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
+  GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
+
+  GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
+    var sectionEl = this._createElement('tbody', 'section');
+    sectionEl.classList.add(group.type);
+    var pairs = group.getSideBySidePairs();
+    for (var i = 0; i < pairs.length; i++) {
+      sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
+          pairs[i].right));
+    }
+    return sectionEl;
+  };
+
+  GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
+      rightLine) {
+    var row = this._createElement('tr');
+    row.classList.add('diff-row', 'side-by-side');
+    row.setAttribute('left-type', leftLine.type);
+    row.setAttribute('right-type', rightLine.type);
+
+    this._appendPair(section, row, leftLine, leftLine.beforeNumber,
+        GrDiffBuilder.Side.LEFT);
+    this._appendPair(section, row, rightLine, rightLine.afterNumber,
+        GrDiffBuilder.Side.RIGHT);
+    return row;
+  };
+
+  GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
+      lineNumber, side) {
+    var lineEl = this._createLineEl(line, lineNumber, line.type, side);
+    lineEl.classList.add(side);
+    row.appendChild(lineEl);
+    var action = this._createContextControl(section, line);
+    if (action) {
+      row.appendChild(action);
+    } else {
+      var textEl = this._createTextEl(line, side);
+      var threadEl = this._commentThreadForLine(line, side);
+      if (threadEl) {
+        textEl.appendChild(threadEl);
+      }
+      row.appendChild(textEl);
+    }
+  };
+
+  GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
+      content, side) {
+    var tr = content.parentElement.parentElement;
+    var content;
+    while (tr = tr.nextSibling) {
+      content = tr.querySelector(
+          'td.content .contentText[data-side="' + side + '"]');
+      if (content) { return content; }
+    }
+    return null;
+  };
+
+  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
+})(window, GrDiffBuilder);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
new file mode 100644
index 0000000..960bf46
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -0,0 +1,77 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window, GrDiffBuilder) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffBuilderUnified) { return; }
+
+  function GrDiffBuilderUnified(diff, comments, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
+  }
+  GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
+  GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
+
+  GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
+    var sectionEl = this._createElement('tbody', 'section');
+    sectionEl.classList.add(group.type);
+
+    for (var i = 0; i < group.lines.length; ++i) {
+      sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
+    }
+    return sectionEl;
+  };
+
+  GrDiffBuilderUnified.prototype._createRow = function(section, line) {
+    var row = this._createElement('tr', line.type);
+    var lineEl = this._createLineEl(line, line.beforeNumber,
+        GrDiffLine.Type.REMOVE);
+    lineEl.classList.add('left');
+    row.appendChild(lineEl);
+    lineEl = this._createLineEl(line, line.afterNumber,
+        GrDiffLine.Type.ADD);
+    lineEl.classList.add('right');
+    row.appendChild(lineEl);
+    row.classList.add('diff-row', 'unified');
+
+    var action = this._createContextControl(section, line);
+    if (action) {
+      row.appendChild(action);
+    } else {
+      var textEl = this._createTextEl(line);
+      var threadEl = this._commentThreadForLine(line);
+      if (threadEl) {
+        textEl.appendChild(threadEl);
+      }
+      row.appendChild(textEl);
+    }
+    return row;
+  };
+
+  GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
+      content, side) {
+    var tr = content.parentElement.parentElement;
+    var content;
+    while (tr = tr.nextSibling) {
+      if (tr.classList.contains('both') || (
+          (side === 'left' && tr.classList.contains('remove')) ||
+          (side === 'right' && tr.classList.contains('add')))) {
+        return tr.querySelector('.contentText');
+      }
+    }
+    return null;
+  };
+
+  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
+})(window, GrDiffBuilder);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
new file mode 100644
index 0000000..ec19a2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -0,0 +1,349 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
+<link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
+<link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
+<dom-module id="gr-diff-builder">
+  <template>
+    <div class="contentWrapper">
+      <content></content>
+    </div>
+    <gr-ranged-comment-layer
+        id="rangeLayer"
+        comments="[[comments]]"></gr-ranged-comment-layer>
+    <gr-syntax-layer
+        id="syntaxLayer"
+        diff="[[diff]]"></gr-syntax-layer>
+    <gr-diff-processor
+        id="processor"
+        groups="{{_groups}}"></gr-diff-processor>
+  </template>
+  <script src="../gr-diff/gr-diff-line.js"></script>
+  <script src="../gr-diff/gr-diff-group.js"></script>
+  <script src="../gr-diff-highlight/gr-annotation.js"></script>
+  <script src="gr-diff-builder.js"></script>
+  <script src="gr-diff-builder-side-by-side.js"></script>
+  <script src="gr-diff-builder-unified.js"></script>
+  <script src="gr-diff-builder-image.js"></script>
+  <script>
+    (function() {
+      'use strict';
+
+      var DiffViewMode = {
+        SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+        UNIFIED: 'UNIFIED_DIFF',
+      };
+
+      var TimingLabel = {
+        TOTAL: 'Diff Total Render',
+        CONTENT: 'Diff Content Render',
+        SYNTAX: 'Diff Syntax Render',
+      };
+
+      Polymer({
+        is: 'gr-diff-builder',
+
+        /**
+         * Fired when the diff is rendered.
+         *
+         * @event render
+         */
+
+        properties: {
+          diff: Object,
+          viewMode: String,
+          comments: Object,
+          isImageDiff: Boolean,
+          baseImage: Object,
+          revisionImage: Object,
+          _builder: Object,
+          _groups: Array,
+          _layers: Array,
+        },
+
+        get diffElement() {
+          return this.queryEffectiveChildren('#diffTable');
+        },
+
+        observers: [
+          '_groupsChanged(_groups.splices)',
+        ],
+
+        attached: function() {
+          // Setup annotation layers.
+          this._layers = [
+            this.$.syntaxLayer,
+            this._createIntralineLayer(),
+            this.$.rangeLayer,
+          ];
+
+          this.async(function() {
+            this._preRenderThread();
+          });
+        },
+
+        render: function(comments, prefs) {
+          this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
+
+          // Stop the processor (if it's running).
+          this.$.processor.cancel();
+          this.$.syntaxLayer.cancel();
+
+          this._builder = this._getDiffBuilder(this.diff, comments, prefs);
+
+          this.$.processor.context = prefs.context;
+          this.$.processor.keyLocations = this._getCommentLocations(comments);
+
+          this._clearDiffContent();
+
+          console.time(TimingLabel.TOTAL);
+          console.time(TimingLabel.CONTENT);
+          return this.$.processor.process(this.diff.content).then(function() {
+            if (this.isImageDiff) {
+              this._builder.renderDiffImages();
+            }
+            console.timeEnd(TimingLabel.CONTENT);
+            console.time(TimingLabel.SYNTAX);
+            this.$.syntaxLayer.process().then(function() {
+              console.timeEnd(TimingLabel.SYNTAX);
+              console.timeEnd(TimingLabel.TOTAL);
+            });
+            this.fire('render');
+          }.bind(this));
+        },
+
+        getLineElByChild: function(node) {
+          while (node) {
+            if (node instanceof Element) {
+              if (node.classList.contains('lineNum')) {
+                return node;
+              }
+              if (node.classList.contains('section')) {
+                return null;
+              }
+            }
+            node = node.previousSibling || node.parentElement;
+          }
+          return null;
+        },
+
+        getLineNumberByChild: function(node) {
+          var lineEl = this.getLineElByChild(node);
+          return lineEl ?
+              parseInt(lineEl.getAttribute('data-value'), 10) : null;
+        },
+
+        renderLineRange: function(startLine, endLine, opt_side) {
+          var groups =
+              this._builder.getGroupsByLineRange(startLine, endLine, opt_side);
+          groups.forEach(function(group) {
+            var newElement = this._builder.buildSectionElement(group);
+            var oldElement = group.element;
+
+            // Transfer comment threads from existing section to new one.
+            var threads = Polymer.dom(newElement).querySelectorAll(
+                'gr-diff-comment-thread');
+            threads.forEach(function(threadEl) {
+              var lineEl = this.getLineElByChild(threadEl, oldElement);
+              if (!lineEl) { // New comment thread.
+                return;
+              }
+              var side = this.getSideByLineEl(lineEl);
+              var line = lineEl.getAttribute('data-value');
+              var oldThreadEl =
+                  this.getCommentThreadByLine(line, side, oldElement);
+              threadEl.parentNode.replaceChild(oldThreadEl, threadEl);
+            }, this);
+
+            // Replace old group elements with new ones.
+            group.element.parentNode.replaceChild(newElement, group.element);
+            group.element = newElement;
+          }, this);
+
+          this.async(function() {
+            this.fire('render');
+          }, 1);
+        },
+
+        getContentByLine: function(lineNumber, opt_side, opt_root) {
+          return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
+        },
+
+        getContentByLineEl: function(lineEl) {
+          var root = Polymer.dom(lineEl.parentElement);
+          var side = this.getSideByLineEl(lineEl);
+          var line = lineEl.getAttribute('data-value');
+          return this.getContentByLine(line, side, root);
+        },
+
+        getLineElByNumber: function(lineNumber, opt_side) {
+          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+          return this.diffElement.querySelector(
+              '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
+        },
+
+        getContentsByLineRange: function(startLine, endLine, opt_side) {
+          var result = [];
+          this._builder.findLinesByRange(startLine, endLine, opt_side, null,
+              result);
+          return result;
+        },
+
+        getCommentThreadByLine: function(lineNumber, opt_side, opt_root) {
+          var content = this.getContentByLine(lineNumber, opt_side, opt_root);
+          return this.getCommentThreadByContentEl(content);
+        },
+
+        getCommentThreadByContentEl: function(contentEl) {
+          if (contentEl.classList.contains('contentText')) {
+            contentEl = contentEl.parentElement;
+          }
+          return contentEl.querySelector('gr-diff-comment-thread');
+        },
+
+        getSideByLineEl: function(lineEl) {
+          return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
+              GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+        },
+
+        createCommentThread: function(changeNum, patchNum, path, side,
+            projectConfig) {
+          return this._builder.createCommentThread(changeNum, patchNum, path,
+              side, projectConfig);
+        },
+
+        emitGroup: function(group, sectionEl) {
+          this._builder.emitGroup(group, sectionEl);
+        },
+
+        showContext: function(newGroups, sectionEl) {
+          var groups = this._builder.groups;
+          // TODO(viktard): Polyfill findIndex for IE10.
+          var contextIndex = groups.findIndex(function(group) {
+            return group.element == sectionEl;
+          });
+          groups.splice.apply(groups, [contextIndex, 1].concat(newGroups));
+
+          newGroups.forEach(function(newGroup) {
+            this._builder.emitGroup(newGroup, sectionEl);
+          }, this);
+          sectionEl.parentNode.removeChild(sectionEl);
+
+          this.async(function() {
+            this.fire('render');
+          }, 1);
+        },
+
+        _getDiffBuilder: function(diff, comments, prefs) {
+          if (this.isImageDiff) {
+            return new GrDiffBuilderImage(diff, comments, prefs,
+                this.diffElement, this.baseImage, this.revisionImage);
+          } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+            return new GrDiffBuilderSideBySide(
+                diff, comments, prefs, this.diffElement, this._layers);
+          } else if (this.viewMode === DiffViewMode.UNIFIED) {
+            return new GrDiffBuilderUnified(
+                diff, comments, prefs, this.diffElement, this._layers);
+          }
+          throw Error('Unsupported diff view mode: ' + this.viewMode);
+        },
+
+        _clearDiffContent: function() {
+          this.diffElement.innerHTML = null;
+        },
+
+        _getCommentLocations: function(comments) {
+          var result = {
+            left: {},
+            right: {},
+          };
+          for (var side in comments) {
+            if (side !== GrDiffBuilder.Side.LEFT &&
+                side !== GrDiffBuilder.Side.RIGHT) {
+              continue;
+            }
+            comments[side].forEach(function(c) {
+              result[side][c.line || GrDiffLine.FILE] = true;
+            });
+          }
+          return result;
+        },
+
+        _groupsChanged: function(changeRecord) {
+          if (!changeRecord) { return; }
+          changeRecord.indexSplices.forEach(function(splice) {
+            var group;
+            for (var i = 0; i < splice.addedCount; i++) {
+              group = splice.object[splice.index + i];
+              this._builder.groups.push(group);
+              this._builder.emitGroup(group);
+            }
+          }, this);
+        },
+
+        _createIntralineLayer: function() {
+          return {
+            addListener: function() {},
+
+            // Take a DIV.contentText element and a line object with intraline
+            // differences to highlight and apply them to the element as
+            // annotations.
+            annotate: function(el, line) {
+              var HL_CLASS = 'style-scope gr-diff intraline';
+              line.highlights.forEach(function(highlight) {
+                // The start and end indices could be the same if a highlight is
+                // meant to start at the end of a line and continue onto the
+                // next one. Ignore it.
+                if (highlight.startIndex === highlight.endIndex) { return; }
+
+                // If endIndex isn't present, continue to the end of the line.
+                var endIndex = highlight.endIndex === undefined ?
+                    line.text.length : highlight.endIndex;
+
+                GrAnnotation.annotateElement(
+                    el,
+                    highlight.startIndex,
+                    endIndex - highlight.startIndex,
+                    HL_CLASS);
+              });
+            },
+          };
+        },
+
+        /**
+         * In pages with large diffs, creating the first comment thread can be
+         * slow because nested Polymer elements (particularly
+         * iron-autogrow-textarea) add style elements to the document head,
+         * which, in turn, triggers a reflow on the page. Create a hidden
+         * thread, attach it to the page, and remove it so the stylesheet will
+         * already exist and the user's comment will be quick to load.
+         * @see https://gerrit-review.googlesource.com/c/82213/
+         */
+        _preRenderThread: function() {
+          var thread = document.createElement('gr-diff-comment-thread');
+          thread.setAttribute('hidden', true);
+          thread.addDraft();
+          var parent = Polymer.dom(this.root);
+          parent.appendChild(thread);
+          Polymer.dom.flush();
+          parent.removeChild(thread);
+        },
+      });
+    })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
new file mode 100644
index 0000000..2090e98
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -0,0 +1,556 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window, GrDiffGroup, GrDiffLine) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffBuilder) { return; }
+
+  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+  function GrDiffBuilder(diff, comments, prefs, outputEl, layers) {
+    this._diff = diff;
+    this._comments = comments;
+    this._prefs = prefs;
+    this._outputEl = outputEl;
+    this.groups = [];
+
+    this.layers = layers || [];
+
+    this.layers.forEach(function(layer) {
+      layer.addListener(this._handleLayerUpdate.bind(this));
+    }.bind(this));
+  }
+
+  GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
+  GrDiffBuilder.GREATER_THAN_CODE = '>'.charCodeAt(0);
+  GrDiffBuilder.AMPERSAND_CODE = '&'.charCodeAt(0);
+  GrDiffBuilder.SEMICOLON_CODE = ';'.charCodeAt(0);
+
+  GrDiffBuilder.LINE_FEED_HTML =
+      '<span class="style-scope gr-diff br"></span>';
+
+  GrDiffBuilder.GroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  GrDiffBuilder.Highlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  GrDiffBuilder.Side = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  GrDiffBuilder.ContextButtonType = {
+    ABOVE: 'above',
+    BELOW: 'below',
+    ALL: 'all',
+  };
+
+  var PARTIAL_CONTEXT_AMOUNT = 10;
+
+  GrDiffBuilder.prototype.buildSectionElement = function(group) {
+    throw Error('Subclasses must implement buildGroupElement');
+  };
+
+  GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
+    var element = this.buildSectionElement(group);
+    this._outputEl.insertBefore(element, opt_beforeSection);
+    group.element = element;
+  };
+
+  GrDiffBuilder.prototype.renderSection = function(element) {
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
+      if (group.element === element) {
+        var newElement = this.buildSectionElement(group);
+        group.element.parentElement.replaceChild(newElement, group.element);
+        group.element = newElement;
+        break;
+      }
+    }
+  };
+
+  GrDiffBuilder.prototype.getGroupsByLineRange = function(
+      startLine, endLine, opt_side) {
+    var groups = [];
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
+      if (group.lines.length === 0) {
+        continue;
+      }
+      var groupStartLine = 0;
+      var groupEndLine = 0;
+      if (opt_side) {
+        groupStartLine = group.lineRange[opt_side].start;
+        groupEndLine = group.lineRange[opt_side].end;
+      }
+
+      if (groupStartLine === 0) { // Line was removed or added.
+        groupStartLine = groupEndLine;
+      }
+      if (groupEndLine === 0) {  // Line was removed or added.
+        groupEndLine = groupStartLine;
+      }
+      if (startLine <= groupEndLine && endLine >= groupStartLine) {
+        groups.push(group);
+      }
+    }
+    return groups;
+  };
+
+  GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
+      opt_root) {
+    var root = Polymer.dom(opt_root || this._outputEl);
+    var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+    return root.querySelector('td.lineNum[data-value="' + lineNumber +
+        '"]' + sideSelector + ' ~ td.content .contentText');
+  };
+
+  /**
+   * Find line elements or line objects by a range of line numbers and a side.
+   *
+   * @param {Number} start The first line number
+   * @param {Number} end The last line number
+   * @param {String} opt_side The side of the range. Either 'left' or 'right'.
+   * @param {Array<GrDiffLine>} out_lines The output list of line objects. Use
+   *     null if not desired.
+   * @param  {Array<HTMLElement>} out_elements The output list of line elements.
+   *     Use null if not desired.
+   */
+  GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
+      out_lines, out_elements) {
+    var groups = this.getGroupsByLineRange(start, end, opt_side);
+    groups.forEach(function(group) {
+      var content = null;
+      group.lines.forEach(function(line) {
+        if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
+            (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
+          return;
+        }
+        var lineNumber = opt_side === 'left' ?
+            line.beforeNumber : line.afterNumber;
+        if (lineNumber < start || lineNumber > end) { return; }
+
+        if (out_lines) { out_lines.push(line); }
+        if (out_elements) {
+          if (content) {
+            content = this._getNextContentOnSide(content, opt_side);
+          } else {
+            content = this.getContentByLine(lineNumber, opt_side,
+                group.element);
+          }
+          if (content) { out_elements.push(content); }
+        }
+      }.bind(this));
+    }.bind(this));
+  };
+
+  /**
+   * Re-renders the DIV.contentText alement for the given side and range of diff
+   * content.
+   */
+  GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
+    var lines = [];
+    var elements = [];
+    var line;
+    var el;
+    this.findLinesByRange(start, end, side, lines, elements);
+    for (var i = 0; i < lines.length; i++) {
+      line = lines[i];
+      el = elements[i];
+      el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
+          el);
+    }
+  };
+
+  GrDiffBuilder.prototype.getSectionsByLineRange = function(
+      startLine, endLine, opt_side) {
+    return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
+        function(group) { return group.element; });
+  };
+
+  GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
+    return this._commentLocations[side][lineNum] === true;
+  };
+
+  // TODO(wyatta): Move this completely into the processor.
+  GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
+      hiddenRange) {
+    var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+    var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+    var linesAfterCtx = lines.slice(hiddenRange[1]);
+
+    if (linesBeforeCtx.length > 0) {
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
+    }
+
+    var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+    ctxLine.contextGroup =
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+    groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+        [ctxLine]));
+
+    if (linesAfterCtx.length > 0) {
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+    }
+  };
+
+  GrDiffBuilder.prototype._createContextControl = function(section, line) {
+    if (!line.contextGroup || !line.contextGroup.lines.length) {
+      return null;
+    }
+
+    var td = this._createElement('td');
+    var showPartialLinks =
+        line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
+
+    if (showPartialLinks) {
+      td.appendChild(this._createContextButton(
+          GrDiffBuilder.ContextButtonType.ABOVE, section, line));
+      td.appendChild(document.createTextNode(' - '));
+    }
+
+    td.appendChild(this._createContextButton(
+        GrDiffBuilder.ContextButtonType.ALL, section, line));
+
+    if (showPartialLinks) {
+      td.appendChild(document.createTextNode(' - '));
+      td.appendChild(this._createContextButton(
+          GrDiffBuilder.ContextButtonType.BELOW, section, line));
+    }
+
+    return td;
+  };
+
+  GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
+    var contextLines = line.contextGroup.lines;
+    var context = PARTIAL_CONTEXT_AMOUNT;
+
+    var button = this._createElement('gr-button', 'showContext');
+    button.setAttribute('link', true);
+
+    var text;
+    var groups = []; // The groups that replace this one if tapped.
+
+    if (type === GrDiffBuilder.ContextButtonType.ALL) {
+      text = 'Show ' + contextLines.length + ' common line';
+      if (contextLines.length > 1) { text += 's'; }
+      groups.push(line.contextGroup);
+    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
+      text = '+' + context + '↑';
+      this._insertContextGroups(groups, contextLines,
+          [context, contextLines.length]);
+    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
+      text = '+' + context + '↓';
+      this._insertContextGroups(groups, contextLines,
+          [0, contextLines.length - context]);
+    }
+
+    button.textContent = text;
+
+    button.addEventListener('tap', function(e) {
+      e.detail = {
+        groups: groups,
+        section: section,
+      };
+      // Let it bubble up the DOM tree.
+    });
+
+    return button;
+  };
+
+  GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
+      opt_side) {
+    function byLineNum(lineNum) {
+      return function(c) {
+        return (c.line === lineNum) ||
+               (c.line === undefined && lineNum === GrDiffLine.FILE);
+      };
+    }
+    var leftComments =
+        comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
+    var rightComments =
+        comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
+
+    var result;
+
+    switch (opt_side) {
+      case GrDiffBuilder.Side.LEFT:
+        result = leftComments;
+        break;
+      case GrDiffBuilder.Side.RIGHT:
+        result = rightComments;
+        break;
+      default:
+        result = leftComments.concat(rightComments);
+        break;
+    }
+
+    return result;
+  };
+
+  GrDiffBuilder.prototype.createCommentThread = function(changeNum, patchNum,
+      path, side, projectConfig) {
+    var threadEl = document.createElement('gr-diff-comment-thread');
+    threadEl.changeNum = changeNum;
+    threadEl.patchNum = patchNum;
+    threadEl.path = path;
+    threadEl.side = side;
+    threadEl.projectConfig = projectConfig;
+    return threadEl;
+  };
+
+  GrDiffBuilder.prototype._commentThreadForLine = function(line, opt_side) {
+    var comments = this._getCommentsForLine(this._comments, line, opt_side);
+    if (!comments || comments.length === 0) {
+      return null;
+    }
+
+    var patchNum = this._comments.meta.patchRange.patchNum;
+    var side = comments[0].side || 'REVISION';
+    if (line.type === GrDiffLine.Type.REMOVE ||
+        opt_side === GrDiffBuilder.Side.LEFT) {
+      if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
+        side = 'PARENT';
+      } else {
+        patchNum = this._comments.meta.patchRange.basePatchNum;
+      }
+    }
+    var threadEl = this.createCommentThread(
+        this._comments.meta.changeNum,
+        patchNum,
+        this._comments.meta.path,
+        side,
+        this._comments.meta.projectConfig);
+    threadEl.comments = comments;
+    return threadEl;
+  };
+
+  GrDiffBuilder.prototype._createLineEl = function(line, number, type,
+      opt_class) {
+    var td = this._createElement('td');
+    if (opt_class) {
+      td.classList.add(opt_class);
+    }
+    if (line.type === GrDiffLine.Type.BLANK) {
+      return td;
+    } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
+      td.classList.add('contextLineNum');
+      td.setAttribute('data-value', '@@');
+    } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
+      td.classList.add('lineNum');
+      td.setAttribute('data-value', number);
+    }
+    return td;
+  };
+
+  GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
+    var td = this._createElement('td');
+    if (line.type !== GrDiffLine.Type.BLANK) {
+      td.classList.add('content');
+    }
+    td.classList.add(line.type);
+    var text = line.text;
+    var html = util.escapeHTML(text);
+    html = this._addTabWrappers(html, this._prefs.tab_size);
+
+    if (this._textLength(text, this._prefs.tab_size) >
+        this._prefs.line_length) {
+      html = this._addNewlines(text, html);
+    }
+
+    var contentText = this._createElement('div', 'contentText');
+    if (opt_side) {
+      contentText.setAttribute('data-side', opt_side);
+    }
+
+    // If the html is equivalent to the text then it didn't get highlighted
+    // or escaped. Use textContent which is faster than innerHTML.
+    if (html === text) {
+      contentText.textContent = text;
+    } else {
+      contentText.innerHTML = html;
+    }
+
+    td.classList.add(line.highlights.length > 0 ?
+        'lightHighlight' : 'darkHighlight');
+
+    this.layers.forEach(function(layer) {
+      layer.annotate(contentText, line);
+    });
+
+    td.appendChild(contentText);
+
+    return td;
+  };
+
+  /**
+   * Returns the text length after normalizing unicode and tabs.
+   * @return {Number} The normalized length of the text.
+   */
+  GrDiffBuilder.prototype._textLength = function(text, tabSize) {
+    text = text.replace(REGEX_ASTRAL_SYMBOL, '_');
+    var numChars = 0;
+    for (var i = 0; i < text.length; i++) {
+      if (text[i] === '\t') {
+        numChars += tabSize - (numChars % tabSize);
+      } else {
+        numChars++;
+      }
+    }
+    return numChars;
+  };
+
+  // Advance `index` by the appropriate number of characters that would
+  // represent one source code character and return that index. For
+  // example, for source code '<span>' the escaped html string is
+  // '&lt;span&gt;'. Advancing from index 0 on the prior html string would
+  // return 4, since &lt; maps to one source code character ('<').
+  GrDiffBuilder.prototype._advanceChar = function(html, index) {
+    // TODO(andybons): Unicode is all kinds of messed up in JS. Account for it.
+    // https://mathiasbynens.be/notes/javascript-unicode
+
+    // Tags don't count as characters
+    while (index < html.length &&
+           html.charCodeAt(index) === GrDiffBuilder.LESS_THAN_CODE) {
+      while (index < html.length &&
+             html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) {
+        index++;
+      }
+      index++;  // skip the ">" itself
+    }
+    // An HTML entity (e.g., &lt;) counts as one character.
+    if (index < html.length &&
+        html.charCodeAt(index) === GrDiffBuilder.AMPERSAND_CODE) {
+      while (index < html.length &&
+             html.charCodeAt(index) !== GrDiffBuilder.SEMICOLON_CODE) {
+        index++;
+      }
+    }
+    return index + 1;
+  };
+
+  GrDiffBuilder.prototype._addNewlines = function(text, html) {
+    var htmlIndex = 0;
+    var indices = [];
+    var numChars = 0;
+    for (var i = 0; i < text.length; i++) {
+      if (numChars > 0 && numChars % this._prefs.line_length === 0) {
+        indices.push(htmlIndex);
+      }
+      htmlIndex = this._advanceChar(html, htmlIndex);
+      if (text[i] === '\t') {
+        numChars += this._prefs.tab_size;
+      } else {
+        numChars++;
+      }
+    }
+    var result = html;
+    // Since the result string is being altered in place, start from the end
+    // of the string so that the insertion indices are not affected as the
+    // result string changes.
+    for (var i = indices.length - 1; i >= 0; i--) {
+      result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML +
+          result.slice(indices[i]);
+    }
+    return result;
+  };
+
+  /**
+   * Takes a string of text (not HTML) and returns a string of HTML with tab
+   * elements in place of tab characters. In each case tab elements are given
+   * the width needed to reach the next tab-stop.
+   *
+   * @param {String} A line of text potentially containing tab characters.
+   * @param {Number} The width for tabs.
+   * @return {String} An HTML string potentially containing tab elements.
+   */
+  GrDiffBuilder.prototype._addTabWrappers = function(line, tabSize) {
+    if (!line.length) { return ''; }
+
+    var result = '';
+    var offset = 0;
+    var split = line.split('\t');
+    var width;
+
+    for (var i = 0; i < split.length - 1; i++) {
+      offset += split[i].length;
+      width = tabSize - (offset % tabSize);
+      result += split[i] + this._getTabWrapper(width, this._prefs.show_tabs);
+      offset += width;
+    }
+    if (split.length) {
+      result += split[split.length - 1];
+    }
+
+    return result;
+  };
+
+  GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
+    // Force this to be a number to prevent arbitrary injection.
+    tabSize = +tabSize;
+    if (isNaN(tabSize)) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    var str = '<span class="style-scope gr-diff tab ';
+    if (showTabs) {
+      str += 'withIndicator';
+    }
+    str += '" style="';
+    // TODO(andybons): CSS tab-size is not supported in IE.
+    str += 'tab-size:' + tabSize + ';';
+    str += '-moz-tab-size:' + tabSize + ';';
+    str += '">\t</span>';
+    return str;
+  };
+
+  GrDiffBuilder.prototype._createElement = function(tagName, className) {
+    var el = document.createElement(tagName);
+    // When Shady DOM is being used, these classes are added to account for
+    // Polymer's polyfill behavior. In order to guarantee sufficient
+    // specificity within the CSS rules, these are added to every element.
+    // Since the Polymer DOM utility functions (which would do this
+    // automatically) are not being used for performance reasons, this is
+    // done manually.
+    el.classList.add('style-scope', 'gr-diff');
+    if (!!className) {
+      el.classList.add(className);
+    }
+    return el;
+  };
+
+  GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
+    this._renderContentByRange(start, end, side);
+  };
+
+  /**
+   * Finds the next DIV.contentText element following the given element, and on
+   * the same side. Will only search within a group.
+   * @param {HTMLElement} content
+   * @param {String} side Either 'left' or 'right'
+   * @return {HTMLElement}
+   */
+  GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
+    throw Error('Subclasses must implement _getNextContentOnSide');
+  };
+
+  window.GrDiffBuilder = GrDiffBuilder;
+})(window, GrDiffGroup, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
new file mode 100644
index 0000000..e8b1453
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -0,0 +1,629 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-builder</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+<script src="../gr-diff/gr-diff-line.js"></script>
+<script src="../gr-diff/gr-diff-group.js"></script>
+<script src="../gr-diff-highlight/gr-annotation.js"></script>
+<script src="gr-diff-builder.js"></script>
+
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
+<link rel="import" href="gr-diff-builder.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+<test-fixture id="div-with-text">
+  <template>
+    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+  </template>
+</test-fixture>
+
+<test-fixture id="mock-diff">
+  <template>
+    <gr-diff-builder view-mode="SIDE_BY_SIDE">
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-builder tests', function() {
+    var element;
+    var builder;
+
+    setup(function() {
+      var prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      builder = new GrDiffBuilder({content: []}, {left: [], right: []}, prefs);
+    });
+
+    test('context control buttons', function() {
+      var section = {};
+      var line = {contextGroup: {lines: []}};
+
+      // Create 10 lines.
+      for (var i = 0; i < 10; i++) {
+        line.contextGroup.lines.push('lorem upsum');
+      }
+
+      // Does not include +10 buttons when there are fewer than 11 lines.
+      var td = builder._createContextControl(section, line);
+      var buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 1);
+      assert.equal(buttons[0].textContent, 'Show 10 common lines');
+
+      // Add another line.
+      line.contextGroup.lines.push('lorem upsum');
+
+      // Includes +10 buttons when there are at least 11 lines.
+      td = builder._createContextControl(section, line);
+      buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 3);
+      assert.equal(buttons[0].textContent, '+10↑');
+      assert.equal(buttons[1].textContent, 'Show 11 common lines');
+      assert.equal(buttons[2].textContent, '+10↓');
+    });
+
+    test('newlines', function() {
+      var text = 'abcdef';
+      assert.equal(builder._addNewlines(text, text), text);
+      text = 'a'.repeat(20);
+      assert.equal(builder._addNewlines(text, text),
+          'a'.repeat(10) +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'a'.repeat(10));
+
+      text = '<span class="thumbsup">👍</span>';
+      var html = '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
+      assert.equal(builder._addNewlines(text, html),
+          '&lt;span clas' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          's=&quot;thumbsu' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'p&quot;&gt;👍&lt;&#x2F;spa' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'n&gt;');
+
+      text = '01234\t56789';
+      assert.equal(builder._addNewlines(text, text),
+          '01234\t5' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          '6789');
+    });
+
+    test('text length with tabs and unicode', function() {
+      assert.equal(builder._textLength('12345', 4), 5);
+      assert.equal(builder._textLength('\t\t12', 4), 10);
+      assert.equal(builder._textLength('abc💢123', 4), 7);
+
+      assert.equal(builder._textLength('abc\t', 8), 8);
+      assert.equal(builder._textLength('abc\t\t', 10), 20);
+      assert.equal(builder._textLength('', 10), 0);
+      assert.equal(builder._textLength('', 10), 0);
+      assert.equal(builder._textLength('abc\tde', 10), 12);
+      assert.equal(builder._textLength('abc\tde\t', 10), 20);
+      assert.equal(builder._textLength('\t\t\t\t\t', 20), 100);
+    });
+
+    test('tab wrapper insertion', function() {
+      var html = 'abc\tdef';
+      var wrapper = builder._getTabWrapper(
+          builder._prefs.tab_size - 3,
+          builder._prefs.show_tabs);
+      assert.ok(wrapper);
+      assert.isAbove(wrapper.length, 0);
+      assert.equal(builder._addTabWrappers(html, builder._prefs.tab_size),
+          'abc' + wrapper + 'def');
+      assert.throws(builder._getTabWrapper.bind(
+          builder,
+          // using \x3c instead of < in string so gjslint can parse
+          '">\x3cimg src="/" onerror="alert(1);">\x3cspan class="',
+          true));
+    });
+
+    test('comments', function() {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      var comments = {left: [], right: []};
+      assert.deepEqual(builder._getCommentsForLine(comments, line), []);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.LEFT), []);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.RIGHT), []);
+
+      comments = {
+        left: [
+          {id: 'l3', line: 3},
+          {id: 'l5', line: 5},
+        ],
+        right: [
+          {id: 'r3', line: 3},
+          {id: 'r5', line: 5},
+        ],
+      };
+      assert.deepEqual(builder._getCommentsForLine(comments, line),
+          [{id: 'l3', line: 3}, {id: 'r5', line: 5}]);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3}]);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5}]);
+    });
+
+    test('comment thread creation', function() {
+      var l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000'};
+      var l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000'};
+      var r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000'};
+
+      builder._comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: '3',
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [l3, l5],
+        right: [r5],
+      };
+
+      function checkThreadProps(threadEl, patchNum, side, comments) {
+        assert.equal(threadEl.changeNum, '42');
+        assert.equal(threadEl.patchNum, patchNum);
+        assert.equal(threadEl.path, '/path/to/foo');
+        assert.equal(threadEl.side, side);
+        assert.deepEqual(threadEl.projectConfig, {foo: 'bar'});
+        assert.deepEqual(threadEl.comments, comments);
+      }
+
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 5;
+      line.afterNumber = 5;
+      var threadEl = builder._commentThreadForLine(line);
+      checkThreadProps(threadEl, '3', 'REVISION', [l5, r5]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadProps(threadEl, '3', 'REVISION', [r5]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadProps(threadEl, '3', 'PARENT', [l5]);
+
+      builder._comments.meta.patchRange.basePatchNum = '1';
+
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps(threadEl, '3', 'REVISION', [l5, r5]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadProps(threadEl, '1', 'REVISION', [l5]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadProps(threadEl, '3', 'REVISION', [r5]);
+
+      builder._comments.meta.patchRange.basePatchNum = 'PARENT';
+
+      line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.beforeNumber = 5;
+      line.afterNumber = 5;
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps(threadEl, '3', 'PARENT', [l5, r5]);
+
+      line = new GrDiffLine(GrDiffLine.Type.ADD);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps(threadEl, '3', 'REVISION', [l3, r5]);
+    });
+
+    suite('intraline differences', function() {
+      var el;
+      var str;
+      var annotateElementSpy;
+      var layer;
+
+      function slice(str, start, end) {
+        return Array.from(str).slice(start, end).join('');
+      }
+
+      setup(function() {
+        el = fixture('div-with-text');
+        str = el.textContent;
+        annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+        layer = document.createElement('gr-diff-builder')
+            ._createIntralineLayer();
+      });
+
+      teardown(function() {
+        annotateElementSpy.restore();
+      });
+
+      test('annotate no highlights', function() {
+        var line = {
+          text: str,
+          highlights: [],
+        };
+
+        layer.annotate(el, line);
+
+        // The content is unchanged.
+        assert.isFalse(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 1);
+        assert.instanceOf(el.childNodes[0], Text);
+        assert.equal(str, el.childNodes[0].textContent);
+      });
+
+      test('annotate with highlights', function() {
+        var line = {
+          text: str,
+          highlights: [
+            {startIndex: 6, endIndex: 12},
+            {startIndex: 18, endIndex: 22},
+          ],
+        };
+        var str0 = slice(str, 0, 6);
+        var str1 = slice(str, 6, 12);
+        var str2 = slice(str, 12, 18);
+        var str3 = slice(str, 18, 22);
+        var str4 = slice(str, 22);
+
+        layer.annotate(el, line);
+
+        assert.isTrue(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 5);
+
+        assert.instanceOf(el.childNodes[0], Text);
+        assert.equal(el.childNodes[0].textContent, str0);
+
+        assert.notInstanceOf(el.childNodes[1], Text);
+        assert.equal(el.childNodes[1].textContent, str1);
+
+        assert.instanceOf(el.childNodes[2], Text);
+        assert.equal(el.childNodes[2].textContent, str2);
+
+        assert.notInstanceOf(el.childNodes[3], Text);
+        assert.equal(el.childNodes[3].textContent, str3);
+
+        assert.instanceOf(el.childNodes[4], Text);
+        assert.equal(el.childNodes[4].textContent, str4);
+      });
+
+      test('annotate without endIndex', function() {
+        var line = {
+          text: str,
+          highlights: [
+            {startIndex: 28},
+          ],
+        };
+
+        var str0 = slice(str, 0, 28);
+        var str1 = slice(str, 28);
+
+        layer.annotate(el, line);
+
+        assert.isTrue(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 2);
+
+        assert.instanceOf(el.childNodes[0], Text);
+        assert.equal(el.childNodes[0].textContent, str0);
+
+        assert.notInstanceOf(el.childNodes[1], Text);
+        assert.equal(el.childNodes[1].textContent, str1);
+      });
+
+      test('annotate ignores empty highlights', function() {
+        var line = {
+          text: str,
+          highlights: [
+            {startIndex: 28, endIndex: 28},
+          ],
+        };
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 1);
+      });
+
+      test('annotate handles unicode', function() {
+        // Put some unicode into the string:
+        str = str.replace(/\s/g, '💢');
+        el.textContent = str;
+        var line = {
+          text: str,
+          highlights: [
+            {startIndex: 6, endIndex: 12},
+          ],
+        };
+
+        var str0 = slice(str, 0, 6);
+        var str1 = slice(str, 6, 12);
+        var str2 = slice(str, 12);
+
+        layer.annotate(el, line);
+
+        assert.isTrue(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 3);
+
+        assert.instanceOf(el.childNodes[0], Text);
+        assert.equal(el.childNodes[0].textContent, str0);
+
+        assert.notInstanceOf(el.childNodes[1], Text);
+        assert.equal(el.childNodes[1].textContent, str1);
+
+        assert.instanceOf(el.childNodes[2], Text);
+        assert.equal(el.childNodes[2].textContent, str2);
+      });
+
+      test('annotate handles unicode w/o endIndex', function() {
+        // Put some unicode into the string:
+        str = str.replace(/\s/g, '💢');
+        el.textContent = str;
+
+        var line = {
+          text: str,
+          highlights: [
+            {startIndex: 6},
+          ],
+        };
+
+        var str0 = slice(str, 0, 6);
+        var str1 = slice(str, 6);
+
+        layer.annotate(el, line);
+
+        assert.isTrue(annotateElementSpy.called);
+        assert.equal(el.childNodes.length, 2);
+
+        assert.instanceOf(el.childNodes[0], Text);
+        assert.equal(el.childNodes[0].textContent, str0);
+
+        assert.notInstanceOf(el.childNodes[1], Text);
+        assert.equal(el.childNodes[1].textContent, str1);
+      });
+    });
+
+    suite('rendering', function() {
+      var content;
+      var outputEl;
+
+      setup(function(done) {
+        var prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1
+        };
+        content = [
+          {
+            a: ['all work and no play make andybons a dull boy'],
+            b: ['elgoog elgoog elgoog']
+          },
+          {
+            ab: [
+              'Non eram nescius, Brute, cum, quae summis ingeniis ',
+              'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+            ]
+          },
+        ];
+        element = fixture('basic');
+        outputEl = element.queryEffectiveChildren('#diffTable');
+        element.addEventListener('render', function() {
+          done();
+        });
+        sinon.stub(element, '_getDiffBuilder', function() {
+          var builder = new GrDiffBuilder(
+              {content: content}, {left: [], right: []}, prefs, outputEl);
+          builder.buildSectionElement = function(group) {
+            var section = document.createElement('stub');
+            section.textContent = group.lines.reduce(function(acc, line) {
+              return acc + line.text;
+            }, '');
+            return section;
+          };
+          return builder;
+        });
+        element.diff = {content: content};
+        element.render({left: [], right: []}, prefs);
+      });
+
+      test('renderSection', function() {
+        var section = outputEl.querySelector('stub:nth-of-type(2)');
+        var prevInnerHTML = section.innerHTML;
+        section.innerHTML = 'wiped';
+        element._builder.renderSection(section);
+        section = outputEl.querySelector('stub:nth-of-type(2)');
+        assert.equal(section.innerHTML, prevInnerHTML);
+      });
+
+      test('getSectionsByLineRange one line', function() {
+        var section = outputEl.querySelector('stub:nth-of-type(2)');
+        var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+        assert.equal(sections.length, 1);
+        assert.strictEqual(sections[0], section);
+      });
+
+      test('getSectionsByLineRange over diff', function() {
+        var section = [
+          outputEl.querySelector('stub:nth-of-type(2)'),
+          outputEl.querySelector('stub:nth-of-type(3)'),
+        ];
+        var sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+        assert.equal(sections.length, 2);
+        assert.strictEqual(sections[0], section[0]);
+        assert.strictEqual(sections[1], section[1]);
+      });
+    });
+
+    suite('mock-diff', function() {
+      var element;
+      var builder;
+      var diff;
+      var prefs;
+
+      setup(function(done) {
+        element = fixture('mock-diff');
+        diff = document.createElement('mock-diff-response').diffResponse;
+        element.diff = diff;
+
+        prefs = {
+          line_length: 80,
+          show_tabs: true,
+          tab_size: 4,
+        };
+
+        element.render({left: [], right: []}, prefs).then(function() {
+          builder = element._builder;
+          done();
+        });
+      });
+
+      test('getContentByLine', function() {
+        var actual;
+
+        actual = builder.getContentByLine(2, 'left');
+        assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+        actual = builder.getContentByLine(2, 'right');
+        assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+        actual = builder.getContentByLine(5, 'left');
+        assert.equal(actual.textContent, diff.content[2].ab[0]);
+
+        actual = builder.getContentByLine(5, 'right');
+        assert.equal(actual.textContent, diff.content[1].b[0]);
+      });
+
+      test('findLinesByRange', function() {
+        var lines = [];
+        var elems = [];
+        var start = 6;
+        var end = 10;
+        var count = end - start + 1;
+
+        builder.findLinesByRange(start, end, 'right', lines, elems);
+
+        assert.equal(lines.length, count);
+        assert.equal(elems.length, count);
+
+        for (var i = 0; i < 5; i++) {
+          assert.instanceOf(lines[i], GrDiffLine);
+          assert.equal(lines[i].afterNumber, start + i);
+          assert.instanceOf(elems[i], HTMLElement);
+          assert.equal(lines[i].text, elems[i].textContent);
+        }
+      });
+
+      test('_renderContentByRange', function() {
+        var spy = sinon.spy(builder, '_createTextEl');
+        var start = 9;
+        var end = 14;
+        var count = end - start + 1;
+
+        builder._renderContentByRange(start, end, 'left');
+
+        assert.equal(spy.callCount, count);
+        spy.getCalls().forEach(function(call, i) {
+          assert.equal(call.args[0].beforeNumber, start + i);
+        });
+
+        spy.restore();
+      });
+
+      test('_getNextContentOnSide side-by-side left', function() {
+        var startElem = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        var expectedStartString = diff.content[2].ab[0];
+        var expectedNextString = diff.content[2].ab[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        var nextElem = builder._getNextContentOnSide(startElem,
+            'left');
+        assert.equal(nextElem.textContent, expectedNextString);
+      });
+
+      test('_getNextContentOnSide side-by-side right', function() {
+        var startElem = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        var expectedStartString = diff.content[1].b[0];
+        var expectedNextString = diff.content[1].b[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        var nextElem = builder._getNextContentOnSide(startElem,
+            'right');
+        assert.equal(nextElem.textContent, expectedNextString);
+      });
+
+      test('_getNextContentOnSide unified left', function(done) {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render({left: [], right: []}, prefs).then(function() {
+          builder = element._builder;
+
+          var startElem = builder.getContentByLine(5, 'left',
+              element.$.diffTable);
+          var expectedStartString = diff.content[2].ab[0];
+          var expectedNextString = diff.content[2].ab[1];
+          assert.equal(startElem.textContent, expectedStartString);
+
+          var nextElem = builder._getNextContentOnSide(startElem,
+              'left');
+          assert.equal(nextElem.textContent, expectedNextString);
+
+          done();
+        });
+      });
+
+      test('_getNextContentOnSide unified right', function(done) {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render({left: [], right: []}, prefs).then(function() {
+          builder = element._builder;
+
+          var startElem = builder.getContentByLine(5, 'right',
+              element.$.diffTable);
+          var expectedStartString = diff.content[1].b[0];
+          var expectedNextString = diff.content[1].b[1];
+          assert.equal(startElem.textContent, expectedStartString);
+
+          var nextElem = builder._getNextContentOnSide(startElem,
+              'right');
+          assert.equal(nextElem.textContent, expectedNextString);
+
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
new file mode 100644
index 0000000..25237b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -0,0 +1,48 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
+
+<dom-module id="gr-diff-comment-thread">
+  <template>
+    <style>
+      :host {
+        border: 1px solid #ddd;
+        border-right: none;
+        display: block;
+        white-space: normal;
+      }
+    </style>
+    <div id="container">
+      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]" as="comment">
+        <gr-diff-comment
+            comment="{{comment}}"
+            change-num="[[changeNum]]"
+            patch-num="[[patchNum]]"
+            draft="[[comment.__draft]]"
+            show-actions="[[_showActions]]"
+            project-config="[[projectConfig]]"
+            on-reply="_handleCommentReply"
+            on-comment-discard="_handleCommentDiscard"
+            on-done="_handleCommentDone"></gr-diff-comment>
+      </template>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-diff-comment-thread.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
new file mode 100644
index 0000000..305c36a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -0,0 +1,226 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-comment-thread',
+
+    /**
+     * Fired when the thread should be discarded.
+     *
+     * @event thread-discard
+     */
+
+    properties: {
+      changeNum: String,
+      comments: {
+        type: Array,
+        value: function() { return []; },
+      },
+      patchNum: String,
+      path: String,
+      projectConfig: Object,
+      side: {
+        type: String,
+        value: 'REVISION',
+      },
+
+      _showActions: Boolean,
+      _orderedComments: Array,
+    },
+
+    listeners: {
+      'comment-update': '_handleCommentUpdate',
+    },
+
+    observers: [
+      '_commentsChanged(comments.splices)',
+    ],
+
+    attached: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        this._showActions = loggedIn;
+      }.bind(this));
+    },
+
+    addOrEditDraft: function(opt_lineNum) {
+      var lastComment = this.comments[this.comments.length - 1];
+      if (lastComment && lastComment.__draft) {
+        var commentEl = this._commentElWithDraftID(
+            lastComment.id || lastComment.__draftID);
+        commentEl.editing = true;
+      } else {
+        this.addDraft(opt_lineNum);
+      }
+    },
+
+    addDraft: function(opt_lineNum, opt_range) {
+      var draft = this._newDraft(opt_lineNum, opt_range);
+      draft.__editing = true;
+      this.push('comments', draft);
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _commentsChanged: function(changeRecord) {
+      this._orderedComments = this._sortedComments(this.comments);
+    },
+
+    _sortedComments: function(comments) {
+      comments.sort(function(c1, c2) {
+        var c1Date = c1.__date || util.parseDate(c1.updated);
+        var c2Date = c2.__date || util.parseDate(c2.updated);
+        return c1Date - c2Date;
+      });
+
+      var commentIDToReplies = {};
+      var topLevelComments = [];
+      for (var i = 0; i < comments.length; i++) {
+        var c = comments[i];
+        if (c.in_reply_to) {
+          if (commentIDToReplies[c.in_reply_to] == null) {
+            commentIDToReplies[c.in_reply_to] = [];
+          }
+          commentIDToReplies[c.in_reply_to].push(c);
+        } else {
+          topLevelComments.push(c);
+        }
+      }
+      var results = [];
+      for (var i = 0; i < topLevelComments.length; i++) {
+        this._visitComment(topLevelComments[i], commentIDToReplies, results);
+      }
+      for (var missingCommentId in commentIDToReplies) {
+        results = results.concat(commentIDToReplies[missingCommentId]);
+      }
+      return results;
+    },
+
+    _visitComment: function(parent, commentIDToReplies, results) {
+      results.push(parent);
+
+      var replies = commentIDToReplies[parent.id];
+      delete commentIDToReplies[parent.id];
+      if (!replies) { return; }
+      for (var i = 0; i < replies.length; i++) {
+        this._visitComment(replies[i], commentIDToReplies, results);
+      }
+    },
+
+    _handleCommentReply: function(e) {
+      var comment = e.detail.comment;
+      var quoteStr;
+      if (e.detail.quote) {
+        var msg = comment.message;
+        var quoteStr = msg.split('\n').map(
+            function(line) { return ' > ' + line; }).join('\n') + '\n\n';
+      }
+      var reply = this._newReply(comment.id, comment.line, quoteStr);
+      reply.__editing = true;
+      this.push('comments', reply);
+    },
+
+    _handleCommentDone: function(e) {
+      var comment = e.detail.comment;
+      var reply = this._newReply(comment.id, comment.line, 'Done');
+      this.push('comments', reply);
+
+      // Allow the reply to render in the dom-repeat.
+      this.async(function() {
+        var commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.save();
+      }.bind(this), 1);
+    },
+
+    _commentElWithDraftID: function(id) {
+      var els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      for (var i = 0; i < els.length; i++) {
+        if (els[i].comment.id === id || els[i].comment.__draftID === id) {
+          return els[i];
+        }
+      }
+      return null;
+    },
+
+    _newReply: function(inReplyTo, opt_lineNum, opt_message) {
+      var d = this._newDraft(opt_lineNum);
+      d.in_reply_to = inReplyTo;
+      if (opt_message != null) {
+        d.message = opt_message;
+      }
+      return d;
+    },
+
+    _newDraft: function(opt_lineNum, opt_range) {
+      var d = {
+        __draft: true,
+        __draftID: Math.random().toString(36),
+        __date: new Date(),
+        path: this.path,
+        side: this.side,
+      };
+      if (opt_lineNum) {
+        d.line = opt_lineNum;
+      }
+      if (opt_range) {
+        d.range = {
+          start_line: opt_range.startLine,
+          start_character: opt_range.startChar,
+          end_line: opt_range.endLine,
+          end_character: opt_range.endChar,
+        };
+      }
+      return d;
+    },
+
+    _handleCommentDiscard: function(e) {
+      var diffCommentEl = Polymer.dom(e).rootTarget;
+      var comment = diffCommentEl.comment;
+      var idx = this._indexOf(comment, this.comments);
+      if (idx == -1) {
+        throw Error('Cannot find comment ' +
+            JSON.stringify(diffCommentEl.comment));
+      }
+      this.splice('comments', idx, 1);
+      if (this.comments.length == 0) {
+        this.fire('thread-discard', {lastComment: comment});
+      }
+    },
+
+    _handleCommentUpdate: function(e) {
+      var comment = e.detail.comment;
+      var index = this._indexOf(comment, this.comments);
+      if (index === -1) {
+        // This should never happen: comment belongs to another thread.
+        console.error('Comment update for another comment thread.');
+        return;
+      }
+      this.comments[index] = comment;
+    },
+
+    _indexOf: function(comment, arr) {
+      for (var i = 0; i < arr.length; i++) {
+        var c = arr[i];
+        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+            (c.id != null && c.id == comment.id)) {
+          return i;
+        }
+      }
+      return -1;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
new file mode 100644
index 0000000..641dc0f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -0,0 +1,272 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-comment-thread</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-comment-thread.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment-thread></gr-diff-comment-thread>
+  </template>
+</test-fixture>
+
+<test-fixture id="withComment">
+  <template>
+    <gr-diff-comment-thread></gr-diff-comment-thread>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment-thread tests', function() {
+    var element;
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+    });
+
+    test('comments are sorted correctly', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        },
+        {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000'
+        },
+        {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 21:00:20.396000000'
+        }
+      ];
+      var results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000'
+        },
+        {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 21:00:20.396000000'
+        }
+      ]);
+    });
+  });
+
+  suite('comment action tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+        saveDiffDraft: function() {
+          return Promise.resolve({
+            ok: true,
+            text: function() { return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done'
+                }));
+            },
+          });
+        },
+        deleteDiffDraft: function() { return Promise.resolve({ok: true}); },
+      });
+      element = fixture('withComment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      }];
+      flushAsynchronousOperations();
+    });
+
+    test('reply', function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('reply', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.notOk(drafts[0].message, 'message should be empty');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('reply', {comment: commentEl.comment}, {bubbles: false});
+    });
+
+    test('quote reply', function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('reply', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, ' > is this a crossover episode!?\n\n');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('reply', {comment: commentEl.comment, quote: true},
+          {bubbles: false});
+    });
+
+    test('done', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('done', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, 'Done');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('done', {comment: commentEl.comment}, {bubbles: false});
+    });
+
+    test('discard', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.push('comments', element._newReply(
+        element.comments[0].id,
+        element.comments[0].line,
+        element.comments[0].path,
+        'it’s pronouced jiff, not giff'));
+      flushAsynchronousOperations();
+
+      var draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', function() {
+        var drafts = element.comments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 0);
+        done();
+      });
+      draftEl.fire('comment-discard', null, {bubbles: false});
+    });
+
+    test('comment-update', function() {
+      var commentEl = element.$$('gr-diff-comment');
+      var updatedComment = {
+        id: element.comments[0].id,
+        foo: 'bar',
+      };
+      commentEl.fire('comment-update', {comment: updatedComment});
+      assert.strictEqual(element.comments[0], updatedComment);
+    });
+
+    test('orphan replies', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+      element.comments = comments;
+      assert.equal(4, element._orderedComments.length);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
new file mode 100644
index 0000000..c3b6233
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -0,0 +1,158 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-diff-comment">
+  <template>
+    <style>
+      :host {
+        background-color: #ffd;
+        display: block;
+        --iron-autogrow-textarea: {
+          padding: 2px;
+        };
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: .5;
+      }
+      .header,
+      .message,
+      .actions {
+        padding: .5em .7em;
+      }
+      .header {
+        display: flex;
+        padding-bottom: 0;
+        font-family: 'Open Sans', sans-serif;
+      }
+      .headerLeft {
+        flex: 1;
+      }
+      .authorName,
+      .draftLabel {
+        font-weight: bold;
+      }
+      .draftLabel {
+        color: #999;
+        display: none;
+      }
+      .date {
+        justify-content: flex-end;
+        margin-left: 5px;
+      }
+      a.date:link,
+      a.date:visited {
+        color: #666;
+      }
+      .actions {
+        display: flex;
+        padding-top: 0;
+      }
+      .action {
+        margin-right: .5em;
+      }
+      .danger {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .editMessage {
+        display: none;
+        margin: .5em .7em;
+        width: calc(100% - 1.4em - 2px);
+      }
+      .danger .action {
+        margin-right: 0;
+      }
+      .container:not(.draft) .actions :not(.reply):not(.quote):not(.done) {
+        display: none;
+      }
+      .draft .reply,
+      .draft .quote,
+      .draft .done {
+        display: none;
+      }
+      .draft .draftLabel {
+        display: inline;
+      }
+      .draft:not(.editing) .save,
+      .draft:not(.editing) .cancel {
+        display: none;
+      }
+      .editing .message,
+      .editing .reply,
+      .editing .quote,
+      .editing .done,
+      .editing .edit {
+        display: none;
+      }
+      .editing .editMessage {
+        background-color: #fff;
+        display: block;
+      }
+    </style>
+    <div id="container"
+        class="container"
+        on-mouseenter="_handleMouseEnter"
+        on-mouseleave="_handleMouseLeave">
+      <div class="header" id="header">
+        <div class="headerLeft">
+          <span class="authorName">[[comment.author.name]]</span>
+          <span class="draftLabel">DRAFT</span>
+        </div>
+        <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
+          <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
+        </a>
+      </div>
+      <iron-autogrow-textarea
+          id="editTextarea"
+          class="editMessage"
+          disabled="{{disabled}}"
+          rows="4"
+          bind-value="{{_messageText}}"
+          on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
+      <gr-linked-text class="message"
+          pre
+          content="[[comment.message]]"
+          config="[[projectConfig.commentlinks]]"></gr-linked-text>
+      <div class="actions" hidden$="[[!showActions]]">
+        <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
+        <gr-button class="action quote" on-tap="_handleQuote">Quote</gr-button>
+        <gr-button class="action done" on-tap="_handleDone">Done</gr-button>
+        <gr-button class="action edit" on-tap="_handleEdit">Edit</gr-button>
+        <gr-button class="action save" on-tap="_handleSave"
+            disabled$="[[_computeSaveDisabled(_messageText)]]">Save</gr-button>
+        <gr-button class="action cancel" on-tap="_handleCancel" hidden>Cancel</gr-button>
+        <div class="danger">
+          <gr-button class="action discard" on-tap="_handleDiscard">Discard</gr-button>
+        </div>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+  </template>
+  <script src="gr-diff-comment.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
new file mode 100644
index 0000000..1b30bde
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -0,0 +1,353 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var STORAGE_DEBOUNCE_INTERVAL = 400;
+
+  Polymer({
+    is: 'gr-diff-comment',
+
+    /**
+     * Fired when the Reply action is triggered.
+     *
+     * @event reply
+     */
+
+    /**
+     * Fired when the Done action is triggered.
+     *
+     * @event done
+     */
+
+    /**
+     * Fired when this comment is discarded.
+     *
+     * @event comment-discard
+     */
+
+    /**
+     * Fired when this comment is saved.
+     *
+     * @event comment-save
+     */
+
+    /**
+     * Fired when this comment is updated.
+     *
+     * @event comment-update
+     */
+
+    /**
+     * @event comment-mouse-over
+     */
+
+    /**
+     * @event comment-mouse-out
+     */
+
+    properties: {
+      changeNum: String,
+      comment: {
+        type: Object,
+        notify: true,
+        observer: '_commentChanged',
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: Boolean,
+        value: false,
+        observer: '_draftChanged',
+      },
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_editingChanged',
+      },
+      patchNum: String,
+      showActions: Boolean,
+      projectConfig: Object,
+
+      _xhrPromise: Object,  // Used for testing.
+      _messageText: {
+        type: String,
+        value: '',
+        observer: '_messageTextChanged',
+      },
+    },
+
+    observers: [
+      '_commentMessageChanged(comment.message)',
+      '_loadLocalDraft(changeNum, patchNum, comment)',
+    ],
+
+    detached: function() {
+      this.cancelDebouncer('fire-update');
+    },
+
+    save: function() {
+      this.comment.message = this._messageText;
+      this.disabled = true;
+
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this.patchNum,
+        path: this.comment.path,
+        line: this.comment.line,
+      });
+
+      this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
+        this.disabled = false;
+        if (!response.ok) { return response; }
+
+        return this.$.restAPI.getResponseObject(response).then(function(obj) {
+          var comment = obj;
+          comment.__draft = true;
+          // Maintain the ephemeral draft ID for identification by other
+          // elements.
+          if (this.comment.__draftID) {
+            comment.__draftID = this.comment.__draftID;
+          }
+          this.comment = comment;
+          this.editing = false;
+          this._fireSave();
+          return obj;
+        }.bind(this));
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
+      }.bind(this));
+    },
+
+    _commentChanged: function(comment) {
+      this.editing = !!comment.__editing;
+      if (this.editing) { // It's a new draft/reply, notify.
+        this._fireUpdate();
+      }
+    },
+
+    _getEventPayload: function(opt_mixin) {
+      var payload = {
+        comment: this.comment,
+        patchNum: this.patchNum,
+      };
+      for (var k in opt_mixin) {
+        payload[k] = opt_mixin[k];
+      }
+      return payload;
+    },
+
+    _fireSave: function() {
+      this.fire('comment-save', this._getEventPayload());
+    },
+
+    _fireUpdate: function() {
+      this.debounce('fire-update', function() {
+        this.fire('comment-update', this._getEventPayload());
+      });
+    },
+
+    _draftChanged: function(draft) {
+      this.$.container.classList.toggle('draft', draft);
+    },
+
+    _editingChanged: function(editing, previousValue) {
+      this.$.container.classList.toggle('editing', editing);
+      if (editing) {
+        var textarea = this.$.editTextarea.textarea;
+        // Put the cursor at the end always.
+        textarea.selectionStart = textarea.value.length;
+        textarea.selectionEnd = textarea.selectionStart;
+        this.async(function() {
+          textarea.focus();
+        }.bind(this));
+      }
+      if (this.comment && this.comment.id) {
+        this.$$('.cancel').hidden = !editing;
+      }
+      if (this.comment) {
+        this.comment.__editing = this.editing;
+      }
+      if (editing != !!previousValue) {
+        // To prevent event firing on comment creation.
+        this._fireUpdate();
+      }
+    },
+
+    _computeLinkToComment: function(comment) {
+      return '#' + comment.line;
+    },
+
+    _computeSaveDisabled: function(draft) {
+      return draft == null || draft.trim() == '';
+    },
+
+    _handleTextareaKeydown: function(e) {
+      if (e.keyCode == 27) {  // 'esc'
+        this._handleCancel(e);
+      }
+    },
+
+    _commentMessageChanged: function(message) {
+      this._messageText = message || '';
+    },
+
+    _messageTextChanged: function(newValue, oldValue) {
+      if (!this.comment || (this.comment && this.comment.id)) { return; }
+
+      this.debounce('store', function() {
+        var message = this._messageText;
+
+        var commentLocation = {
+          changeNum: this.changeNum,
+          patchNum: this.patchNum,
+          path: this.comment.path,
+          line: this.comment.line,
+        };
+
+        if ((!this._messageText || !this._messageText.length) && oldValue) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(commentLocation);
+        } else {
+          this.$.storage.setDraftComment(commentLocation, message);
+        }
+        this._fireUpdate();
+      }, STORAGE_DEBOUNCE_INTERVAL);
+    },
+
+    _handleLinkTap: function(e) {
+      e.preventDefault();
+      var hash = this._computeLinkToComment(this.comment);
+      // Don't add the hash to the window history if it's already there.
+      // Otherwise you mess up expected back button behavior.
+      if (window.location.hash == hash) { return; }
+      // Change the URL but don’t trigger a nav event. Otherwise it will
+      // reload the page.
+      page.show(window.location.pathname + hash, null, false);
+    },
+
+    _handleReply: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire('reply', this._getEventPayload(), {bubbles: false});
+    },
+
+    _handleQuote: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire(
+          'reply', this._getEventPayload({quote: true}), {bubbles: false});
+    },
+
+    _handleDone: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire('done', this._getEventPayload(), {bubbles: false});
+    },
+
+    _handleEdit: function(e) {
+      this._preventDefaultAndBlur(e);
+      this._messageText = this.comment.message;
+      this.editing = true;
+    },
+
+    _handleSave: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.save();
+    },
+
+    _handleCancel: function(e) {
+      this._preventDefaultAndBlur(e);
+      if (this.comment.message == null || this.comment.message.length == 0) {
+        this._fireDiscard();
+        return;
+      }
+      this._messageText = this.comment.message;
+      this.editing = false;
+    },
+
+    _fireDiscard: function() {
+      this.cancelDebouncer('fire-update');
+      this.fire('comment-discard', this._getEventPayload());
+    },
+
+    _handleDiscard: function(e) {
+      this._preventDefaultAndBlur(e);
+      if (!this.comment.__draft) {
+        throw Error('Cannot discard a non-draft comment.');
+      }
+      this.editing = false;
+      this.disabled = true;
+      if (!this.comment.id) {
+        this.disabled = false;
+        this._fireDiscard();
+        return;
+      }
+
+      this._xhrPromise = this._deleteDraft(this.comment).then(
+          function(response) {
+            this.disabled = false;
+            if (!response.ok) { return response; }
+
+            this._fireDiscard();
+          }.bind(this)).catch(function(err) {
+            this.disabled = false;
+            throw err;
+          }.bind(this));
+    },
+
+    _preventDefaultAndBlur: function(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.blur();
+    },
+
+    _saveDraft: function(draft) {
+      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
+    },
+
+    _deleteDraft: function(draft) {
+      return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
+          draft);
+    },
+
+    _loadLocalDraft: function(changeNum, patchNum, comment) {
+      // Only apply local drafts to comments that haven't been saved
+      // remotely, and haven't been given a default message already.
+      if (!comment || comment.id || comment.message) {
+        return;
+      }
+
+      var draft = this.$.storage.getDraftComment({
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: comment.path,
+        line: comment.line,
+      });
+
+      if (draft) {
+        this.set('comment.message', draft.message);
+      }
+    },
+
+    _handleMouseEnter: function(e) {
+      this.fire('comment-mouse-over', this._getEventPayload());
+    },
+
+    _handleMouseLeave: function(e) {
+      this.fire('comment-mouse-out', this._getEventPayload());
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
new file mode 100644
index 0000000..fcf8b41
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -0,0 +1,291 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-comment</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-comment.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<test-fixture id="draft">
+  <template>
+    <gr-diff-comment draft="true"></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment tests', function() {
+    var element;
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(null); },
+      });
+      element = fixture('basic');
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+    });
+
+    test('proper event fires on reply', function(done) {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        done();
+      });
+      MockInteractions.tap(element.$$('.reply'));
+    });
+
+    test('proper event fires on quote', function(done) {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        assert.isTrue(e.detail.quote);
+        done();
+      });
+      MockInteractions.tap(element.$$('.quote'));
+    });
+
+    test('proper event fires on done', function(done) {
+      element.addEventListener('done', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.done'));
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+
+  suite('gr-diff-comment draft tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(null); },
+        saveDiffDraft: function() {
+          return Promise.resolve({
+            ok: true,
+            text: function() {
+              return Promise.resolve(
+                ')]}\'\n{' +
+                  '"id": "baf0414d_40572e03",' +
+                  '"path": "/path/to/file",' +
+                  '"line": 5,' +
+                  '"updated": "2015-12-08 21:52:36.177000000",' +
+                  '"message": "saved!"' +
+                '}'
+              );
+            },
+          });
+        },
+        removeChangeReviewer: function() {
+          return Promise.resolve({ok: true});
+        },
+      });
+      stub('gr-storage', {
+        getDraftComment: function() { return null; },
+      });
+      element = fixture('draft');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+    });
+
+    function isVisible(el) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') != 'none';
+    }
+
+    test('button visibility states', function() {
+      element.showActions = false;
+      assert.isTrue(element.$$('.actions').hasAttribute('hidden'));
+      element.showActions = true;
+      assert.isFalse(element.$$('.actions').hasAttribute('hidden'));
+
+      element.draft = true;
+      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.editing = true;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.draft = false;
+      element.editing = false;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.$$('.discard')),
+          'discard is not visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.$$('.reply')), 'reply is visible');
+      assert.isTrue(isVisible(element.$$('.quote')), 'quote is visible');
+      assert.isTrue(isVisible(element.$$('.done')), 'done is visible');
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+    });
+
+    test('draft creation/cancelation', function(done) {
+      assert.isFalse(element.editing);
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = '';
+      // Save should be disabled on an empty message.
+      var disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      var updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      var numDiscardEvents = 0;
+      element.addEventListener('comment-discard', function(e) {
+        numDiscardEvents++;
+        if (numDiscardEvents == 3) {
+          assert.isFalse(updateStub.called);
+          done();
+        }
+      });
+      MockInteractions.tap(element.$$('.cancel'));
+      MockInteractions.tap(element.$$('.discard'));
+      element.flushDebouncer('fire-update');
+      MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
+    });
+
+    test('draft saving/editing', function(done) {
+      var fireStub = sinon.stub(element, 'fire');
+
+      element.draft = true;
+      MockInteractions.tap(element.$$('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert(fireStub.calledWith('comment-update'),
+             'comment-update should be sent');
+      assert.deepEqual(fireStub.lastCall.args, [
+        'comment-update', {
+          comment: {
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            __editing: true,
+            line: 5,
+            path: '/path/to/file',
+          },
+          patchNum: 1,
+        },
+      ]);
+      MockInteractions.tap(element.$$('.save'));
+
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      element._xhrPromise.then(function(draft) {
+        assert(fireStub.calledWith('comment-save'),
+               'comment-save should be sent');
+        assert.deepEqual(fireStub.lastCall.args[1], {
+          comment: {
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            __editing: false,
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
+          },
+          patchNum: 1,
+        });
+        assert.isFalse(element.disabled,
+                       'Element should be enabled when done creating draft.');
+        assert.equal(draft.message, 'saved!');
+        assert.isFalse(element.editing);
+      }).then(function() {
+        MockInteractions.tap(element.$$('.edit'));
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
+        MockInteractions.tap(element.$$('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+
+        element._xhrPromise.then(function(draft) {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(draft.message, 'saved!');
+          assert.isFalse(element.editing);
+          fireStub.restore();
+          done();
+        });
+      });
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
new file mode 100644
index 0000000..5a41709
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -0,0 +1,30 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-diff-cursor">
+  <template>
+    <gr-cursor-manager
+        id="cursorManager"
+        scroll="keep-visible"
+        cursor-target-class="target-row"
+        fold-offset-top="[[foldOffsetTop]]"
+        target="{{diffRow}}"></gr-cursor-manager>
+  </template>
+  <script src="gr-diff-cursor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
new file mode 100644
index 0000000..99a0b5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -0,0 +1,350 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var DiffSides = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var LEFT_SIDE_CLASS = 'target-side-left';
+  var RIGHT_SIDE_CLASS = 'target-side-right';
+
+  Polymer({
+    is: 'gr-diff-cursor',
+
+    properties: {
+      /**
+       * Either DiffSides.LEFT or DiffSides.RIGHT.
+       */
+      side: {
+        type: String,
+        value: DiffSides.RIGHT,
+      },
+      diffRow: {
+        type: Object,
+        notify: true,
+        observer: '_rowChanged',
+      },
+
+      /**
+       * The diff views to cursor through and listen to.
+       */
+      diffs: {
+        type: Array,
+        value: function() {
+          return [];
+        },
+      },
+
+      foldOffsetTop: {
+        type: Number,
+        value: 0,
+      },
+
+      /**
+       * If set, the cursor will attempt to move to the line number (instead of
+       * the first chunk) the next time the diff renders. It is set back to null
+       * when used.
+       */
+      initialLineNumber: {
+        type: Number,
+        value: null,
+      },
+    },
+
+    observers: [
+      '_updateSideClass(side)',
+      '_diffsChanged(diffs.splices)',
+    ],
+
+    moveLeft: function() {
+      this.side = DiffSides.LEFT;
+      if (this._isTargetBlank()) {
+        this.moveUp();
+      }
+    },
+
+    moveRight: function() {
+      this.side = DiffSides.RIGHT;
+      if (this._isTargetBlank()) {
+        this.moveUp();
+      }
+    },
+
+    moveDown: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.next(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.next();
+      }
+    },
+
+    moveUp: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.previous(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.previous();
+      }
+    },
+
+    moveToNextChunk: function() {
+      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousChunk: function() {
+      this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToNextCommentThread: function() {
+      this.$.cursorManager.next(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousCommentThread: function() {
+      this.$.cursorManager.previous(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    moveToLineNumber: function(number, side) {
+      var row = this._findRowByNumber(number, side);
+      if (row) {
+        this.side = side;
+        this.$.cursorManager.setCursor(row);
+      }
+    },
+
+    /**
+     * Get the line number element targeted by the cursor row and side.
+     * @return {DOMElement}
+     */
+    getTargetLineElement: function() {
+      var lineElSelector = '.lineNum';
+
+      if (!this.diffRow) {
+        return;
+      }
+
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
+      }
+
+      return this.diffRow.querySelector(lineElSelector);
+    },
+
+    getTargetDiffElement: function() {
+      // Find the parent diff element of the cursor row.
+      for (var diff = this.diffRow; diff; diff = diff.parentElement) {
+        if (diff.tagName === 'GR-DIFF') { return diff; }
+      }
+      return null;
+    },
+
+    moveToFirstChunk: function() {
+      this.$.cursorManager.moveToStart();
+      this.moveToNextChunk();
+    },
+
+    reInitCursor: function() {
+      this._updateStops();
+      if (this.initialLineNumber) {
+        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.initialLineNumber = null;
+      } else {
+        this.moveToFirstChunk();
+      }
+    },
+
+    handleDiffUpdate: function() {
+      this._updateStops();
+
+      if (!this.diffRow) {
+        this.reInitCursor();
+      }
+    },
+
+    /**
+     * Get a short address for the location of the cursor. Such as '123' for
+     * line 123 of the revision, or 'b321' for line 321 of the base patch.
+     * Returns an empty string if an address is not available.
+     * @return {String}
+     */
+    getAddress: function() {
+      if (!this.diffRow) { return ''; }
+
+      // Get the line-number cell targeted by the cursor. If the mode is unified
+      // then prefer the revision cell if available.
+      var cell;
+      if (this._getViewMode() === DiffViewMode.UNIFIED) {
+        cell = this.diffRow.querySelector('.lineNum.right');
+        if (!cell) {
+          cell = this.diffRow.querySelector('.lineNum.left');
+        }
+      } else {
+        cell = this.diffRow.querySelector('.lineNum.' + this.side);
+      }
+      if (!cell) { return ''; }
+
+      var number = cell.getAttribute('data-value');
+      if (!number || number === 'FILE') { return ''; }
+
+      return (cell.matches('.left') ? 'b' : '') + number;
+    },
+
+    _getViewMode: function() {
+      if (!this.diffRow) {
+        return null;
+      }
+
+      if (this.diffRow.classList.contains('side-by-side')) {
+        return DiffViewMode.SIDE_BY_SIDE;
+      } else {
+        return DiffViewMode.UNIFIED;
+      }
+    },
+
+    _rowHasSide: function(row) {
+      var selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
+          ' + .content';
+      return !!row.querySelector(selector);
+    },
+
+    _isFirstRowOfChunk: function(row) {
+      var parentClassList = row.parentNode.classList;
+      return parentClassList.contains('section') &&
+          parentClassList.contains('delta') &&
+          !row.previousSibling;
+    },
+
+    _rowHasThread: function(row) {
+      return row.querySelector('gr-diff-comment-thread');
+    },
+
+    /**
+     * If we jumped to a row where there is no content on the current side then
+     * switch to the alternate side.
+     */
+    _fixSide: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+          this._isTargetBlank()) {
+        this.side = this.side === DiffSides.LEFT ?
+            DiffSides.RIGHT : DiffSides.LEFT;
+      }
+    },
+
+    _isTargetBlank: function() {
+      if (!this.diffRow) {
+        return false;
+      }
+
+      var actions = this._getActionsForRow();
+      return (this.side === DiffSides.LEFT && !actions.left) ||
+          (this.side === DiffSides.RIGHT && !actions.right);
+    },
+
+    _rowChanged: function(newRow, oldRow) {
+      if (oldRow) {
+        oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      }
+      this._updateSideClass();
+    },
+
+    _updateSideClass: function() {
+      if (!this.diffRow) {
+        return;
+      }
+      this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
+          this.diffRow);
+      this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
+          this.diffRow);
+    },
+
+    _isActionType: function(type) {
+      return type !== 'blank' && type !== 'contextControl';
+    },
+
+    _getActionsForRow: function() {
+      var actions = {left: false, right: false};
+      if (this.diffRow) {
+        actions.left = this._isActionType(
+            this.diffRow.getAttribute('left-type'));
+        actions.right = this._isActionType(
+            this.diffRow.getAttribute('right-type'));
+      }
+      return actions;
+    },
+
+    _getStops: function() {
+      return this.diffs.reduce(
+          function(stops, diff) {
+            return stops.concat(diff.getCursorStops());
+          }, []);
+    },
+
+    _updateStops: function() {
+      this.$.cursorManager.stops = this._getStops();
+    },
+
+    /**
+     * Setup and tear down on-render listeners for any diffs that are added or
+     * removed from the cursor.
+     * @private
+     */
+    _diffsChanged: function(changeRecord) {
+      if (!changeRecord) { return; }
+
+      this._updateStops();
+
+      var splice;
+      var i;
+      for (var spliceIdx = 0;
+        changeRecord.indexSplices &&
+            spliceIdx < changeRecord.indexSplices.length;
+        spliceIdx++) {
+        splice = changeRecord.indexSplices[spliceIdx];
+
+        for (i = splice.index;
+            i < splice.index + splice.addedCount;
+            i++) {
+          this.listen(this.diffs[i], 'render', 'handleDiffUpdate');
+        }
+
+        for (i = 0;
+            i < splice.removed && splice.removed.length;
+            i++) {
+          this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
+        }
+      }
+    },
+
+    _findRowByNumber: function(targetNumber, side) {
+      var stops = this.$.cursorManager.stops;
+      var selector;
+      for (var i = 0; i < stops.length; i++) {
+        selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
+        if (stops[i].querySelector(selector)) {
+          return stops[i];
+        }
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
new file mode 100644
index 0000000..5bdd138
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -0,0 +1,261 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-cursor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="./gr-diff-cursor.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
+
+<test-fixture id="basic">
+  <template>
+    <mock-diff-response></mock-diff-response>
+    <gr-diff></gr-diff>
+    <gr-diff-cursor></gr-diff-cursor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-cursor tests', function() {
+    var cursorElement;
+    var diffElement;
+    var mockDiffResponse;
+
+    setup(function(done) {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+
+      var fixtureElems = fixture('basic');
+      mockDiffResponse = fixtureElems[0];
+      diffElement = fixtureElems[1];
+      cursorElement = fixtureElems[2];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', diffElement);
+
+      diffElement.$.restAPI.getDiffPreferences().then(function(prefs) {
+        diffElement.prefs = prefs;
+      });
+
+      sinon.stub(diffElement, '_getDiff', function() {
+        return Promise.resolve(mockDiffResponse.diffResponse);
+      });
+
+      sinon.stub(diffElement, '_getDiffComments', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      sinon.stub(diffElement, '_getDiffDrafts', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      var setupDone = function() {
+        cursorElement.moveToFirstChunk();
+        done();
+        diffElement.removeEventListener('render', setupDone);
+      };
+      diffElement.addEventListener('render', setupDone);
+
+      diffElement.reload();
+    });
+
+    test('diff cursor functionality (side-by-side)', function() {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+
+    suite('unified diff', function() {
+
+      setup(function(done) {
+        // We must allow the diff to re-render after setting the viewMode.
+        var renderHandler = function() {
+          diffElement.removeEventListener('render', renderHandler);
+          cursorElement.reInitCursor();
+          done();
+        };
+        diffElement.addEventListener('render', renderHandler);
+        diffElement.viewMode = 'UNIFIED_DIFF';
+      });
+
+      test('diff cursor functionality (unified)', function() {
+        // The cursor has been initialized to the first delta.
+        assert.isOk(cursorElement.diffRow);
+
+        var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+        assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+        firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+        assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+        cursorElement.moveDown();
+
+        assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+        assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+        cursorElement.moveUp();
+
+        assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+        assert.equal(cursorElement.diffRow, firstDeltaRow);
+      });
+    });
+
+    test('cursor side functionality', function() {
+      // The side only applies to side-by-side mode, which should be the default
+      // mode.
+      assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+      var firstDeltaSection = diffElement.$$('.section.delta');
+      var firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+
+      // Because the first delta in this diff is on the right, it should be set
+      // to the right side.
+      assert.equal(cursorElement.side, 'right');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      var firstIndex = cursorElement.$.cursorManager.index;
+
+      // Move the side to the left. Because this delta only has a right side, we
+      // should be moved up to the previous line where there is content on the
+      // right. The previous row is part of the previous section.
+      cursorElement.moveLeft();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.previousSibling);
+
+      // If we move down, we should skip everything in the first delta because
+      // we are on the left side and the first delta has no content on the left.
+      cursorElement.moveDown();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.nextSibling);
+    });
+
+    test('chunk skip functionality', function() {
+      var chunks = Polymer.dom(diffElement.root).querySelectorAll(
+          '.section.delta');
+      var indexOfChunk = function(chunk) {
+        return Array.prototype.indexOf.call(chunks, chunk);
+      };
+
+      // We should be initialized to the first chunk. Since this chunk only has
+      // content on the right side, our side should be right.
+      var currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, 0);
+      assert.equal(cursorElement.side, 'right');
+
+      // Move to the next chunk.
+      cursorElement.moveToNextChunk();
+
+      // Since this chunk only has content on the left side. we should have been
+      // automatically mvoed over.
+      var previousIndex = currentIndex;
+      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, previousIndex + 1);
+      assert.equal(cursorElement.side, 'left');
+    });
+
+    test('initialLineNumber disabled', function(done) {
+      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+
+      diffElement.addEventListener('render', function() {
+        assert.isFalse(moveToNumStub.called);
+        assert.isTrue(moveToChunkStub.called);
+        done();
+      });
+
+      diffElement.reload();
+    });
+
+    test('initialLineNumber enabled', function(done) {
+      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+
+      diffElement.addEventListener('render', function() {
+        assert.isFalse(moveToChunkStub.called);
+        assert.isTrue(moveToNumStub.called);
+        assert.equal(moveToNumStub.lastCall.args[0], 10);
+        assert.equal(moveToNumStub.lastCall.args[1], 'right');
+        done();
+      });
+
+      cursorElement.initialLineNumber = 10;
+      cursorElement.side = 'right';
+
+      diffElement.reload();
+    });
+
+    test('getAddress', function() {
+      // It should initialize to the first chunk: line 5 of the revision.
+      assert.equal(cursorElement.getAddress(), '5');
+
+      // Revision line 4 is up.
+      cursorElement.moveUp();
+      assert.equal(cursorElement.getAddress(), '4');
+
+      // Base line 4 is left.
+      cursorElement.moveLeft();
+      assert.equal(cursorElement.getAddress(), 'b4');
+
+      // Moving to the next chunk takes it back to the start.
+      cursorElement.moveToNextChunk();
+      assert.equal(cursorElement.getAddress(), '5');
+
+      // The following chunk is a removal starting on line 10 of the base.
+      cursorElement.moveToNextChunk();
+      assert.equal(cursorElement.getAddress(), 'b10');
+
+      // Should be an empty string if there is no selection.
+      cursorElement.$.cursorManager.unsetCursor();
+      assert.equal(cursorElement.getAddress(), '');
+    });
+
+    test('_findRowByNumber', function() {
+      // Get the first ab row after the first chunk.
+      var row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
+
+      // It should be line 8 on the right, but line 5 on the left.
+      assert.equal(cursorElement._findRowByNumber(8, 'right'), row);
+      assert.equal(cursorElement._findRowByNumber(5, 'left'), row);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
new file mode 100644
index 0000000..ec21fd1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -0,0 +1,209 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrAnnotation) { return; }
+
+  // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+  var ANNOTATION_TAG = 'HL';
+
+  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+  var GrAnnotation = {
+
+    /**
+     * The DOM API textContent.length calculation is broken when the text
+     * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+     * @param  {Text} A text node.
+     * @return {Number} The length of the text.
+     */
+    getLength: function(node) {
+      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+    },
+
+    /**
+     * Surrounds the element's text at specified range in an ANNOTATION_TAG
+     * element. If the element has child elements, the range is split and
+     * applied as deeply as possible.
+     */
+    annotateElement: function(parent, offset, length, cssClass) {
+      var nodes = [].slice.apply(parent.childNodes);
+      var node;
+      var nodeLength;
+      var subLength;
+
+      for (var i = 0; i < nodes.length; i++) {
+        node = nodes[i];
+        nodeLength = this.getLength(node);
+
+        // If the current node is completely before the offset.
+        if (nodeLength <= offset) {
+          offset -= nodeLength;
+          continue;
+        }
+
+        // Sublength is the annotation length for the current node.
+        subLength = Math.min(length, nodeLength - offset);
+
+        if (node instanceof Text) {
+          this._annotateText(node, offset, subLength, cssClass);
+        } else if (node instanceof HTMLElement) {
+          this.annotateElement(node, offset, subLength, cssClass);
+        }
+
+        // If there is still more to annotate, then shift the indices, otherwise
+        // work is done, so break the loop.
+        if (subLength < length) {
+          length -= subLength;
+          offset = 0;
+        } else {
+          break;
+        }
+      }
+    },
+
+    /**
+     * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+     *
+     * @return {!Element} Wrapped node.
+     */
+    wrapInHighlight: function(node, cssClass) {
+      var hl;
+      if (node.tagName === ANNOTATION_TAG) {
+        hl = node;
+        hl.classList.add(cssClass);
+      } else {
+        hl = document.createElement(ANNOTATION_TAG);
+        hl.className = cssClass;
+        Polymer.dom(node.parentElement).replaceChild(hl, node);
+        Polymer.dom(hl).appendChild(node);
+      }
+      return hl;
+    },
+
+    /**
+     * Splits Text Node and wraps it in hl with cssClass.
+     * Wraps trailing part after split, tailing one if opt_firstPart is true.
+     *
+     * @param {!Node} node
+     * @param {number} offset
+     * @param {string} cssClass
+     * @param {boolean=} opt_firstPart
+     */
+    splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
+      if (this.getLength(node) === offset || offset === 0) {
+        return this.wrapInHighlight(node, cssClass);
+      } else {
+        if (opt_firstPart) {
+          this.splitNode(node, offset);
+          // Node points to first part of the Text, second one is sibling.
+        } else {
+          node = this.splitNode(node, offset);
+        }
+        return this.wrapInHighlight(node, cssClass);
+      }
+    },
+
+    /**
+     * Splits Node at offset.
+     * If Node is Element, it's cloned and the node at offset is split too.
+     *
+     * @param {!Node} node
+     * @param {number} offset
+     * @return {!Node} Trailing Node.
+     */
+    splitNode: function(element, offset) {
+      if (element instanceof Text) {
+        return this.splitTextNode(element, offset);
+      }
+      var tail = element.cloneNode(false);
+      element.parentElement.insertBefore(tail, element.nextSibling);
+      // Skip nodes before offset.
+      var node = element.firstChild;
+      while (node &&
+          this.getLength(node) <= offset ||
+          this.getLength(node) === 0) {
+        offset -= this.getLength(node);
+        node = node.nextSibling;
+      }
+      if (this.getLength(node) > offset) {
+        tail.appendChild(this.splitNode(node, offset));
+      }
+      while (node.nextSibling) {
+        tail.appendChild(node.nextSibling);
+      }
+      return tail;
+    },
+
+    /**
+     * Node.prototype.splitText Unicode-valid alternative.
+     *
+     * DOM Api for splitText() is broken for Unicode:
+     * https://mathiasbynens.be/notes/javascript-unicode
+     *
+     * @param {!Text} node
+     * @param {number} offset
+     * @return {!Text} Trailing Text Node.
+     */
+    splitTextNode: function(node, offset) {
+      if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
+        // TODO (viktard): Polyfill Array.from for IE10.
+        var head = Array.from(node.textContent);
+        var tail = head.splice(offset);
+        var parent = node.parentNode;
+
+        // Split the content of the original node.
+        node.textContent = head.join('');
+
+        var tailNode = document.createTextNode(tail.join(''));
+        if (parent) {
+          parent.insertBefore(tailNode, node.nextSibling);
+        }
+        return tailNode;
+      } else {
+        return node.splitText(offset);
+      }
+    },
+
+    _annotateText: function(node, offset, length, cssClass) {
+      var nodeLength = this.getLength(node);
+
+      // There are four cases:
+      //  1) Entire node is highlighted.
+      //  2) Highlight is at the start.
+      //  3) Highlight is at the end.
+      //  4) Highlight is in the middle.
+
+      if (offset === 0 && nodeLength === length) {
+        // Case 1.
+        this.wrapInHighlight(node, cssClass);
+      } else if (offset === 0) {
+        // Case 2.
+        this.splitAndWrapInHighlight(node, length, cssClass, true);
+      } else if (offset + length === nodeLength) {
+        // Case 3
+        this.splitAndWrapInHighlight(node, offset, cssClass, false);
+      } else {
+        // Case 4
+        this.splitAndWrapInHighlight(this.splitTextNode(node, offset), length,
+            cssClass, true);
+      }
+    },
+  };
+
+  window.GrAnnotation = GrAnnotation;
+})(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
new file mode 100644
index 0000000..27a684d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -0,0 +1,190 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-annotation</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="gr-annotation.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<test-fixture id="basic">
+  <template>
+    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('annotation', function() {
+    var str;
+    var parent;
+    var textNode;
+
+    setup(function() {
+      parent = fixture('basic');
+      textNode = parent.childNodes[0];
+      str = textNode.textContent;
+    });
+
+    test('_annotateText Case 1', function() {
+      GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+      assert.equal(parent.childNodes.length, 1);
+      assert.instanceOf(parent.childNodes[0], HTMLElement);
+      assert.equal(parent.childNodes[0].className, 'foobar');
+      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+      assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+    });
+
+    test('_annotateText Case 2', function() {
+      var length = 12;
+      var substr = str.substr(0, length);
+      var remainder = str.substr(length);
+
+      GrAnnotation._annotateText(textNode, 0, length, 'foobar');
+
+      assert.equal(parent.childNodes.length, 2);
+
+      assert.instanceOf(parent.childNodes[0], HTMLElement);
+      assert.equal(parent.childNodes[0].className, 'foobar');
+      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+      assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
+
+      assert.instanceOf(parent.childNodes[1], Text);
+      assert.equal(parent.childNodes[1].textContent, remainder);
+    });
+
+    test('_annotateText Case 3', function() {
+      var index = 12;
+      var length = str.length - index;
+      var remainder = str.substr(0, index);
+      var substr = str.substr(index);
+
+      GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+      assert.equal(parent.childNodes.length, 2);
+
+      assert.instanceOf(parent.childNodes[0], Text);
+      assert.equal(parent.childNodes[0].textContent, remainder);
+
+      assert.instanceOf(parent.childNodes[1], HTMLElement);
+      assert.equal(parent.childNodes[1].className, 'foobar');
+      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+    });
+
+    test('_annotateText Case 4', function() {
+      var index = str.indexOf('dolor');
+      var length = 'dolor '.length;
+
+      var remainderPre = str.substr(0, index);
+      var substr = str.substr(index, length);
+      var remainderPost = str.substr(index + length);
+
+      GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+      assert.equal(parent.childNodes.length, 3);
+
+      assert.instanceOf(parent.childNodes[0], Text);
+      assert.equal(parent.childNodes[0].textContent, remainderPre);
+
+      assert.instanceOf(parent.childNodes[1], HTMLElement);
+      assert.equal(parent.childNodes[1].className, 'foobar');
+      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+
+      assert.instanceOf(parent.childNodes[2], Text);
+      assert.equal(parent.childNodes[2].textContent, remainderPost);
+    });
+
+    test('_annotateElement design doc example', function() {
+      var layers = [
+        'amet, ',
+        'inceptos ',
+        'amet, ',
+        'et, suspendisse ince'
+      ];
+
+      // Apply the layers successively.
+      layers.forEach(function(layer, i) {
+        GrAnnotation.annotateElement(
+            parent, str.indexOf(layer), layer.length, 'layer-' + (i + 1));
+      });
+
+      assert.equal(parent.textContent, str);
+
+      // Layer 1:
+      var layer1 = parent.querySelectorAll('.layer-1');
+      assert.equal(layer1.length, 1);
+      assert.equal(layer1[0].textContent, layers[0]);
+      assert.equal(layer1[0].parentElement, parent);
+
+      // Layer 2:
+      var layer2 = parent.querySelectorAll('.layer-2');
+      assert.equal(layer2.length, 1);
+      assert.equal(layer2[0].textContent, layers[1]);
+      assert.equal(layer2[0].parentElement, parent);
+
+      // Layer 3:
+      var layer3 = parent.querySelectorAll('.layer-3');
+      assert.equal(layer3.length, 1);
+      assert.equal(layer3[0].textContent, layers[2]);
+      assert.equal(layer3[0].parentElement, layer1[0]);
+
+      // Layer 4:
+      var layer4 = parent.querySelectorAll('.layer-4');
+      assert.equal(layer4.length, 3);
+
+      assert.equal(layer4[0].textContent, 'et, ');
+      assert.equal(layer4[0].parentElement, layer3[0]);
+
+      assert.equal(layer4[1].textContent, 'suspendisse ');
+      assert.equal(layer4[1].parentElement, parent);
+
+      assert.equal(layer4[2].textContent, 'ince');
+      assert.equal(layer4[2].parentElement, layer2[0]);
+
+      assert.equal(layer4[0].textContent +
+          layer4[1].textContent +
+          layer4[2].textContent,
+          layers[3]);
+    });
+
+    test('splitTextNode', function() {
+      var helloString = 'hello';
+      var asciiString = 'ASCII';
+      var unicodeString = 'Unic💢de';
+
+      var node;
+      var tail;
+
+      // Non-unicode path:
+      node = document.createTextNode(helloString + asciiString);
+      tail = GrAnnotation.splitTextNode(node, helloString.length);
+      assert(node.textContent, helloString);
+      assert(tail.textContent, asciiString);
+
+      // Unicdoe path:
+      node = document.createTextNode(helloString + unicodeString);
+      tail = GrAnnotation.splitTextNode(node, helloString.length);
+      assert(node.textContent, helloString);
+      assert(tail.textContent, unicodeString);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
new file mode 100644
index 0000000..54294a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -0,0 +1,41 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
+
+<dom-module id="gr-diff-highlight">
+  <template>
+    <style>
+      .contentWrapper ::content {
+        position: relative;
+      }
+      .contentWrapper ::content .range {
+        background-color: rgba(255,213,0,0.5);
+        display: inline;
+      }
+      .contentWrapper ::content .rangeHighlight {
+        background-color: rgba(255,255,0,0.5);
+        display: inline;
+      }
+    </style>
+    <div class="contentWrapper">
+      <content></content>
+    </div>
+  </template>
+  <script src="gr-annotation.js"></script>
+  <script src="gr-diff-highlight.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
new file mode 100644
index 0000000..bfe103b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -0,0 +1,274 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-highlight',
+
+    properties: {
+      comments: Object,
+      loggedIn: Boolean,
+      _cachedDiffBuilder: Object,
+      isAttached: Boolean,
+    },
+
+    listeners: {
+      'comment-mouse-out': '_handleCommentMouseOut',
+      'comment-mouse-over': '_handleCommentMouseOver',
+      'create-comment': '_createComment',
+    },
+
+    observers: [
+      '_enableSelectionObserver(loggedIn, isAttached)',
+    ],
+
+    get diffBuilder() {
+      if (!this._cachedDiffBuilder) {
+        this._cachedDiffBuilder =
+            Polymer.dom(this).querySelector('gr-diff-builder');
+      }
+      return this._cachedDiffBuilder;
+    },
+
+    _enableSelectionObserver: function(loggedIn, isAttached) {
+      if (loggedIn && isAttached) {
+        this.listen(document, 'selectionchange', '_handleSelectionChange');
+      } else {
+        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+      }
+    },
+
+    isRangeSelected: function() {
+      return !!this.$$('gr-selection-action-box');
+    },
+
+    _handleSelectionChange: function() {
+      // Can't use up or down events to handle selection started and/or ended in
+      // in comment threads or outside of diff.
+      // Debounce removeActionBox to give it a chance to react to click/tap.
+      this._removeActionBoxDebounced();
+      this.debounce('selectionChange', this._handleSelection, 200);
+    },
+
+    _handleCommentMouseOver: function(e) {
+      var comment = e.detail.comment;
+      if (!comment.range) { return; }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var index = this._indexOfComment(side, comment);
+      if (index !== undefined) {
+        this.set(['comments', side, index, '__hovering'], true);
+      }
+    },
+
+    _handleCommentMouseOut: function(e) {
+      var comment = e.detail.comment;
+      if (!comment.range) { return; }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var index = this._indexOfComment(side, comment);
+      if (index !== undefined) {
+        this.set(['comments', side, index, '__hovering'], false);
+      }
+    },
+
+    _indexOfComment: function(side, comment) {
+      var idProp = comment.id ? 'id' : '__draftID';
+      for (var i = 0; i < this.comments[side].length; i++) {
+        if (comment[idProp] &&
+            this.comments[side][i][idProp] === comment[idProp]) {
+          return i;
+        }
+      }
+    },
+
+    /**
+     * Convert DOM Range selection to concrete numbers (line, column, side).
+     * Moves range end if it's not inside td.content.
+     * Returns null if selection end is not valid (outside of diff).
+     *
+     * @param {Node} node td.content child
+     * @param {number} offset offset within node
+     * @return {{
+     *   node: Node,
+     *   side: string,
+     *   line: Number,
+     *   column: Number
+     * }}
+     */
+    _normalizeSelectionSide: function(node, offset) {
+      var column;
+      if (!this.contains(node)) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(node);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      if (!side) {
+        return;
+      }
+      var line = this.diffBuilder.getLineNumberByChild(lineEl);
+      if (!line) {
+        return;
+      }
+      var contentText = this.diffBuilder.getContentByLineEl(lineEl);
+      if (!contentText) {
+        return;
+      }
+      var contentTd = contentText.parentElement;
+      if (!contentTd.contains(node)) {
+        node = contentText;
+        column = 0;
+      } else {
+        var thread = contentTd.querySelector('gr-diff-comment-thread');
+        if (thread && thread.contains(node)) {
+          column = this._getLength(contentText);
+          node = contentText;
+        } else {
+          column = this._convertOffsetToColumn(node, offset);
+        }
+      }
+
+      return {
+        node: node,
+        side: side,
+        line: line,
+        column: column,
+      };
+    },
+
+    _handleSelection: function() {
+      var selection = window.getSelection();
+      if (selection.rangeCount != 1) {
+        return;
+      }
+      var range = selection.getRangeAt(0);
+      if (range.collapsed) {
+        return;
+      }
+      var start =
+          this._normalizeSelectionSide(range.startContainer, range.startOffset);
+      if (!start) {
+        return;
+      }
+      var end =
+          this._normalizeSelectionSide(range.endContainer, range.endOffset);
+      if (!end) {
+        return;
+      }
+      if (start.side !== end.side ||
+          end.line < start.line ||
+          (start.line === end.line && start.column === end.column)) {
+        return;
+      }
+
+      // TODO (viktard): Drop empty first and last lines from selection.
+
+      var actionBox = document.createElement('gr-selection-action-box');
+      Polymer.dom(this.root).appendChild(actionBox);
+      actionBox.range = {
+        startLine: start.line,
+        startChar: start.column,
+        endLine: end.line,
+        endChar: end.column,
+      };
+      actionBox.side = start.side;
+      if (start.line === end.line) {
+        actionBox.placeAbove(range);
+      } else if (start.node instanceof Text) {
+        actionBox.placeAbove(start.node.splitText(start.column));
+        start.node.parentElement.normalize(); // Undo splitText from above.
+      } else if (start.node.classList.contains('content') &&
+                 start.node.firstChild) {
+        actionBox.placeAbove(start.node.firstChild);
+      } else {
+        actionBox.placeAbove(start.node);
+      }
+    },
+
+    _createComment: function(e) {
+      this._removeActionBox();
+    },
+
+    _removeActionBoxDebounced: function() {
+      this.debounce('removeActionBox', this._removeActionBox, 10);
+    },
+
+    _removeActionBox: function() {
+      var actionBox = this.$$('gr-selection-action-box');
+      if (actionBox) {
+        Polymer.dom(this.root).removeChild(actionBox);
+      }
+    },
+
+    _convertOffsetToColumn: function(el, offset) {
+      if (el instanceof Element && el.classList.contains('content')) {
+        return offset;
+      }
+      while (el.previousSibling ||
+          !el.parentElement.classList.contains('content')) {
+        if (el.previousSibling) {
+          el = el.previousSibling;
+          offset += this._getLength(el);
+        } else {
+          el = el.parentElement;
+        }
+      }
+      return offset;
+    },
+
+    /**
+     * Traverse Element from right to left, call callback for each node.
+     * Stops if callback returns true.
+     *
+     * @param {!Node} startNode
+     * @param {function(Node):boolean} callback
+     * @param {Object=} opt_flags If flags.left is true, traverse left.
+     */
+    _traverseContentSiblings: function(startNode, callback, opt_flags) {
+      var travelLeft = opt_flags && opt_flags.left;
+      var node = startNode;
+      while (node) {
+        if (node instanceof Element &&
+            node.tagName !== 'HL' &&
+            node.tagName !== 'SPAN') {
+          break;
+        }
+        var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+        if (callback(node)) {
+          break;
+        }
+        node = nextNode;
+      }
+    },
+
+    /**
+     * Get length of a node. If the node is a content node, then only give the
+     * length of its .contentText child.
+     *
+     * @param {!Node} node
+     * @return {number}
+     */
+    _getLength: function(node) {
+      if (node instanceof Element && node.classList.contains('content')) {
+        return this._getLength(node.querySelector('.contentText'));
+      } else {
+        return GrAnnotation.getLength(node);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
new file mode 100644
index 0000000..5f84e4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -0,0 +1,497 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-highlight</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-highlight.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-highlight>
+      <table id="diffTable">
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="138">138</td>
+            <td class="content both darkHighlight"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="119">119</td>
+            <td class="content both darkHighlight"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="140">140</td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove lightHighlight"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl>udiam, <hl>quid</hl> sit, <span class="tab withIndicator" style="tab-size:8;"></span>quod <hl>Epicurum</hl></div><gr-diff-comment-thread>
+                [Yet another random diff thread content here]
+              </gr-diff-comment-thread></td>
+            <td class="right lineNum" data-value="120">120</td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add lightHighlight"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl> otiosum,  <span class="tab withIndicator" style="tab-size:8;"></span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="141"></td>
+            <td class="content both darkHighlight"><div class="contentText">nam et<hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab withIndicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+            <td class="right lineNum" data-value="130"></td>
+            <td class="content both darkHighlight"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section contextControl">
+          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
+            <td class="left contextLineNum" data-value="@@"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+            <td class="right contextLineNum" data-value="@@"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+            <td class="left"></td>
+            <td class="blank darkHighlight"></td>
+            <td class="right lineNum" data-value="146"></td>
+            <td class="content add darkHighlight"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="165"></td>
+            <td class="content both darkHighlight"><div class="contentText">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+            <td class="right lineNum" data-value="147"></td>
+            <td class="content both darkHighlight"><div class="contentText">in physicis, <hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+          </tr>
+        </tbody>
+
+      </table>
+    </gr-diff-highlight>
+  </template>
+</test-fixture>
+
+<test-fixture id="highlighted">
+  <template>
+    <div>
+      <hl class="rangeHighlight">foo</hl>
+      bar
+      <hl class="rangeHighlight">baz</hl>
+    </div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-highlight', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    suite('selectionchange event handling', function() {
+      var emulateSelection = function() {
+        document.dispatchEvent(new CustomEvent('selectionchange'));
+        element.flushDebouncer('selectionChange');
+        element.flushDebouncer('removeActionBox');
+      };
+
+      setup(function() {
+        sandbox.stub(element, '_handleSelection');
+        sandbox.stub(element, '_removeActionBox');
+      });
+
+      test('enabled if logged in', function() {
+        element.loggedIn = true;
+        emulateSelection();
+        assert.isTrue(element._handleSelection.called);
+        assert.isTrue(element._removeActionBox.called);
+      });
+
+      test('ignored if logged out', function() {
+        element.loggedIn = false;
+        emulateSelection();
+        assert.isFalse(element._handleSelection.called);
+        assert.isFalse(element._removeActionBox.called);
+      });
+    });
+
+    suite('comment events', function() {
+      var builder;
+
+      setup(function() {
+        builder = {
+          getContentsByLineRange: sandbox.stub().returns([]),
+          getLineElByChild: sandbox.stub().returns({}),
+          getSideByLineEl: sandbox.stub().returns('other-side'),
+          renderLineRange: sandbox.stub(),
+        };
+        element._cachedDiffBuilder = builder;
+      });
+
+      test('ignores thread discard for line comment', function(done) {
+        element.fire('thread-discard', {lastComment: {}});
+        flush(function() {
+          assert.isFalse(builder.renderLineRange.called);
+          done();
+        });
+      });
+
+      test('ignores comment discard for line comment', function(done) {
+        element.fire('comment-discard', {comment: {}});
+        flush(function() {
+          assert.isFalse(builder.renderLineRange.called);
+          done();
+        });
+      });
+
+      test('comment-mouse-over from line comments is ignored', function() {
+        sandbox.stub(element, 'set');
+        element.fire('comment-mouse-over', {comment: {}});
+        assert.isFalse(element.set.called);
+      });
+
+      test('comment-mouse-over from ranged comment causes set', function() {
+        sandbox.stub(element, 'set');
+        sandbox.stub(element, '_indexOfComment').returns(0);
+        element.fire('comment-mouse-over', {comment: {range: {}}});
+        assert.isTrue(element.set.called);
+      });
+
+      test('comment-mouse-out from line comments is ignored', function() {
+        element.fire('comment-mouse-over', {comment: {}});
+        assert.isFalse(builder.getContentsByLineRange.called);
+      });
+
+      test('on create-comment action box is removed', function() {
+        sandbox.stub(element, '_removeActionBox');
+        element.fire('create-comment', {
+          comment: {
+            range: {},
+          },
+        });
+        assert.isTrue(element._removeActionBox.called);
+      });
+    });
+
+    suite('selection', function() {
+      var diff;
+      var builder;
+      var contentStubs;
+
+      var stubContent = function(line, side, opt_child) {
+        var contentTd = diff.querySelector(
+            '.' + side + '.lineNum[data-value="' + line + '"] ~ .content');
+        var contentText = contentTd.querySelector('.contentText');
+        var lineEl = diff.querySelector(
+            '.' + side + '.lineNum[data-value="' + line + '"]');
+        contentStubs.push({
+          lineEl: lineEl,
+          contentTd: contentTd,
+          contentText: contentText,
+        });
+        builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
+        builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+        builder.getContentByLine.withArgs(line, side).returns(contentText);
+        builder.getSideByLineEl.withArgs(lineEl).returns(side);
+        return contentText;
+      };
+
+      var emulateSelection = function(
+          startNode, startOffset, endNode, endOffset) {
+        var selection = window.getSelection();
+        var range = document.createRange();
+        range.setStart(startNode, startOffset);
+        range.setEnd(endNode, endOffset);
+        selection.addRange(range);
+        element._handleSelection();
+      };
+
+      var getActionRange = function() {
+        return Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').range;
+      };
+
+      var getActionSide = function() {
+        return Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').side;
+      };
+
+      var getLineElByChild = function(node) {
+        var stubs = contentStubs.find(function(stub) {
+          return stub.contentTd.contains(node);
+        });
+        return stubs && stubs.lineEl;
+      };
+
+      setup(function() {
+        contentStubs = [];
+        stub('gr-selection-action-box', {
+          placeAbove: sandbox.stub(),
+        });
+        diff = element.querySelector('#diffTable');
+        builder = {
+          getContentByLine: sandbox.stub(),
+          getContentByLineEl: sandbox.stub(),
+          getLineElByChild: getLineElByChild,
+          getLineNumberByChild: sandbox.stub(),
+          getSideByLineEl: sandbox.stub(),
+        };
+        element._cachedDiffBuilder = builder;
+      });
+
+      teardown(function() {
+        contentStubs = null;
+        window.getSelection().removeAllRanges();
+      });
+
+      test('single line', function() {
+        var content = stubContent(138, 'left');
+        emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 138,
+          startChar: 5,
+          endLine: 138,
+          endChar: 12,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('multiline', function() {
+        var startContent = stubContent(119, 'right');
+        var endContent = stubContent(120, 'right');
+        emulateSelection(
+            startContent.firstChild, 10, endContent.lastChild, 7);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 10,
+          endLine: 120,
+          endChar: 34,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('multiline grow end highlight over tabs', function() {
+        var startContent = stubContent(119, 'right');
+        var endContent = stubContent(120, 'right');
+        emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 10,
+          endLine: 120,
+          endChar: 2,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('collapsed', function() {
+        var content = stubContent(138, 'left');
+        emulateSelection(content.firstChild, 5, content.firstChild, 5);
+        assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts inside hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelector('.foo');
+        emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 8,
+          endLine: 140,
+          endChar: 23,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('ends inside hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelector('.bar');
+        emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 18,
+          endLine: 140,
+          endChar: 27,
+        });
+      });
+
+      test('multiple hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelectorAll('hl')[4];
+        emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 2,
+          endLine: 140,
+          endChar: 60,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts outside of diff', function() {
+        var contentText = stubContent(140, 'left');
+        var contentTd = contentText.parentElement;
+
+        emulateSelection(contentTd.previousElementSibling.firstChild, 2,
+            contentText.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('ends outside of diff', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(content.nextElementSibling.firstChild, 2,
+            content.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts and ends on different sides', function() {
+        var startContent = stubContent(140, 'left');
+        var endContent = stubContent(130, 'right');
+        emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts in comment thread element', function() {
+        var startContent = stubContent(140, 'left');
+        var comment = startContent.parentElement.querySelector(
+            'gr-diff-comment-thread');
+        var endContent = stubContent(141, 'left');
+        emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 81,
+          endLine: 141,
+          endChar: 4,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('ends in comment thread element', function() {
+        var content = stubContent(140, 'left');
+        var comment = content.parentElement.querySelector(
+            'gr-diff-comment-thread');
+        emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 4,
+          endLine: 140,
+          endChar: 81,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts in context element', function() {
+        var contextControl = diff.querySelector('.contextControl');
+        var content = stubContent(146, 'right');
+        emulateSelection(contextControl, 0, content.firstChild, 7);
+        // TODO (viktard): Select nearest line.
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('ends in context element', function() {
+        var contextControl = diff.querySelector('.contextControl');
+        var content = stubContent(141, 'left');
+        emulateSelection(content.firstChild, 2, contextControl, 0);
+        // TODO (viktard): Select nearest line.
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('selection containing context element', function() {
+        var startContent = stubContent(130, 'right');
+        var endContent = stubContent(146, 'right');
+        emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 130,
+          startChar: 3,
+          endLine: 146,
+          endChar: 14,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('ends at a tab', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(
+            content.firstChild, 1, content.querySelector('span'), 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 1,
+          endLine: 140,
+          endChar: 51,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts at a tab', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(
+            content.querySelectorAll('hl')[3], 0,
+            content.querySelectorAll('span')[1], 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 51,
+          endLine: 140,
+          endChar: 68,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      // TODO (viktard): Selection starts in line number.
+      // TODO (viktard): Empty lines in selection start.
+      // TODO (viktard): Empty lines in selection end.
+      // TODO (viktard): Only empty lines selected.
+      // TODO (viktard): Unified mode.
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
new file mode 100644
index 0000000..cbf63d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -0,0 +1,120 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+
+<dom-module id="gr-diff-preferences">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host[disabled] {
+        opacity: .5;
+        pointer-events: none;
+      }
+      input,
+      select {
+        font: inherit;
+      }
+      input[type="number"] {
+        width: 4em;
+      }
+      .header,
+      .actions {
+        padding: 1em 1.5em;
+      }
+      .header {
+        border-bottom: 1px solid #ddd;
+        font-weight: bold;
+      }
+      .mainContainer {
+        padding: 1em 0;
+      }
+      .pref {
+        align-items: center;
+        display: flex;
+        padding: .35em 1.5em;
+        width: 20em;
+      }
+      .pref:hover {
+        background-color: #ebf5fb;
+      }
+      .pref label {
+        cursor: pointer;
+        flex: 1;
+      }
+      .actions {
+        border-top: 1px solid #ddd;
+        display: flex;
+        justify-content: space-between;
+      }
+      .beta {
+        font-weight: bold;
+        color: #888;
+      }
+    </style>
+    <div class="header">
+      Diff View Preferences
+    </div>
+    <div class="mainContainer">
+      <div class="pref">
+        <label for="contextSelect">Context</label>
+        <select id="contextSelect" on-change="_handleContextSelectChange">
+          <option value="3">3 lines</option>
+          <option value="10">10 lines</option>
+          <option value="25">25 lines</option>
+          <option value="50">50 lines</option>
+          <option value="75">75 lines</option>
+          <option value="100">100 lines</option>
+          <option value="-1">Whole file</option>
+        </select>
+      </div>
+      <div class="pref">
+        <label for="columnsInput">Columns</label>
+        <input is="iron-input" type="number" id="columnsInput"
+            prevent-invalid-input
+            allowed-pattern="[0-9]"
+            bind-value="{{_newPrefs.line_length}}">
+      </div>
+      <div class="pref">
+        <label for="tabSizeInput">Tab width</label>
+        <input is="iron-input" type="number" id="tabSizeInput"
+            prevent-invalid-input
+            allowed-pattern="[0-9]"
+            bind-value="{{_newPrefs.tab_size}}">
+      </div>
+      <div class="pref">
+        <label for="showTabsInput">Show tabs</label>
+        <input is="iron-input" type="checkbox" id="showTabsInput"
+            on-tap="_handleShowTabsTap">
+      </div>
+      <div class="pref">
+        <label for="syntaxHighlightInput">Syntax highlighting</label>
+        <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
+            on-tap="_handleSyntaxHighlightTap">
+      </div>
+    </div>
+    <div class="actions">
+      <gr-button primary on-tap="_handleSave">Save</gr-button>
+      <gr-button on-tap="_handleCancel">Cancel</gr-button>
+    </div>
+  </template>
+  <script src="gr-diff-preferences.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
new file mode 100644
index 0000000..4103b2e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -0,0 +1,97 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-preferences',
+
+    /**
+     * Fired when the user presses the save button.
+     *
+     * @event save
+     */
+
+    /**
+     * Fired when the user presses the cancel button.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      prefs: {
+        type: Object,
+        notify: true,
+      },
+      localPrefs: {
+        type: Object,
+        notify: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+
+      _newPrefs: Object,
+      _newLocalPrefs: Object,
+    },
+
+    observers: [
+      '_prefsChanged(prefs.*)',
+      '_localPrefsChanged(localPrefs.*)',
+    ],
+
+    _prefsChanged: function(changeRecord) {
+      var prefs = changeRecord.base;
+      // TODO(andybons): This is not supported in IE. Implement a polyfill.
+      // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
+      // an object as a value, it must be marked enumerable.
+      this._newPrefs = Object.assign({}, prefs);
+      this.$.contextSelect.value = prefs.context;
+      this.$.showTabsInput.checked = prefs.show_tabs;
+      this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
+    },
+
+    _localPrefsChanged: function(changeRecord) {
+      var localPrefs = changeRecord.base || {};
+      // TODO(viktard): This is not supported in IE. Implement a polyfill.
+      this._newLocalPrefs = Object.assign({}, localPrefs);
+    },
+
+    _handleContextSelectChange: function(e) {
+      var selectEl = Polymer.dom(e).rootTarget;
+      this.set('_newPrefs.context', parseInt(selectEl.value, 10));
+    },
+
+    _handleShowTabsTap: function(e) {
+      this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
+    },
+
+    _handleSyntaxHighlightTap: function(e) {
+      this.set('_newPrefs.syntax_highlighting',
+          Polymer.dom(e).rootTarget.checked);
+    },
+
+    _handleSave: function() {
+      this.prefs = this._newPrefs;
+      this.localPrefs = this._newLocalPrefs;
+      this.fire('save', null, {bubbles: false});
+    },
+
+    _handleCancel: function() {
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
new file mode 100644
index 0000000..0c40d9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-preferences</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-preferences.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-preferences></gr-diff-preferences>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-preferences tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('model changes', function() {
+      element.prefs = {
+        context: 10,
+        line_length: 100,
+        show_tabs: true,
+        tab_size: 8,
+        syntax_highlighting: true,
+      };
+      assert.deepEqual(element.prefs, element._newPrefs);
+
+      element.$.contextSelect.value = '50';
+      element.fire('change', {}, {node: element.$.contextSelect});
+      element.$.columnsInput.bindValue = 80;
+      element.$.tabSizeInput.bindValue = 4;
+      MockInteractions.tap(element.$.showTabsInput);
+      MockInteractions.tap(element.$.syntaxHighlightInput);
+
+      assert.equal(element._newPrefs.context, 50);
+      assert.equal(element._newPrefs.line_length, 80);
+      assert.equal(element._newPrefs.tab_size, 4);
+      assert.isFalse(element._newPrefs.show_tabs);
+      assert.isFalse(element._newPrefs.syntax_highlighting);
+    });
+
+    test('events', function(done) {
+      var savePromise = new Promise(function(resolve) {
+        element.addEventListener('save', function() { resolve(); });
+      });
+      var cancelPromise = new Promise(function(resolve) {
+        element.addEventListener('cancel', function() { resolve(); });
+      });
+      Promise.all([savePromise, cancelPromise]).then(function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('gr-button[primary]'));
+      MockInteractions.tap(element.$$('gr-button:not([primary])'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
new file mode 100644
index 0000000..cae8bad
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-diff-processor">
+  <script src="../gr-diff/gr-diff-line.js"></script>
+  <script src="../gr-diff/gr-diff-group.js"></script>
+  <script src="gr-diff-processor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
new file mode 100644
index 0000000..2a1e880
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -0,0 +1,495 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var WHOLE_FILE = -1;
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffGroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  var DiffHighlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  /**
+   * The maximum size for an addition or removal chunk before it is broken down
+   * into a series of chunks that are this size at most.
+   *
+   * Note: The value of 70 is chosen so that it is larger than the default
+   * _asyncThreshold of 64, but feel free to tune this constant to your
+   * performance needs.
+   */
+  var MAX_GROUP_SIZE = 70;
+
+  Polymer({
+    is: 'gr-diff-processor',
+
+    properties: {
+
+      /**
+       * The amount of context around collapsed groups.
+       */
+      context: Number,
+
+      /**
+       * The array of groups output by the processor.
+       */
+      groups: {
+        type: Array,
+        notify: true,
+      },
+
+      /**
+       * Locations that should not be collapsed, including the locations of
+       * comments.
+       */
+      keyLocations: {
+        type: Object,
+        value: function() { return {left: {}, right: {}}; },
+      },
+
+      /**
+       * The maximum number of lines to process synchronously.
+       */
+      _asyncThreshold: {
+        type: Number,
+        value: 64,
+      },
+
+      _nextStepHandle: Number,
+    },
+
+    /**
+     * Asynchronously process the diff object into groups. As it processes, it
+     * will splice groups into the `groups` property of the component.
+     * @return {Promise} A promise that resolves when the diff is completely
+     *     processed.
+     */
+    process: function(content) {
+      return new Promise(function(resolve) {
+        this.groups = [];
+        this.push('groups', this._makeFileComments());
+
+        var state = {
+          lineNums: {left: 0, right: 0},
+          sectionIndex: 0,
+        };
+
+        content = this._splitCommonGroupsWithComments(content);
+
+        var currentBatch = 0;
+        var nextStep = function() {
+          // If we are done, resolve the promise.
+          if (state.sectionIndex >= content.length) {
+            resolve(this.groups);
+            this._nextStepHandle = undefined;
+            return;
+          }
+
+          // Process the next section and incorporate the result.
+          var result = this._processNext(state, content);
+          result.groups.forEach(function(group) {
+            this.push('groups', group);
+            currentBatch += group.lines.length;
+          }, this);
+          state.lineNums.left += result.lineDelta.left;
+          state.lineNums.right += result.lineDelta.right;
+
+          // Increment the index and recurse.
+          state.sectionIndex++;
+          if (currentBatch >= this._asyncThreshold) {
+            currentBatch = 0;
+            this._nextStepHandle = this.async(nextStep, 1);
+          } else {
+            nextStep.call(this);
+          }
+        };
+
+        nextStep.call(this);
+      }.bind(this));
+    },
+
+    /**
+     * Cancel any jobs that are running.
+     */
+    cancel: function() {
+      if (this._nextStepHandle !== undefined) {
+        this.cancelAsync(this._nextStepHandle);
+        this._nextStepHandle = undefined;
+      }
+    },
+
+    /**
+     * Process the next section of the diff.
+     */
+    _processNext: function(state, content) {
+      var section = content[state.sectionIndex];
+
+      var rows = {
+        both: section[DiffGroupType.BOTH] || null,
+        added: section[DiffGroupType.ADDED] || null,
+        removed: section[DiffGroupType.REMOVED] || null,
+      };
+
+      var highlights = {
+        added: section[DiffHighlights.ADDED] || null,
+        removed: section[DiffHighlights.REMOVED] || null,
+      };
+
+      if (rows.both) { // If it's a shared section.
+        var sectionEnd = null;
+        if (state.sectionIndex === 0) {
+          sectionEnd = 'first';
+        } else if (state.sectionIndex === content.length - 1) {
+          sectionEnd = 'last';
+        }
+
+        var sharedGroups = this._sharedGroupsFromRows(
+            rows.both,
+            content.length > 1 ? this.context : WHOLE_FILE,
+            state.lineNums.left,
+            state.lineNums.right,
+            sectionEnd);
+
+        return {
+          lineDelta: {
+            left: rows.both.length,
+            right: rows.both.length,
+          },
+          groups: sharedGroups,
+        };
+      } else { // Otherwise it's a delta section.
+
+        var deltaGroup = this._deltaGroupFromRows(
+            rows.added,
+            rows.removed,
+            state.lineNums.left,
+            state.lineNums.right,
+            highlights);
+
+        return {
+          lineDelta: {
+            left: rows.removed ? rows.removed.length : 0,
+            right: rows.added ? rows.added.length : 0,
+          },
+          groups: [deltaGroup],
+        };
+      }
+    },
+
+    /**
+     * Take rows of a shared diff section and produce an array of corresponding
+     * (potentially collapsed) groups.
+     * @param  {Array<String>} rows
+     * @param  {Number} context
+     * @param  {Number} startLineNumLeft
+     * @param  {Number} startLineNumRight
+     * @param  {String} opt_sectionEnd String representing whether this is the
+     *     first section or the last section or neither. Use the values 'first',
+     *     'last' and null respectively.
+     * @return {Array<GrDiffGroup>}
+     */
+    _sharedGroupsFromRows: function(rows, context, startLineNumLeft,
+        startLineNumRight, opt_sectionEnd) {
+      var result = [];
+      var lines = [];
+      var line;
+
+      // Map each row to a GrDiffLine.
+      for (var i = 0; i < rows.length; i++) {
+        line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.text = rows[i];
+        line.beforeNumber = ++startLineNumLeft;
+        line.afterNumber = ++startLineNumRight;
+        lines.push(line);
+      }
+
+      // Find the hidden range based on the user's context preference. If this
+      // is the first or the last section of the diff, make sure the collapsed
+      // part of the section extends to the edge of the file.
+      var hiddenRange = [context, rows.length - context];
+      if (opt_sectionEnd === 'first') {
+        hiddenRange[0] = 0;
+      } else if (opt_sectionEnd === 'last') {
+        hiddenRange[1] = rows.length;
+      }
+
+      // If there is a range to hide.
+      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+        var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+        var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+        var linesAfterCtx = lines.slice(hiddenRange[1]);
+
+        if (linesBeforeCtx.length > 0) {
+          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
+        }
+
+        var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+        ctxLine.contextGroup =
+            new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+        result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+            [ctxLine]));
+
+        if (linesAfterCtx.length > 0) {
+          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+        }
+      } else {
+        result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
+      }
+
+      return result;
+    },
+
+    /**
+     * Take the rows of a delta diff section and produce the corresponding
+     * group.
+     * @param  {Array<String>} rowsAdded
+     * @param  {Array<String>} rowsRemoved
+     * @param  {Number} startLineNumLeft
+     * @param  {Number} startLineNumRight
+     * @return {GrDiffGroup}
+     */
+    _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
+        startLineNumRight, highlights) {
+      var lines = [];
+      if (rowsRemoved) {
+        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
+            rowsRemoved, startLineNumLeft, highlights.removed));
+      }
+      if (rowsAdded) {
+        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.ADD,
+            rowsAdded, startLineNumRight, highlights.added));
+      }
+      return new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+    },
+
+    /**
+     * @return {Array<GrDiffLine>}
+     */
+    _deltaLinesFromRows: function(lineType, rows, startLineNum,
+        opt_highlights) {
+      // Normalize highlights if they have been passed.
+      if (opt_highlights) {
+        opt_highlights = this._normalizeIntralineHighlights(rows,
+            opt_highlights);
+      }
+
+      var lines = [];
+      var line;
+      for (var i = 0; i < rows.length; i++) {
+        line = new GrDiffLine(lineType);
+        line.text = rows[i];
+        if (lineType === GrDiffLine.Type.ADD) {
+          line.afterNumber = ++startLineNum;
+        } else {
+          line.beforeNumber = ++startLineNum;
+        }
+        if (opt_highlights) {
+          line.highlights = opt_highlights.filter(
+              function(hl) { return hl.contentIndex === i; });
+        }
+        lines.push(line);
+      }
+      return lines;
+    },
+
+    _makeFileComments: function() {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = GrDiffLine.FILE;
+      line.afterNumber = GrDiffLine.FILE;
+      return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
+    },
+
+    /**
+     * In order to show comments out of the bounds of the selected context,
+     * treat them as separate chunks within the model so that the content (and
+     * context surrounding it) renders correctly.
+     * @param  {Object} content The diff content object.
+     * @return {Object} A new diff content object with regions split up.
+     */
+    _splitCommonGroupsWithComments: function(content) {
+      var result = [];
+      var leftLineNum = 0;
+      var rightLineNum = 0;
+
+      // For each section in the diff.
+      for (var i = 0; i < content.length; i++) {
+
+        // If it isn't a common group, append it as-is and update line numbers.
+        if (!content[i].ab) {
+          if (content[i].a) {
+            leftLineNum += content[i].a.length;
+          }
+          if (content[i].b) {
+            rightLineNum += content[i].b.length;
+          }
+
+          this._breakdownGroup(content[i]).forEach(function(group) {
+            result.push(group);
+          });
+
+          continue;
+        }
+
+        var chunk = content[i].ab;
+        var currentChunk = {ab: []};
+
+        // For each line in the common group.
+        for (var j = 0; j < chunk.length; j++) {
+          leftLineNum++;
+          rightLineNum++;
+
+          // If this line should not be collapsed.
+          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
+              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
+
+            // If any lines have been accumulated into the chunk leading up to
+            // this non-collapse line, then add them as a chunk and start a new
+            // one.
+            if (currentChunk.ab && currentChunk.ab.length > 0) {
+              result.push(currentChunk);
+              currentChunk = {ab: []};
+            }
+
+            // Add the non-collapse line as its own chunk.
+            result.push({ab: [chunk[j]]});
+          } else {
+            // Append the current line to the current chunk.
+            currentChunk.ab.push(chunk[j]);
+          }
+        }
+
+        if (currentChunk.ab && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+
+      return result;
+    },
+
+    /**
+     * The `highlights` array consists of a list of <skip length, mark length>
+     * pairs, where the skip length is the number of characters between the
+     * end of the previous edit and the start of this edit, and the mark
+     * length is the number of edited characters following the skip. The start
+     * of the edits is from the beginning of the related diff content lines.
+     *
+     * Note that the implied newline character at the end of each line is
+     * included in the length calculation, and thus it is possible for the
+     * edits to span newlines.
+     *
+     * A line highlight object consists of three fields:
+     * - contentIndex: The index of the diffChunk `content` field (the line
+     *   being referred to).
+     * - startIndex: Where the highlight should begin.
+     * - endIndex: (optional) Where the highlight should end. If omitted, the
+     *   highlight is meant to be a continuation onto the next line.
+     */
+    _normalizeIntralineHighlights: function(content, highlights) {
+      var contentIndex = 0;
+      var idx = 0;
+      var normalized = [];
+      for (var i = 0; i < highlights.length; i++) {
+        var line = content[contentIndex] + '\n';
+        var hl = highlights[i];
+        var j = 0;
+        while (j < hl[0]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        var lineHighlight = {
+          contentIndex: contentIndex,
+          startIndex: idx,
+        };
+
+        j = 0;
+        while (line && j < hl[1]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            normalized.push(lineHighlight);
+            lineHighlight = {
+              contentIndex: contentIndex,
+              startIndex: idx,
+            };
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        lineHighlight.endIndex = idx;
+        normalized.push(lineHighlight);
+      }
+      return normalized;
+    },
+
+    /**
+     * If a group is an addition or a removal, break it down into smaller groups
+     * of that type using the MAX_GROUP_SIZE. If the group is a shared section
+     * or a delta it is returned as the single element of the result array.
+     * @param {!Object} A raw chunk from a diff response.
+     * @return {!Array<!Array<!Object>>}
+     */
+    _breakdownGroup: function(group) {
+      var key = null;
+      if (group.a && !group.b) {
+        key = 'a';
+      } else if (group.b && !group.a) {
+        key = 'b';
+      }
+
+      if (!key) { return [group]; }
+
+      return this._breakdown(group[key], MAX_GROUP_SIZE)
+        .map(function(subgroupLines) {
+          var subGroup = {};
+          subGroup[key] = subgroupLines;
+          return subGroup;
+        });
+    },
+
+    /**
+     * Given an array and a size, return an array of arrays where no inner array
+     * is larger than that size, preserving the original order.
+     * @param  {!Array<T>}
+     * @param  {number}
+     * @return {!Array<!Array<T>>}
+     * @template T
+     */
+    _breakdown: function(array, size) {
+      if (!array.length) { return []; }
+      if (array.length < size) { return [array]; }
+
+      var head = array.slice(0, array.length - size);
+      var tail = array.slice(array.length - size);
+
+      return this._breakdown(head, size).concat([tail])
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
new file mode 100644
index 0000000..9d687ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -0,0 +1,578 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-processor test</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-processor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-processor></gr-diff-processor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-processor tests', function() {
+    var WHOLE_FILE = -1;
+    var loremIpsum = 'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+        'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+        'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+        'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+        'fugit assum per.';
+
+    var element;
+
+    suite('not logged in', function() {
+
+      setup(function() {
+        element = fixture('basic');
+
+        element.context = 4;
+      });
+
+      test('process loaded content', function(done) {
+        var content = [
+          {
+            ab: [
+              '<!DOCTYPE html>',
+              '<meta charset="utf-8">',
+            ]
+          },
+          {
+            a: [
+              '  Welcome ',
+              '  to the wooorld of tomorrow!',
+            ],
+            b: [
+              '  Hello, world!',
+            ],
+          },
+          {
+            ab: [
+              'Leela: This is the only place the ship can’t hear us, so ',
+              'everyone pretend to shower.',
+              'Fry: Same as every day. Got it.',
+            ]
+          },
+        ];
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups.length, 4);
+
+          var group = groups[0];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 1);
+          assert.equal(group.lines[0].text, '');
+          assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+          group = groups[1];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 2);
+          assert.equal(group.lines.length, 2);
+
+          function beforeNumberFn(l) { return l.beforeNumber; }
+          function afterNumberFn(l) { return l.afterNumber; }
+          function textFn(l) { return l.text; }
+
+          assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+          assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+          assert.deepEqual(group.lines.map(textFn), [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]);
+
+          group = groups[2];
+          assert.equal(group.type, GrDiffGroup.Type.DELTA);
+          assert.equal(group.lines.length, 3);
+          assert.equal(group.adds.length, 1);
+          assert.equal(group.removes.length, 2);
+          assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+          assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+          assert.deepEqual(group.removes.map(textFn), [
+            '  Welcome ',
+            '  to the wooorld of tomorrow!',
+          ]);
+          assert.deepEqual(group.adds.map(textFn), [
+            '  Hello, world!',
+          ]);
+
+          group = groups[3];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 3);
+          assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+          assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+          assert.deepEqual(group.lines.map(textFn), [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ]);
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        var content = [
+          {ab: []},
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: []},
+          {b: ['elgoog elgoog elgoog']},
+          {ab: []},
+        ];
+        for (var i = 0; i < 100; i++) {
+          content[0].ab.push('all work and no play make jack a dull boy');
+          content[4].ab.push('all work and no play make jill a dull girl');
+        }
+        for (var i = 0; i < 5; i++) {
+          content[2].ab.push('no tv and no beer make homer go crazy');
+        }
+
+        var context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[0].lines.length, 1);
+          assert.equal(groups[0].lines[0].text, '');
+          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
+          groups[1].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[0].ab[0]);
+          });
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, context);
+          groups[2].lines.forEach(function(l) {
+            assert.equal(l.text, content[0].ab[0]);
+          });
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[3].lines.length, 1);
+          assert.equal(groups[3].removes.length, 1);
+          assert.equal(groups[3].removes[0].text,
+              'all work and no play make andybons a dull boy');
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 5);
+          groups[4].lines.forEach(function(l) {
+            assert.equal(l.text, content[2].ab[0]);
+          });
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 1);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+
+          assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[6].lines.length, context);
+          groups[6].lines.forEach(function(l) {
+            assert.equal(l.text, content[4].ab[0]);
+          });
+
+          assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
+          groups[7].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[4].ab[0]);
+          });
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        var content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: []},
+          {b: ['elgoog elgoog elgoog']},
+        ];
+        for (var i = 0; i < 50; i++) {
+          content[1].ab.push('no tv and no beer make homer go crazy');
+        }
+
+        var context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[0].lines.length, 1);
+          assert.equal(groups[0].lines[0].text, '');
+          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[1].lines.length, 1);
+          assert.equal(groups[1].removes.length, 1);
+          assert.equal(groups[1].removes[0].text,
+              'all work and no play make andybons a dull boy');
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, context);
+          groups[2].lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
+          groups[3].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, context);
+          groups[4].lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 1);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+
+          done();
+        });
+      });
+
+      test('break up common diff chunks', function() {
+        element.keyLocations = {
+          left: {1: true},
+          right: {10: true},
+        };
+        var lineNums = {left: 0, right: 0};
+
+        var content = [
+          {
+            ab: [
+              'Copyright (C) 2015 The Android Open Source Project',
+              '',
+              'Licensed under the Apache License, Version 2.0 (the "License");',
+              'you may not use this file except in compliance with the ' +
+                  'License.',
+              'You may obtain a copy of the License at',
+              '',
+              'http://www.apache.org/licenses/LICENSE-2.0',
+              '',
+              'Unless required by applicable law or agreed to in writing, ',
+              'software distributed under the License is distributed on an ',
+              '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+              'either express or implied. See the License for the specific ',
+              'language governing permissions and limitations under the ' +
+                  'License.',
+            ]
+          }
+        ];
+        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        assert.deepEqual(result, [
+          {
+            ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          },
+          {
+            ab: [
+              '',
+              'Licensed under the Apache License, Version 2.0 (the "License");',
+              'you may not use this file except in compliance with the ' +
+                  'License.',
+              'You may obtain a copy of the License at',
+              '',
+              'http://www.apache.org/licenses/LICENSE-2.0',
+              '',
+              'Unless required by applicable law or agreed to in writing, ',
+            ]
+          },
+          {
+            ab: [
+                'software distributed under the License is distributed on an '],
+          },
+          {
+            ab: [
+              '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+              'either express or implied. See the License for the specific ',
+              'language governing permissions and limitations under the ' +
+                  'License.',
+            ]
+          }
+        ]);
+      });
+
+      test('intraline normalization', function() {
+        // The content and highlights are in the format returned by the Gerrit
+        // REST API.
+        var content = [
+          '      <section class="summary">',
+          '        <gr-linked-text content="' +
+              '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+          '      </section>',
+        ];
+        var highlights = [
+          [31, 34], [42, 26]
+        ];
+
+        var results = element._normalizeIntralineHighlights(content,
+            highlights);
+        assert.deepEqual(results, [
+          {
+            contentIndex: 0,
+            startIndex: 31,
+          },
+          {
+            contentIndex: 1,
+            startIndex: 0,
+            endIndex: 33,
+          },
+          {
+            contentIndex: 1,
+            startIndex: 75,
+          },
+          {
+            contentIndex: 2,
+            startIndex: 0,
+            endIndex: 6,
+          }
+        ]);
+
+        content = [
+          '        this._path = value.path;',
+          '',
+          '        // When navigating away from the page, there is a ' +
+            'possibility that the',
+          '        // patch number is no longer a part of the URL ' +
+            '(say when navigating to',
+          '        // the top-level change info view) and therefore ' +
+            'undefined in `params`.',
+          '        if (!this._patchRange.patchNum) {',
+        ];
+        highlights = [
+          [14, 17],
+          [11, 70],
+          [12, 67],
+          [12, 67],
+          [14, 29],
+        ];
+        results = element._normalizeIntralineHighlights(content, highlights);
+        assert.deepEqual(results, [
+          {
+            contentIndex: 0,
+            startIndex: 14,
+            endIndex: 31,
+          },
+          {
+            contentIndex: 2,
+            startIndex: 8,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 3,
+            startIndex: 11,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 4,
+            startIndex: 11,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 5,
+            startIndex: 12,
+            endIndex: 41,
+          }
+        ]);
+      });
+
+      suite('gr-diff-processor helpers', function() {
+        var rows;
+
+        setup(function() {
+          rows = loremIpsum.split(' ');
+        });
+
+        test('_sharedGroupsFromRows WHOLE_FILE', function() {
+          var context = WHOLE_FILE;
+          var lineNumbers = {left: 10, right: 100};
+          var result = element._sharedGroupsFromRows(
+              rows, context, lineNumbers.left, lineNumbers.right, null);
+
+          // Results in one, uncollapsed group with all rows.
+          assert.equal(result.length, 1);
+          assert.equal(result[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(result[0].lines.length, rows.length);
+
+          // Line numbers are set correctly.
+          assert.equal(result[0].lines[0].beforeNumber, lineNumbers.left + 1);
+          assert.equal(result[0].lines[0].afterNumber, lineNumbers.right + 1);
+
+          assert.equal(result[0].lines[rows.length - 1].beforeNumber,
+              lineNumbers.left + rows.length);
+          assert.equal(result[0].lines[rows.length - 1].afterNumber,
+              lineNumbers.right + rows.length);
+        });
+
+        test('_sharedGroupsFromRows context', function() {
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, null);
+          var expectedCollapseSize = rows.length - 2 * context;
+
+          assert.equal(result.length, 3, 'Results in three groups');
+
+          // The first and last are uncollapsed context, whereas the middle has
+          // a single context-control line.
+          assert.equal(result[0].lines.length, context);
+          assert.equal(result[1].lines.length, 1);
+          assert.equal(result[2].lines.length, context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result[1].lines[0].contextGroup.lines.length,
+              expectedCollapseSize);
+        });
+
+        test('_sharedGroupsFromRows first', function() {
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, 'first');
+          var expectedCollapseSize = rows.length - context;
+
+          assert.equal(result.length, 2, 'Results in two groups');
+
+          // Only the first group is collapsed.
+          assert.equal(result[0].lines.length, 1);
+          assert.equal(result[1].lines.length, context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result[0].lines[0].contextGroup.lines.length,
+              expectedCollapseSize);
+        });
+
+        test('_sharedGroupsFromRows few-rows', function() {
+          // Only ten rows.
+          rows = rows.slice(0, 10);
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, 'first');
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.length, 1, 'Results in one group');
+          assert.equal(result[0].lines.length, rows.length);
+        });
+
+        test('_deltaLinesFromRows', function() {
+          var startLineNum = 10;
+          var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
+              startLineNum);
+
+          assert.equal(result.length, rows.length);
+          assert.equal(result[0].type, GrDiffLine.Type.ADD);
+          assert.equal(result[0].afterNumber, startLineNum + 1);
+          assert.notOk(result[0].beforeNumber);
+          assert.equal(result[result.length - 1].afterNumber,
+              startLineNum + rows.length);
+          assert.notOk(result[result.length - 1].beforeNumber);
+
+          result = element._deltaLinesFromRows(GrDiffLine.Type.REMOVE, rows,
+              startLineNum);
+
+          assert.equal(result.length, rows.length);
+          assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+          assert.equal(result[0].beforeNumber, startLineNum + 1);
+          assert.notOk(result[0].afterNumber);
+          assert.equal(result[result.length - 1].beforeNumber,
+              startLineNum + rows.length);
+          assert.notOk(result[result.length - 1].afterNumber);
+        });
+      });
+
+      suite('_breakdown*', function() {
+        var sandbox;
+        setup(function() {
+          sandbox = sinon.sandbox.create();
+        });
+
+        teardown(function() {
+          sandbox.restore();
+        });
+
+        test('_breakdownGroup ignores shared groups', function() {
+          sandbox.stub(element, '_breakdown');
+          var chunk = {ab: ['blah', 'blah', 'blah']};
+          var result = element._breakdownGroup(chunk);
+          assert.deepEqual(result, [chunk]);
+          assert.isFalse(element._breakdown.called);
+        });
+
+        test('_breakdownGroup breaks down additions', function() {
+          sandbox.spy(element, '_breakdown');
+          var chunk = {b: ['blah', 'blah', 'blah']};
+          var result = element._breakdownGroup(chunk);
+          assert.deepEqual(result, [chunk]);
+          assert.isTrue(element._breakdown.called);
+        });
+
+        test('_breakdown common case', function() {
+          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+              .split(' ');
+          var size = 3;
+
+          var result = element._breakdown(array, size);
+
+          result.forEach(function(subResult) {
+            assert.isAtMost(subResult.length, size);
+          });
+          var flattened = result
+              .reduce(function(a, b) { return a.concat(b); }, []);
+          assert.deepEqual(flattened, array);
+        });
+
+        test('_breakdown smaller than size', function() {
+          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+              .split(' ');
+          var size = 10;
+          var expected = [array];
+
+          var result = element._breakdown(array, size);
+
+          assert.deepEqual(result, expected);
+        });
+
+        test('_breakdown empty', function() {
+          var array = [];
+          var size = 10;
+          var expected = [];
+
+          var result = element._breakdown(array, size);
+
+          assert.deepEqual(result, expected);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
new file mode 100644
index 0000000..09cab0b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -0,0 +1,43 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-diff-selection">
+  <template>
+    <style>
+      .contentWrapper ::content .content {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+      }
+
+      :host.selected-right .contentWrapper ::content .right + .content,
+      :host.selected-left .contentWrapper ::content .left + .content,
+      :host.selected-right .contentWrapper ::content .unified .right ~ .content,
+      :host.selected-left .contentWrapper ::content .unified .left ~ .content {
+        -webkit-user-select: text;
+        -moz-user-select: text;
+        -ms-user-select: text;
+        user-select: text;
+      }
+    </style>
+    <div class="contentWrapper">
+      <content></content>
+    </div>
+  </template>
+  <script src="gr-diff-selection.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
new file mode 100644
index 0000000..7d0b7ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-selection',
+
+    properties: {
+      _cachedDiffBuilder: Object,
+    },
+
+    listeners: {
+      'copy': '_handleCopy',
+      'down': '_handleDown',
+    },
+
+    attached: function() {
+      this.classList.add('selected-right');
+    },
+
+    get diffBuilder() {
+      if (!this._cachedDiffBuilder) {
+        this._cachedDiffBuilder =
+            Polymer.dom(this).querySelector('gr-diff-builder');
+      }
+      return this._cachedDiffBuilder;
+    },
+
+    _handleDown: function(e) {
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var targetClass = 'selected-' + side;
+      var alternateClass = 'selected-' + (side === 'left' ? 'right' : 'left');
+
+      if (this.classList.contains(alternateClass)) {
+        this.classList.remove(alternateClass);
+      }
+      if (!this.classList.contains(targetClass)) {
+        this.classList.add(targetClass);
+      }
+    },
+
+    _handleCopy: function(e) {
+      if (!e.target.classList.contains('content')) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var text = this._getSelectedText(side);
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    },
+
+    _getSelectedText: function(opt_side) {
+      var sel = window.getSelection();
+      if (sel.rangeCount != 1) {
+        return; // No multi-select support yet.
+      }
+      var range = sel.getRangeAt(0);
+      var fragment = range.cloneContents();
+      var selector = '.content,td.content:nth-of-type(1)';
+      if (opt_side) {
+        selector = '.' + opt_side + ' + ' + selector;
+      }
+      var contentEls = Polymer.dom(fragment).querySelectorAll(selector);
+      if (contentEls.length === 0) {
+        return fragment.textContent;
+      }
+
+      var text = '';
+      for (var i = 0; i < contentEls.length; i++) {
+        text += contentEls[i].textContent + '\n';
+      }
+      return text;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
new file mode 100644
index 0000000..f99e373
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-selection</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-selection.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-selection>
+      <table>
+        <tr>
+          <td class="lineNum left">1</td>
+          <td class="content">ba ba</td>
+          <td class="lineNum right">1</td>
+          <td class="content">some other text</td>
+        </tr>
+        <tr>
+          <td class="lineNum left">2</td>
+          <td class="content">zin</td>
+          <td class="lineNum right">2</td>
+          <td class="content">more more more</td>
+        </tr>
+        <tr>
+          <td class="lineNum left">2</td>
+          <td class="content">ga ga</td>
+          <td class="lineNum right">3</td>
+          <td class="other">some other text</td>
+        </tr>
+      </table>
+    </gr-diff-selection>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-selection', function() {
+    var element;
+
+    var emulateCopyOn = function(target) {
+      var fakeEvent = {
+        target: target,
+        preventDefault: sinon.stub(),
+        clipboardData: {
+          setData: sinon.stub(),
+        },
+      };
+      element._handleCopy(fakeEvent);
+      return fakeEvent;
+    };
+
+    setup(function() {
+      element = fixture('basic');
+      element._cachedDiffBuilder = {
+        getLineElByChild: sinon.stub().returns({}),
+        getSideByLineEl: sinon.stub(),
+      };
+    });
+
+    test('applies selected-left on left side click', function() {
+      element.classList.add('selected-right');
+      element._cachedDiffBuilder.getSideByLineEl.returns('left');
+      MockInteractions.down(element);
+      assert.isTrue(
+          element.classList.contains('selected-left'), 'adds selected-left');
+      assert.isFalse(
+          element.classList.contains('selected-right'),
+          'removes selected-right');
+    });
+
+    test('applies selected-right on right side click', function() {
+      element.classList.add('selected-left');
+      element._cachedDiffBuilder.getSideByLineEl.returns('right');
+      MockInteractions.down(element);
+      assert.isTrue(
+          element.classList.contains('selected-right'), 'adds selected-right');
+      assert.isFalse(
+          element.classList.contains('selected-left'), 'removes selected-left');
+    });
+
+    test('ignores copy for non-content Element', function() {
+      sinon.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('.other'));
+      assert.isFalse(element._getSelectedText.called);
+    });
+
+    test('asks for text for right side Elements', function() {
+      element._cachedDiffBuilder.getSideByLineEl.returns('left');
+      sinon.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('td.content'));
+      assert.deepEqual(['left'], element._getSelectedText.lastCall.args);
+    });
+
+    test('reacts to copy for content Elements', function() {
+      sinon.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('td.content'));
+      assert.isTrue(element._getSelectedText.called);
+    });
+
+    test('copy event is prevented for content Elements', function() {
+      sinon.stub(element, '_getSelectedText');
+      var event = emulateCopyOn(element.querySelector('td.content'));
+      assert.isTrue(event.preventDefault.called);
+    });
+
+    test('inserts text into clipboard on copy', function() {
+      sinon.stub(element, '_getSelectedText').returns('the text');
+      var event = emulateCopyOn(element.querySelector('td.content'));
+      assert.deepEqual(
+          ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
+    });
+
+    test('copies content correctly', function() {
+      element.classList.add('selected-left');
+      var selection = window.getSelection();
+      var range = document.createRange();
+      range.setStart(element.querySelector('td.content').firstChild, 3);
+      range.setEnd(
+          element.querySelectorAll('td.content')[4].firstChild, 2);
+      selection.addRange(range);
+      assert.equal('ba\nzin\nga\n', element._getSelectedText('left'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
new file mode 100644
index 0000000..2573ad1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -0,0 +1,223 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
+<link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
+
+<dom-module id="gr-diff-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      h3 {
+        padding: .75em var(--default-horizontal-margin);
+      }
+      .reviewed {
+        display: inline-block;
+        margin: 0 .25em;
+        vertical-align: .15em;
+      }
+      .jumpToFileContainer {
+        display: inline-block;
+      }
+      .mobileJumpToFileContainer {
+        display: none;
+      }
+      .downArrow {
+        display: inline-block;
+        font-size: .6em;
+        vertical-align: middle;
+      }
+      .dropdown-trigger {
+        color: #00e;
+        cursor: pointer;
+        padding: 0;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      .dropdown-content a {
+        cursor: pointer;
+        display: block;
+        font-weight: normal;
+        padding: .3em .5em;
+      }
+      .dropdown-content a:before {
+        color: #ccc;
+        content: attr(data-key-nav);
+        display: inline-block;
+        margin-right: .5em;
+        width: .3em;
+      }
+      .dropdown-content a:hover {
+        background-color: #00e;
+        color: #fff;
+      }
+      .dropdown-content a[selected] {
+        color: #000;
+        font-weight: bold;
+        pointer-events: none;
+        text-decoration: none;
+      }
+      .dropdown-content a[selected]:hover {
+        background-color: #fff;
+        color: #000;
+      }
+      gr-button {
+        font: inherit;
+        padding: .3em 0;
+        text-decoration: none;
+      }
+      .loading {
+        padding: 0 var(--default-horizontal-margin) 1em;
+        color: #666;
+      }
+      .header {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        margin: 0 var(--default-horizontal-margin) .75em;
+      }
+      .prefsButton {
+        text-align: right;
+      }
+      #modeSelect {
+        margin-left: .5em;
+      }
+      @media screen and (max-width: 50em) {
+        .dash {
+          display: none;
+        }
+        .reviewed {
+          vertical-align: -.1em;
+        }
+        .jumpToFileContainer {
+          display: none;
+        }
+        .mobileJumpToFileContainer {
+          display: block;
+          width: 100%;
+        }
+      }
+    </style>
+    <h3>
+      <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
+        [[_changeNum]]</a><span>:</span>
+      <span>[[_change.subject]]</span>
+      <span class="dash">—</span>
+      <input id="reviewed"
+          class="reviewed"
+          type="checkbox"
+          on-change="_handleReviewedChange"
+          hidden$="[[!_loggedIn]]" hidden>
+      <div class="jumpToFileContainer">
+        <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
+          <span>[[_computeFileDisplayName(_path)]]</span>
+          <span class="downArrow">&#9660;</span>
+        </gr-button>
+        <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
+          <div class="dropdown-content">
+            <template is="dom-repeat" items="[[_fileList]]" as="path">
+              <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
+                 selected$="[[_computeFileSelected(path, _path)]]"
+                 data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                 on-tap="_handleFileTap">
+                 [[_computeFileDisplayName(path)]]
+              </a>
+            </template>
+          </div>
+        </iron-dropdown>
+      </div>
+      <div class="mobileJumpToFileContainer">
+        <select on-change="_handleMobileSelectChange">
+          <template is="dom-repeat" items="[[_fileList]]" as="path">
+            <option
+                value$="[[path]]"
+                selected$="[[_computeFileSelected(path, _path)]]">
+              [[_computeFileDisplayName(path)]]
+            </option>
+          </template>
+        </select>
+      </div>
+    </h3>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <div class="header">
+        <gr-patch-range-select
+            path="[[_path]]"
+            change-num="[[_changeNum]]"
+            patch-range="[[_patchRange]]"
+            files-weblinks="[[_filesWeblinks]]"
+            available-patches="[[_computeAvailablePatches(_change.revisions)]]">
+        </gr-patch-range-select>
+        <div>
+          <select
+              id="modeSelect"
+              is="gr-select"
+              bind-value="{{changeViewState.diffMode}}"
+              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">
+            <option value="SIDE_BY_SIDE">Side By Side</option>
+            <option value="UNIFIED_DIFF">Unified</option>
+          </select>
+          <span hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]">
+            <span
+                hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
+            <gr-button link
+                class="prefsButton"
+                on-tap="_handlePrefsTap">Preferences</gr-button>
+          </span>
+        </div>
+      </div>
+      <gr-overlay id="prefsOverlay" with-backdrop>
+        <gr-diff-preferences
+            prefs="{{_prefs}}"
+            local-prefs="{{_localPrefs}}"
+            on-save="_handlePrefsSave"
+            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
+      </gr-overlay>
+      <gr-diff
+          id="diff"
+          project="[[_change.project]]"
+          commit="[[_change.current_revision]]"
+          is-image-diff="{{_isImageDiff}}"
+          files-weblinks="{{_filesWeblinks}}"
+          change-num="[[_changeNum]]"
+          patch-range="[[_patchRange]]"
+          path="[[_path]]"
+          prefs="[[_prefs]]"
+          project-config="[[_projectConfig]]"
+          view-mode="[[_diffMode]]"
+          on-line-selected="_onLineSelected">
+      </gr-diff>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
+    <gr-diff-cursor id="cursor"></gr-diff-cursor>
+  </template>
+  <script src="gr-diff-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
new file mode 100644
index 0000000..d6a3bc0
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -0,0 +1,488 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var DiffSides = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var HASH_PATTERN = /^b?\d+$/;
+
+  Polymer({
+    is: 'gr-diff-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      changeViewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+
+      _patchRange: Object,
+      _change: Object,
+      _changeNum: String,
+      _diff: Object,
+      _fileList: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _path: {
+        type: String,
+        observer: '_pathChanged',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _prefs: Object,
+      _localPrefs: Object,
+      _projectConfig: Object,
+      _userPrefs: Object,
+      _diffMode: {
+        type: String,
+        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+      },
+      _isImageDiff: Boolean,
+      _filesWeblinks: Object,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    observers: [
+      '_getChangeDetail(_changeNum)',
+      '_getProjectConfig(_change.project)',
+      '_getFiles(_changeNum, _patchRange.*)',
+    ],
+
+    attached: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this._setReviewed(true);
+        }
+      }.bind(this));
+
+      if (this._path) {
+        this.fire('title-change',
+            {title: this._computeFileDisplayName(this._path)});
+      }
+
+      this.$.cursor.push('diffs', this.$.diff);
+    },
+
+    detached: function() {
+      // Reset the diff mode to null so that it reverts to the user preference.
+      this.changeViewState.diffMode = null;
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getProjectConfig: function(project) {
+      return this.$.restAPI.getProjectConfig(project).then(
+          function(config) {
+            this._projectConfig = config;
+          }.bind(this));
+    },
+
+    _getChangeDetail: function(changeNum) {
+      return this.$.restAPI.getDiffChangeDetail(changeNum).then(
+          function(change) {
+            this._change = change;
+          }.bind(this));
+    },
+
+    _getFiles: function(changeNum, patchRangeRecord) {
+      var patchRange = patchRangeRecord.base;
+      return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
+          changeNum, patchRange).then(function(files) {
+            this._fileList = files;
+          }.bind(this));
+    },
+
+    _getDiffPreferences: function() {
+      return this.$.restAPI.getDiffPreferences();
+    },
+
+    _getPreferences: function() {
+      return this.$.restAPI.getPreferences();
+    },
+
+    _handleReviewedChange: function(e) {
+      this._setReviewed(Polymer.dom(e).rootTarget.checked);
+    },
+
+    _setReviewed: function(reviewed) {
+      this.$.reviewed.checked = reviewed;
+      this._saveReviewedState(reviewed).catch(function(err) {
+        alert('Couldn’t change file review status. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _saveReviewedState: function(reviewed) {
+      return this.$.restAPI.saveFileReviewed(this._changeNum,
+          this._patchRange.patchNum, this._path, reviewed);
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 37: // left
+          if (e.shiftKey) {
+            e.preventDefault();
+            this.$.cursor.moveLeft();
+          }
+          break;
+        case 39: // right
+          if (e.shiftKey) {
+            e.preventDefault();
+            this.$.cursor.moveRight();
+          }
+          break;
+        case 40: // down
+        case 74: // 'j'
+          e.preventDefault();
+          this.$.cursor.moveDown();
+          break;
+        case 38: // up
+        case 75: // 'k'
+          e.preventDefault();
+          this.$.cursor.moveUp();
+          break;
+        case 67: // 'c'
+          if (!this.$.diff.isRangeSelected()) {
+            e.preventDefault();
+            var line = this.$.cursor.getTargetLineElement();
+            if (line) {
+              this.$.diff.addDraftAtLine(line);
+            }
+          }
+          break;
+        case 219:  // '['
+          e.preventDefault();
+          this._navToFile(this._fileList, -1);
+          break;
+        case 221:  // ']'
+          e.preventDefault();
+          this._navToFile(this._fileList, 1);
+          break;
+        case 78:  // 'n'
+          e.preventDefault();
+          if (e.shiftKey) {
+            this.$.cursor.moveToNextCommentThread();
+          } else {
+            this.$.cursor.moveToNextChunk();
+          }
+          break;
+        case 80:  // 'p'
+          e.preventDefault();
+          if (e.shiftKey) {
+            this.$.cursor.moveToPreviousCommentThread();
+          } else {
+            this.$.cursor.moveToPreviousChunk();
+          }
+          break;
+        case 65:  // 'a'
+          if (e.shiftKey) { // Hide left diff.
+            e.preventDefault();
+            this.$.diff.toggleLeftDiff();
+            break;
+          }
+
+          if (!this._loggedIn) { break; }
+
+          this.set('changeViewState.showReplyDialog', true);
+          /* falls through */ // required by JSHint
+        case 85:  // 'u'
+          if (this._changeNum && this._patchRange.patchNum) {
+            e.preventDefault();
+            page.show(this._getChangePath(
+                this._changeNum,
+                this._patchRange,
+                this._change && this._change.revisions));
+          }
+          break;
+        case 188:  // ','
+          e.preventDefault();
+          this.$.prefsOverlay.open();
+          break;
+      }
+    },
+
+    _navToFile: function(fileList, direction) {
+      if (fileList.length == 0) { return; }
+
+      var idx = fileList.indexOf(this._path) + direction;
+      if (idx < 0 || idx > fileList.length - 1) {
+        page.show(this._getChangePath(
+            this._changeNum,
+            this._patchRange,
+            this._change && this._change.revisions));
+        return;
+      }
+      page.show(this._getDiffURL(this._changeNum,
+                                 this._patchRange,
+                                 fileList[idx]));
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view != this.tagName.toLowerCase()) { return; }
+
+      this._loadHash(location.hash);
+
+      this._changeNum = value.changeNum;
+      this._patchRange = {
+        patchNum: value.patchNum,
+        basePatchNum: value.basePatchNum || 'PARENT',
+      };
+      this._path = value.path;
+
+      this.fire('title-change',
+          {title: this._computeFileDisplayName(this._path)});
+
+      // When navigating away from the page, there is a possibility that the
+      // patch number is no longer a part of the URL (say when navigating to
+      // the top-level change info view) and therefore undefined in `params`.
+      if (!this._patchRange.patchNum) {
+        return;
+      }
+
+      var promises = [];
+
+      this._localPrefs = this.$.storage.getPreferences();
+      promises.push(this._getDiffPreferences().then(function(prefs) {
+        this._prefs = prefs;
+      }.bind(this)));
+
+      promises.push(this._getPreferences().then(function(prefs) {
+        this._userPrefs = prefs;
+      }.bind(this)));
+
+      promises.push(this._getChangeDetail(this._changeNum));
+
+      Promise.all(promises)
+          .then(function() { return this.$.diff.reload(); }.bind(this))
+          .then(function() { this._loading = false; }.bind(this));
+    },
+
+    /**
+     * If the URL hash is a diff address then configure the diff cursor.
+     */
+    _loadHash: function(hash) {
+      var hash = hash.replace(/^#/, '');
+      if (!HASH_PATTERN.test(hash)) { return; }
+      if (hash[0] === 'b') {
+        this.$.cursor.side = DiffSides.LEFT;
+        hash = hash.substring(1);
+      } else {
+        this.$.cursor.side = DiffSides.RIGHT;
+      }
+      this.$.cursor.initialLineNumber = parseInt(hash, 10);
+    },
+
+    _pathChanged: function(path) {
+      if (this._fileList.length == 0) { return; }
+
+      this.set('changeViewState.selectedFileIndex',
+          this._fileList.indexOf(path));
+
+      if (this._loggedIn) {
+        this._setReviewed(true);
+      }
+    },
+
+    _getDiffURL: function(changeNum, patchRange, path) {
+      return '/c/' + changeNum + '/' + this._patchRangeStr(patchRange) + '/' +
+          path;
+    },
+
+    _computeDiffURL: function(changeNum, patchRangeRecord, path) {
+      return this._getDiffURL(changeNum, patchRangeRecord.base, path);
+    },
+
+    _patchRangeStr: function(patchRange) {
+      var patchStr = patchRange.patchNum;
+      if (patchRange.basePatchNum != null &&
+          patchRange.basePatchNum != 'PARENT') {
+        patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
+      }
+      return patchStr;
+    },
+
+    _computeAvailablePatches: function(revisions) {
+      var patchNums = [];
+      for (var rev in revisions) {
+        patchNums.push(revisions[rev]._number);
+      }
+      return patchNums.sort(function(a, b) { return a - b; });
+    },
+
+    _getChangePath: function(changeNum, patchRange, revisions) {
+      var base = '/c/' + changeNum + '/';
+
+      // The change may not have loaded yet, making revisions unavailable.
+      if (!revisions) {
+        return base + this._patchRangeStr(patchRange);
+      }
+
+      var latestPatchNum = -1;
+      for (var rev in revisions) {
+        latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
+      }
+      if (patchRange.basePatchNum !== 'PARENT' ||
+          parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
+        return base + this._patchRangeStr(patchRange);
+      }
+
+      return base;
+    },
+
+    _computeChangePath: function(changeNum, patchRangeRecord, revisions) {
+      return this._getChangePath(changeNum, patchRangeRecord.base, revisions);
+    },
+
+    _computeFileDisplayName: function(path) {
+      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeFileSelected: function(path, currentPath) {
+      return path == currentPath;
+    },
+
+    _computePrefsButtonHidden: function(prefs, loggedIn) {
+      return !loggedIn || !prefs;
+    },
+
+    _computeKeyNav: function(path, selectedPath, fileList) {
+      var selectedIndex = fileList.indexOf(selectedPath);
+      if (fileList.indexOf(path) == selectedIndex - 1) {
+        return '[';
+      }
+      if (fileList.indexOf(path) == selectedIndex + 1) {
+        return ']';
+      }
+      return '';
+    },
+
+    _handleFileTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _handleMobileSelectChange: function(e) {
+      var path = Polymer.dom(e).rootTarget.value;
+      page.show(this._getDiffURL(this._changeNum, this._patchRange, path));
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _handlePrefsTap: function(e) {
+      e.preventDefault();
+      this.$.prefsOverlay.open();
+    },
+
+    _handlePrefsSave: function(e) {
+      e.stopPropagation();
+      var el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      this.$.storage.savePreferences(this._localPrefs);
+      this._saveDiffPreferences().then(function(response) {
+        el.disabled = false;
+        if (!response.ok) { return response; }
+
+        this.$.prefsOverlay.close();
+      }.bind(this)).catch(function(err) {
+        el.disabled = false;
+      }.bind(this));
+    },
+
+    _saveDiffPreferences: function() {
+      return this.$.restAPI.saveDiffPreferences(this._prefs);
+    },
+
+    _handlePrefsCancel: function(e) {
+      e.stopPropagation();
+      this.$.prefsOverlay.close();
+    },
+
+    /**
+     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+     * the current state.
+     *
+     * The expected behavior is to use the mode specified in the user's
+     * preferences unless they have manually chosen the alternative view. If the
+     * user navigates up to the change view, it should clear this choice and
+     * revert to the preference the next time a diff is viewed.
+     *
+     * Use side-by-side if the user is not logged in.
+     *
+     * @return {String}
+     */
+    _getDiffViewMode: function() {
+      if (this.changeViewState.diffMode) {
+        return this.changeViewState.diffMode;
+      } else if (this._userPrefs && this._userPrefs.diff_view) {
+        return this.changeViewState.diffMode = this._userPrefs.diff_view;
+      }
+
+      return DiffViewMode.SIDE_BY_SIDE;
+    },
+
+    _computeModeSelectHidden: function() {
+      return this._isImageDiff;
+    },
+
+    _onLineSelected: function(e, detail) {
+      this.$.cursor.moveToLineNumber(detail.number, detail.side);
+      history.pushState(null, null, '#' + this.$.cursor.getAddress());
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
new file mode 100644
index 0000000..0a4d6b6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -0,0 +1,415 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-view></gr-diff-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-view tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+        getProjectConfig: function() { return Promise.resolve({}); },
+        getDiffChangeDetail: function() { return Promise.resolve(null); },
+        getChangeFiles: function() { return Promise.resolve({}); },
+        saveFileReviewed: function() { return Promise.resolve(); },
+      });
+      element = fixture('basic');
+    });
+
+    test('toggle left diff with a hotkey', function() {
+      var toggleLeftDiffStub = sinon.stub(element.$.diff, 'toggleLeftDiff');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'a'
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+      toggleLeftDiffStub.restore();
+    });
+
+    test('keyboard shortcuts', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: {_number: 10},
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+
+      var showStub = sinon.stub(page, 'show');
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
+          'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
+          'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
+          'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      var showPrefsStub = sinon.stub(element.$.prefsOverlay, 'open');
+      MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
+      assert(showPrefsStub.calledOnce);
+
+      var scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      showPrefsStub.restore();
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: {_number: 10},
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
+          'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
+          'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
+          'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
+
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with old patch number', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
+      element._change = {
+        revisions: {
+          a: {_number: 1},
+          b: {_number: 2},
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('go up to change via kb without change loaded', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
+
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('jump to file dropdown', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
+
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+    });
+
+    test('jump to file dropdown with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
+    });
+
+    test('file review status', function(done) {
+      element._loggedIn = true;
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
+      element._fileList = ['/COMMIT_MSG'];
+      element._path = '/COMMIT_MSG';
+      var saveReviewedStub = sinon.stub(element, '_saveReviewedState',
+          function() { return Promise.resolve(); });
+
+      flush(function() {
+        var commitMsg = Polymer.dom(element.root).querySelector(
+            'input[type="checkbox"]');
+
+        assert.isTrue(commitMsg.checked);
+        MockInteractions.tap(commitMsg);
+        assert.isFalse(commitMsg.checked);
+        assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+
+        MockInteractions.tap(commitMsg);
+        assert.isTrue(commitMsg.checked);
+        assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
+
+        saveReviewedStub.restore();
+        done();
+      });
+    });
+
+    test('diff mode selector correctly toggles the diff', function() {
+      var select = element.$.modeSelect;
+      var diffDisplay = element.$.diff;
+
+      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
+
+      // The mode selected in the view state reflects the selected option.
+      assert.equal(element._getDiffViewMode(), select.value);
+
+      // The mode selected in the view state reflects the view rednered in the
+      // diff.
+      assert.equal(select.value, diffDisplay.viewMode);
+
+      // We will simulate a user change of the selected mode.
+      var newMode = 'UNIFIED_DIFF';
+
+      // Set the actual value of the select, and simulate the change event.
+      select.value = newMode;
+      element.fire('change', {}, {node: select});
+
+      // Make sure the handler was called and the state is still coherent.
+      assert.equal(element._getDiffViewMode(), newMode);
+      assert.equal(element._getDiffViewMode(), select.value);
+      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
+    });
+
+    test('_loadHash', function() {
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Ignores invalid hashes:
+      element._loadHash('not valid');
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash:
+      element._loadHash('234');
+      assert.equal(element.$.cursor.initialLineNumber, 234);
+      assert.equal(element.$.cursor.side, 'right');
+
+      // Base hash:
+      element._loadHash('b345');
+      assert.equal(element.$.cursor.initialLineNumber, 345);
+      assert.equal(element.$.cursor.side, 'left');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
new file mode 100644
index 0000000..2dc495a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -0,0 +1,116 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window, GrDiffLine) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffGroup) { return; }
+
+  function GrDiffGroup(type, opt_lines) {
+    this.type = type;
+    this.lines = [];
+    this.adds = [];
+    this.removes = [];
+
+    this.lineRange = {
+      left: {start: null, end: null},
+      right: {start: null, end: null},
+    };
+
+    if (opt_lines) {
+      opt_lines.forEach(this.addLine, this);
+    }
+  }
+
+  GrDiffGroup.prototype.element = null;
+
+  GrDiffGroup.Type = {
+    BOTH: 'both',
+    CONTEXT_CONTROL: 'contextControl',
+    DELTA: 'delta',
+  };
+
+  GrDiffGroup.prototype.addLine = function(line) {
+    this.lines.push(line);
+
+    var notDelta = (this.type === GrDiffGroup.Type.BOTH ||
+        this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
+    if (notDelta && (line.type === GrDiffLine.Type.ADD ||
+        line.type === GrDiffLine.Type.REMOVE)) {
+      throw Error('Cannot add delta line to a non-delta group.');
+    }
+
+    if (line.type === GrDiffLine.Type.ADD) {
+      this.adds.push(line);
+    } else if (line.type === GrDiffLine.Type.REMOVE) {
+      this.removes.push(line);
+    }
+    this._updateRange(line);
+  };
+
+  GrDiffGroup.prototype.getSideBySidePairs = function() {
+    if (this.type === GrDiffGroup.Type.BOTH ||
+        this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
+      return this.lines.map(function(line) {
+        return {
+          left: line,
+          right: line,
+        };
+      });
+    }
+
+    var pairs = [];
+    var i = 0;
+    var j = 0;
+    while (i < this.removes.length || j < this.adds.length) {
+      pairs.push({
+        left: this.removes[i] || GrDiffLine.BLANK_LINE,
+        right: this.adds[j] || GrDiffLine.BLANK_LINE,
+      });
+      i++;
+      j++;
+    }
+    return pairs;
+  };
+
+  GrDiffGroup.prototype._updateRange = function(line) {
+    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') { return; }
+
+    if (line.type === GrDiffLine.Type.ADD ||
+        line.type === GrDiffLine.Type.BOTH) {
+      if (this.lineRange.right.start === null ||
+          line.afterNumber < this.lineRange.right.start) {
+        this.lineRange.right.start = line.afterNumber;
+      }
+      if (this.lineRange.right.end === null ||
+          line.afterNumber > this.lineRange.right.end) {
+        this.lineRange.right.end = line.afterNumber;
+      }
+    }
+
+    if (line.type === GrDiffLine.Type.REMOVE ||
+        line.type === GrDiffLine.Type.BOTH) {
+      if (this.lineRange.left.start === null ||
+          line.beforeNumber < this.lineRange.left.start) {
+        this.lineRange.left.start = line.beforeNumber;
+      }
+      if (this.lineRange.left.end === null ||
+          line.beforeNumber > this.lineRange.left.end) {
+        this.lineRange.left.end = line.beforeNumber;
+      }
+    }
+  };
+
+  window.GrDiffGroup = GrDiffGroup;
+})(window, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
new file mode 100644
index 0000000..563825e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -0,0 +1,126 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-group</title>
+
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="gr-diff-line.js"></script>
+<script src="gr-diff-group.js"></script>
+
+<script>
+  suite('gr-diff-group tests', function() {
+
+    test('delta line pairs', function() {
+      var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l2 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      l1.afterNumber = 128;
+      l2.afterNumber = 129;
+      l3.beforeNumber = 64;
+      group.addLine(l1);
+      group.addLine(l2);
+      group.addLine(l3);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, [l1, l2]);
+      assert.deepEqual(group.removes, [l3]);
+      assert.deepEqual(group.lineRange, {
+        left: {start: 64, end: 64},
+        right: {start: 128, end: 129},
+      });
+
+      var pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l3, right: l1},
+        {left: GrDiffLine.BLANK_LINE, right: l2},
+      ]);
+
+      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, [l1, l2]);
+      assert.deepEqual(group.removes, [l3]);
+
+      pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l3, right: l1},
+        {left: GrDiffLine.BLANK_LINE, right: l2},
+      ]);
+    });
+
+    test('group/header line pairs', function() {
+      var l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      l1.beforeNumber = 64;
+      l1.afterNumber = 128;
+
+      var l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      l2.beforeNumber = 65;
+      l2.afterNumber = 129;
+
+      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      l3.beforeNumber = 66;
+      l3.afterNumber = 130;
+
+      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, []);
+      assert.deepEqual(group.removes, []);
+
+      assert.deepEqual(group.lineRange, {
+        left: {start: 64, end: 66},
+        right: {start: 128, end: 130},
+      });
+
+      var pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l1, right: l1},
+        {left: l2, right: l2},
+        {left: l3, right: l3},
+      ]);
+
+      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, []);
+      assert.deepEqual(group.removes, []);
+
+      pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l1, right: l1},
+        {left: l2, right: l2},
+        {left: l3, right: l3},
+      ]);
+    });
+
+    test('adding delta lines to non-delta group', function() {
+      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+
+      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      assert.throws(group.addLine.bind(group, l1));
+      assert.throws(group.addLine.bind(group, l2));
+      assert.doesNotThrow(group.addLine.bind(group, l3));
+
+      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.throws(group.addLine.bind(group, l1));
+      assert.throws(group.addLine.bind(group, l2));
+      assert.doesNotThrow(group.addLine.bind(group, l3));
+    });
+  });
+
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
new file mode 100644
index 0000000..2a5913c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -0,0 +1,47 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrDiffLine) { return; }
+
+  function GrDiffLine(type) {
+    this.type = type;
+    this.highlights = [];
+  }
+
+  GrDiffLine.prototype.afterNumber = 0;
+
+  GrDiffLine.prototype.beforeNumber = 0;
+
+  GrDiffLine.prototype.contextGroup = null;
+
+  GrDiffLine.prototype.text = '';
+
+  GrDiffLine.Type = {
+    ADD: 'add',
+    BOTH: 'both',
+    BLANK: 'blank',
+    CONTEXT_CONTROL: 'contextControl',
+    REMOVE: 'remove',
+  };
+
+  GrDiffLine.FILE = 'FILE';
+
+  GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
+
+  window.GrDiffLine = GrDiffLine;
+
+})(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
new file mode 100644
index 0000000..46612a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -0,0 +1,188 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
+<link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
+<link rel="import" href="../gr-syntax-themes/gr-theme-default.html">
+
+<dom-module id="gr-diff">
+  <template>
+    <style>
+      :host {
+        --light-remove-highlight-color: #fee;
+        --dark-remove-highlight-color: #ffd4d4;
+        --light-add-highlight-color: #efe;
+        --dark-add-highlight-color: #d4ffd4;
+      }
+      :host.no-left .sideBySide ::content .left,
+      :host.no-left .sideBySide ::content .left + td,
+      :host.no-left .sideBySide ::content .right:not([data-value]),
+      :host.no-left .sideBySide ::content .right:not([data-value]) + td {
+        display: none;
+      }
+      .diffContainer {
+        border-bottom: 1px solid #eee;
+        border-top: 1px solid #eee;
+        display: flex;
+        font: 12px var(--monospace-font-family);
+        overflow-x: auto;
+        will-change: transform;
+      }
+      table {
+        border-collapse: collapse;
+        border-right: 1px solid #ddd;
+        table-layout: fixed;
+      }
+      table tbody {
+        -webkit-transform: translateZ(0);
+        -moz-transform: translateZ(0);
+        -ms-transform: translateZ(0);
+        -o-transform: translateZ(0);
+        transform: translateZ(0);
+      }
+      .lineNum {
+        background-color: #eee;
+      }
+      .image-diff .gr-diff {
+        text-align: center;
+      }
+      .image-diff img {
+        max-width: 50em;
+        outline: 1px solid #ccc;
+      }
+      .image-diff label {
+        font-family: var(--font-family);
+        font-style: italic;
+      }
+      .diff-row.target-row.target-side-left .lineNum.left,
+      .diff-row.target-row.target-side-right .lineNum.right,
+      .diff-row.target-row.unified .lineNum {
+        background-color: #BBDEFB;
+      }
+      .diff-row.target-row.target-side-left .lineNum.left:before,
+      .diff-row.target-row.target-side-right .lineNum.right:before,
+      .diff-row.target-row.unified .lineNum:before {
+        color: #000;
+      }
+      .blank,
+      .content {
+        background-color: #fff;
+      }
+      .lineNum,
+      .content {
+        vertical-align: top;
+        white-space: pre;
+      }
+      .contentText:empty:before {
+        /**
+         * Insert glyph to prevent empty diff content from collapsing.
+         * "\200B" is a 'ZERO WIDTH SPACE' (U+200B)
+         */
+        content: "\200B";
+      }
+      .contextLineNum:before,
+      .lineNum:before {
+        display: inline-block;
+        color: #666;
+        content: attr(data-value);
+        padding: 0 .75em;
+        text-align: right;
+        width: 100%;
+      }
+      .canComment .lineNum[data-value] {
+        cursor: pointer;
+      }
+      .canComment .lineNum[data-value="FILE"]:before {
+        content: 'File';
+      }
+      .content {
+        overflow: hidden;
+        /* Set max and min width since setting width on table cells still
+           allows them to shrink. */
+        max-width: var(--content-width, 80ch);
+        min-width: var(--content-width, 80ch);
+      }
+      .content.add .intraline,
+      .content.add.darkHighlight {
+        background-color: var(--dark-add-highlight-color);
+      }
+      .content.add.lightHighlight {
+        background-color: var(--light-add-highlight-color);
+      }
+      .content.remove .intraline,
+      .content.remove.darkHighlight {
+        background-color: var(--dark-remove-highlight-color);
+      }
+      .content.remove.lightHighlight {
+        background-color: var(--light-remove-highlight-color);
+      }
+      .contextControl {
+        background-color: #fef;
+        color: #849;
+      }
+      .contextControl gr-button {
+        display: inline-block;
+        font-family: var(--monospace-font-family);
+        text-decoration: none;
+      }
+      .contextControl td:not(.lineNum) {
+        text-align: center;
+      }
+      .br:after {
+        /* Line feed */
+        content: '\A';
+      }
+      .tab {
+        display: inline-block;
+        position: relative;
+      }
+      .tab.withIndicator {
+        color: #D68E47;
+        text-decoration: line-through;
+      }
+    </style>
+    <style include="gr-theme-default"></style>
+    <div class$="[[_computeContainerClass(_loggedIn, viewMode)]]"
+        on-tap="_handleTap">
+      <gr-diff-selection>
+        <gr-diff-highlight
+            id="highlights"
+            logged-in="[[_loggedIn]]"
+            comments="{{_comments}}">
+          <gr-diff-builder
+              id="diffBuilder"
+              comments="[[_comments]]"
+              diff="[[_diff]]"
+              view-mode="[[viewMode]]"
+              is-image-diff="[[isImageDiff]]"
+              base-image="[[_baseImage]]"
+              revision-image="[[_revisionImage]]">
+            <table id="diffTable"></table>
+          </gr-diff-builder>
+        </gr-diff-highlight>
+      </gr-diff-selection>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-diff-line.js"></script>
+  <script src="gr-diff-group.js"></script>
+  <script src="gr-diff.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
new file mode 100644
index 0000000..dbcbb38
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -0,0 +1,466 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  Polymer({
+    is: 'gr-diff',
+
+    /**
+     * Fired when the user selects a line.
+     * @event line-selected
+     */
+
+    properties: {
+      changeNum: String,
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+        observer: '_prefsObserver',
+      },
+      projectConfig: {
+        type: Object,
+        observer: '_projectConfigChanged',
+      },
+      project: String,
+      commit: String,
+      isImageDiff: {
+        type: Boolean,
+        computed: '_computeIsImageDiff(_diff)',
+        notify: true,
+      },
+      filesWeblinks: {
+        type: Object,
+        value: function() { return {}; },
+        notify: true,
+      },
+
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+        observer: '_viewModeObserver',
+      },
+      _diff: Object,
+      _comments: Object,
+      _baseImage: Object,
+      _revisionImage: Object,
+    },
+
+    listeners: {
+      'thread-discard': '_handleThreadDiscard',
+      'comment-discard': '_handleCommentDiscard',
+      'comment-update': '_handleCommentUpdate',
+      'comment-save': '_handleCommentSave',
+      'create-comment': '_handleCreateComment',
+    },
+
+    attached: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = loggedIn;
+      }.bind(this));
+    },
+
+    reload: function() {
+      this._clearDiffContent();
+
+      var promises = [];
+
+      promises.push(this._getDiff().then(function(diff) {
+        this._diff = diff;
+        return this._loadDiffAssets();
+      }.bind(this)));
+
+      promises.push(this._getDiffCommentsAndDrafts().then(function(comments) {
+        this._comments = comments;
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
+        if (this.prefs) {
+          this._render();
+        }
+      }.bind(this));
+    },
+
+    getCursorStops: function() {
+      if (this.hidden) {
+        return [];
+      }
+
+      return Polymer.dom(this.root).querySelectorAll('.diff-row');
+    },
+
+    addDraftAtLine: function(el) {
+      this._selectLine(el);
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return; }
+
+        var value = el.getAttribute('data-value');
+        if (value === GrDiffLine.FILE) {
+          this._addDraft(el);
+          return;
+        }
+        var lineNum = parseInt(value, 10);
+        if (isNaN(lineNum)) {
+          throw Error('Invalid line number: ' + value);
+        }
+        this._addDraft(el, lineNum);
+      }.bind(this));
+    },
+
+    isRangeSelected: function() {
+      return this.$.highlights.isRangeSelected();
+    },
+
+    toggleLeftDiff: function() {
+      this.toggleClass('no-left');
+    },
+
+    _getCommentThreads: function() {
+      return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
+    },
+
+    _computeContainerClass: function(loggedIn, viewMode) {
+      var classes = ['diffContainer'];
+      switch (viewMode) {
+        case DiffViewMode.UNIFIED:
+          classes.push('unified');
+          break;
+        case DiffViewMode.SIDE_BY_SIDE:
+          classes.push('sideBySide');
+          break;
+        default:
+          throw Error('Invalid view mode: ', viewMode);
+      }
+      if (loggedIn) {
+        classes.push('canComment');
+      }
+      return classes.join(' ');
+    },
+
+    _handleTap: function(e) {
+      var el = Polymer.dom(e).rootTarget;
+
+      if (el.classList.contains('showContext')) {
+        this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
+      } else if (el.classList.contains('lineNum')) {
+        this.addDraftAtLine(el);
+      } else if (el.tagName === 'HL' ||
+          el.classList.contains('content') ||
+          el.classList.contains('contentText')) {
+        var target = this.$.diffBuilder.getLineElByChild(el);
+        if (target) { this._selectLine(target); }
+      }
+    },
+
+    _selectLine: function(el) {
+      this.fire('line-selected', {
+        side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
+        number: el.getAttribute('data-value'),
+      });
+    },
+
+    _handleCreateComment: function(e) {
+      var range = e.detail.range;
+      var diffSide = e.detail.side;
+      var line = range.endLine;
+      var lineEl = this.$.diffBuilder.getLineElByNumber(line, diffSide);
+      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+      var contentEl = contentText.parentElement;
+      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      var side = this._getSideByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+
+      threadEl.addDraft(line, range);
+    },
+
+    _addDraft: function(lineEl, opt_lineNum) {
+      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+      var contentEl = contentText.parentElement;
+      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      var side = this._getSideByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+
+      threadEl.addOrEditDraft(opt_lineNum);
+    },
+
+    _getOrCreateThreadAtLine: function(contentEl, patchNum, side) {
+      var threadEl = contentEl.querySelector('gr-diff-comment-thread');
+
+      if (!threadEl) {
+        threadEl = this.$.diffBuilder.createCommentThread(
+            this.changeNum, patchNum, this.path, side, this.projectConfig);
+        contentEl.appendChild(threadEl);
+      }
+
+      return threadEl;
+    },
+
+    _getPatchNumByLineAndContent: function(lineEl, contentEl) {
+      var patchNum = this.patchRange.patchNum;
+      if ((lineEl.classList.contains(DiffSide.LEFT) ||
+          contentEl.classList.contains('remove')) &&
+          this.patchRange.basePatchNum !== 'PARENT') {
+        patchNum = this.patchRange.basePatchNum;
+      }
+      return patchNum;
+    },
+
+    _getSideByLineAndContent: function(lineEl, contentEl) {
+      var side = 'REVISION';
+      if ((lineEl.classList.contains(DiffSide.LEFT) ||
+          contentEl.classList.contains('remove')) &&
+          this.patchRange.basePatchNum === 'PARENT') {
+        side = 'PARENT';
+      }
+      return side;
+    },
+
+    _handleThreadDiscard: function(e) {
+      var el = Polymer.dom(e).rootTarget;
+      el.parentNode.removeChild(el);
+    },
+
+    _handleCommentDiscard: function(e) {
+      var comment = e.detail.comment;
+      this._removeComment(comment, e.detail.patchNum);
+    },
+
+    _removeComment: function(comment, opt_patchNum) {
+      var side = this._findCommentSide(comment, opt_patchNum);
+      this._removeCommentFromSide(comment, side);
+    },
+
+    _findCommentSide: function(comment, opt_patchNum) {
+      if (comment.side === 'PARENT') {
+        return DiffSide.LEFT;
+      } else {
+        return this._comments.meta.patchRange.basePatchNum === opt_patchNum ?
+            DiffSide.LEFT : DiffSide.RIGHT;
+      }
+    },
+
+    _handleCommentSave: function(e) {
+      var comment = e.detail.comment;
+      var side = this._findCommentSide(comment, e.detail.patchNum);
+      var idx = this._findDraftIndex(comment, side);
+      this.set(['_comments', side, idx], comment);
+    },
+
+    _handleCommentUpdate: function(e) {
+      var comment = e.detail.comment;
+      var side = this._findCommentSide(comment, e.detail.patchNum);
+      var idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) { // Update draft or comment.
+        this.set(['_comments', side, idx], comment);
+      } else { // Create new draft.
+        this.push(['_comments', side], comment);
+      }
+    },
+
+    _removeCommentFromSide: function(comment, side) {
+      var idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) {
+        this.splice('_comments.' + side, idx, 1);
+      }
+    },
+
+    _findCommentIndex: function(comment, side) {
+      if (!comment.id || !this._comments[side]) {
+        return -1;
+      }
+      return this._comments[side].findIndex(function(item) {
+        return item.id === comment.id;
+      });
+    },
+
+    _findDraftIndex: function(comment, side) {
+      if (!comment.__draftID || !this._comments[side]) {
+        return -1;
+      }
+      return this._comments[side].findIndex(function(item) {
+        return item.__draftID === comment.__draftID;
+      });
+    },
+
+    _prefsObserver: function(newPrefs, oldPrefs) {
+      // Scan the preference objects one level deep to see if they differ.
+      var differ = !oldPrefs;
+      if (newPrefs && oldPrefs) {
+        for (var key in newPrefs) {
+          if (newPrefs[key] !== oldPrefs[key]) {
+            differ = true;
+          }
+        }
+      }
+
+      if (differ) {
+        this._prefsChanged(newPrefs);
+      }
+    },
+
+    _viewModeObserver: function() {
+      this._prefsChanged(this.prefs);
+    },
+
+    _prefsChanged: function(prefs) {
+      if (!prefs) { return; }
+      this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      this.updateStyles();
+
+      if (this._diff && this._comments) {
+        this._render();
+      }
+    },
+
+    _render: function() {
+      this.$.diffBuilder.render(this._comments, this.prefs);
+    },
+
+    _clearDiffContent: function() {
+      this.$.diffTable.innerHTML = null;
+    },
+
+    _handleGetDiffError: function(response) {
+      this.fire('page-error', {response: response});
+    },
+
+    _getDiff: function() {
+      return this.$.restAPI.getDiff(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path,
+          this._handleGetDiffError.bind(this)).then(function(diff) {
+               this.filesWeblinks = {
+                 meta_a: diff.meta_a && diff.meta_a.web_links,
+                 meta_b: diff.meta_b && diff.meta_b.web_links,
+               };
+               return diff;
+             }.bind(this));
+    },
+
+    _getDiffComments: function() {
+      return this.$.restAPI.getDiffComments(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path);
+    },
+
+    _getDiffDrafts: function() {
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) {
+          return Promise.resolve({baseComments: [], comments: []});
+        }
+        return this.$.restAPI.getDiffDrafts(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path);
+      }.bind(this));
+    },
+
+    _getDiffCommentsAndDrafts: function() {
+      var promises = [];
+      promises.push(this._getDiffComments());
+      promises.push(this._getDiffDrafts());
+      return Promise.all(promises).then(function(results) {
+        return Promise.resolve({
+          comments: results[0],
+          drafts: results[1],
+        });
+      }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
+    },
+
+    _normalizeDiffCommentsAndDrafts: function(results) {
+      function markAsDraft(d) {
+        d.__draft = true;
+        return d;
+      }
+      var baseDrafts = results.drafts.baseComments.map(markAsDraft);
+      var drafts = results.drafts.comments.map(markAsDraft);
+      return Promise.resolve({
+        meta: {
+          path: this.path,
+          changeNum: this.changeNum,
+          patchRange: this.patchRange,
+          projectConfig: this.projectConfig,
+        },
+        left: results.comments.baseComments.concat(baseDrafts),
+        right: results.comments.comments.concat(drafts),
+      });
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _computeIsImageDiff: function() {
+      if (!this._diff) { return false; }
+
+      var isA = this._diff.meta_a &&
+          this._diff.meta_a.content_type.indexOf('image/') === 0;
+      var isB = this._diff.meta_b &&
+          this._diff.meta_b.content_type.indexOf('image/') === 0;
+
+      return this._diff.binary && (isA || isB);
+    },
+
+    _loadDiffAssets: function() {
+      if (this.isImageDiff) {
+        return this._getImages().then(function(images) {
+          this._baseImage = images.baseImage;
+          this._revisionImage = images.revisionImage;
+        }.bind(this));
+      } else {
+        this._baseImage = null;
+        this._revisionImage = null;
+        return Promise.resolve();
+      }
+    },
+
+    _getImages: function() {
+      return this.$.restAPI.getImagesForDiff(this.project, this.commit,
+          this.changeNum, this._diff, this.patchRange);
+    },
+
+    _projectConfigChanged: function(projectConfig) {
+      var threadEls = this._getCommentThreads();
+      for (var i = 0; i < threadEls.length; i++) {
+        threadEls[i].projectConfig = projectConfig;
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
new file mode 100644
index 0000000..c33eadb
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -0,0 +1,473 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff></gr-diff>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', function() {
+    var element;
+
+    suite('not logged in', function() {
+
+      setup(function() {
+        stub('gr-rest-api-interface', {
+          getLoggedIn: function() { return Promise.resolve(false); },
+        });
+        element = fixture('basic');
+      });
+
+      test('toggleLeftDiff', function() {
+        element.toggleLeftDiff();
+        assert.isTrue(element.classList.contains('no-left'));
+        element.toggleLeftDiff();
+        assert.isFalse(element.classList.contains('no-left'));
+      });
+
+      test('get drafts', function(done) {
+        element.patchRange = {basePatchNum: 0, patchNum: 0};
+
+        var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts');
+        element._getDiffDrafts().then(function(result) {
+          assert.deepEqual(result, {baseComments: [], comments: []});
+          sinon.assert.notCalled(getDraftsStub);
+          getDraftsStub.restore();
+          done();
+        });
+      });
+
+      test('loads files weblinks', function(done) {
+        var diffStub = sinon.stub(element.$.restAPI, 'getDiff').returns(
+            Promise.resolve({
+              meta_a: {
+                web_links: 'foo',
+              },
+              meta_b: {
+                web_links: 'bar',
+              },
+            }));
+        element.patchRange = {};
+        element._getDiff().then(function() {
+          assert.deepEqual(element.filesWeblinks, {
+            meta_a: 'foo',
+            meta_b: 'bar',
+          });
+          done();
+        });
+        diffStub.restore();
+      });
+
+      test('remove comment', function() {
+        element._comments = {
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT'},
+            {id: 'bc2', side: 'PARENT'},
+            {id: 'bd1', __draft: true, side: 'PARENT'},
+            {id: 'bd2', __draft: true, side: 'PARENT'},
+          ],
+          right: [
+            {id: 'c1'},
+            {id: 'c2'},
+            {id: 'd1', __draft: true},
+            {id: 'd2', __draft: true},
+          ],
+        };
+
+        element._removeComment({});
+        // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+        // to believe that one object deepEquals another even when they do :-/.
+        assert.equal(JSON.stringify(element._comments), JSON.stringify({
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT'},
+            {id: 'bc2', side: 'PARENT'},
+            {id: 'bd1', __draft: true, side: 'PARENT'},
+            {id: 'bd2', __draft: true, side: 'PARENT'},
+          ],
+          right: [
+            {id: 'c1'},
+            {id: 'c2'},
+            {id: 'd1', __draft: true},
+            {id: 'd2', __draft: true},
+          ],
+        }));
+
+        element._removeComment({id: 'bc2', side: 'PARENT'});
+        assert.equal(JSON.stringify(element._comments), JSON.stringify({
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT'},
+            {id: 'bd1', __draft: true, side: 'PARENT'},
+            {id: 'bd2', __draft: true, side: 'PARENT'},
+          ],
+          right: [
+            {id: 'c1'},
+            {id: 'c2'},
+            {id: 'd1', __draft: true},
+            {id: 'd2', __draft: true},
+          ],
+        }));
+
+        element._removeComment({id: 'd2'});
+        assert.deepEqual(JSON.stringify(element._comments), JSON.stringify({
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT'},
+            {id: 'bd1', __draft: true, side: 'PARENT'},
+            {id: 'bd2', __draft: true, side: 'PARENT'},
+          ],
+          right: [
+            {id: 'c1'},
+            {id: 'c2'},
+            {id: 'd1', __draft: true},
+          ],
+        }));
+      });
+
+      test('renders image diffs', function(done) {
+        var mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        var mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
+              'AAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        var mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
+              'AAAAAAAAAAAA/////w==',
+          type: 'image/bmp'
+        };
+        var mockCommit = {
+          commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
+          parents: [{
+            commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
+            subject: 'Added a carrot',
+          }],
+          author: {
+            name: 'Wyatt Allen',
+            email: 'wyatta@google.com',
+            date: '2016-05-23 21:44:51.000000000',
+            tz: -420,
+          },
+          committer: {
+            name: 'Wyatt Allen',
+            email: 'wyatta@google.com',
+            date: '2016-05-25 00:25:41.000000000',
+            tz: -420,
+          },
+          subject: 'Updated the carrot',
+          message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
+        };
+        var mockComments = {baseComments: [], comments: []};
+
+        var stubs = [];
+        stubs.push(sinon.stub(element, '_getDiff',
+            function() { return Promise.resolve(mockDiff); }));
+        stubs.push(sinon.stub(element.$.restAPI, 'getCommitInfo',
+            function() { return Promise.resolve(mockCommit); }));
+        stubs.push(sinon.stub(element.$.restAPI,
+            'getCommitFileContents',
+            function() { return Promise.resolve(mockFile1); }));
+        stubs.push(sinon.stub(element.$.restAPI,
+            'getChangeFileContents',
+            function() { return Promise.resolve(mockFile2); }));
+        stubs.push(sinon.stub(element.$.restAPI, '_getDiffComments',
+            function() { return Promise.resolve(mockComments); }));
+        stubs.push(sinon.stub(element.$.restAPI, 'getDiffDrafts',
+            function() { return Promise.resolve(mockComments); }));
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+
+        var rendered = function() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          var leftInmage = element.$.diffTable.querySelector('td.left img');
+          assert.isOk(leftInmage);
+          assert.equal(leftInmage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile1.body);
+
+          // Right image rendered with this change's revision of the image.
+          var rightInmage = element.$.diffTable.querySelector('td.right img');
+          assert.isOk(rightInmage);
+          assert.equal(rightInmage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile2.body);
+
+          // Cleanup.
+          element.removeEventListener('render', rendered);
+          stubs.forEach(function(stub) { stub.restore(); });
+
+          done();
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.$.restAPI.getDiffPreferences().then(function(prefs) {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+
+      test('_handleTap lineNum', function(done) {
+        var addDraftStub = sinon.stub(element, 'addDraftAtLine');
+        var el = document.createElement('div');
+        el.className = 'lineNum';
+        el.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(addDraftStub.called);
+          assert.equal(addDraftStub.lastCall.args[0], el);
+          done();
+        });
+        el.click();
+      });
+
+      test('_handleTap context', function(done) {
+        var showContextStub = sinon.stub(element.$.diffBuilder, 'showContext');
+        var el = document.createElement('div');
+        el.className = 'showContext';
+        el.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(showContextStub.called);
+          done();
+        });
+        el.click();
+      });
+
+      test('_handleTap content', function(done) {
+        var content = document.createElement('div');
+        var lineEl = document.createElement('div');
+
+        var selectStub = sinon.stub(element, '_selectLine');
+        var getLineStub = sinon.stub(element.$.diffBuilder, 'getLineElByChild',
+            function() { return lineEl; });
+
+        content.className = 'content';
+        content.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(selectStub.called);
+          assert.equal(selectStub.lastCall.args[0], lineEl);
+          selectStub.restore();
+          getLineStub.restore();
+          done();
+        });
+        content.click();
+      });
+    });
+
+    suite('logged in', function() {
+
+      setup(function() {
+        stub('gr-rest-api-interface', {
+          getLoggedIn: function() { return Promise.resolve(true); },
+        });
+        element = fixture('basic');
+      });
+
+      test('get drafts', function(done) {
+        element.patchRange = {basePatchNum: 0, patchNum: 0};
+        var draftsResponse = {
+          baseComments: [{id: 'foo'}],
+          comments: [{id: 'bar'}],
+        };
+        var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts',
+            function() { return Promise.resolve(draftsResponse); });
+        element._getDiffDrafts().then(function(result) {
+          assert.deepEqual(result, draftsResponse);
+          getDraftsStub.restore();
+          done();
+        });
+      });
+
+      test('get comments and drafts', function(done) {
+        var comments = {
+          baseComments: [
+            {id: 'bc1'},
+            {id: 'bc2'},
+          ],
+          comments: [
+            {id: 'c1'},
+            {id: 'c2'},
+          ],
+        };
+        var diffCommentsStub = sinon.stub(element, '_getDiffComments',
+            function() { return Promise.resolve(comments); });
+
+        var drafts = {
+          baseComments: [
+            {id: 'bd1'},
+            {id: 'bd2'},
+          ],
+          comments: [
+            {id: 'd1'},
+            {id: 'd2'},
+          ],
+        };
+        var diffDraftsStub = sinon.stub(element, '_getDiffDrafts',
+            function() { return Promise.resolve(drafts); });
+
+        element.changeNum = '42';
+        element.patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        };
+        element.path = '/path/to/foo';
+        element.projectConfig = {foo: 'bar'};
+
+        element._getDiffCommentsAndDrafts().then(function(result) {
+          assert.deepEqual(result, {
+            meta: {
+              changeNum: '42',
+              patchRange: {
+                basePatchNum: 'PARENT',
+                patchNum: 3,
+              },
+              path: '/path/to/foo',
+              projectConfig: {foo: 'bar'},
+            },
+            left: [
+              {id: 'bc1'},
+              {id: 'bc2'},
+              {id: 'bd1', __draft: true},
+              {id: 'bd2', __draft: true},
+            ],
+            right: [
+              {id: 'c1'},
+              {id: 'c2'},
+              {id: 'd1', __draft: true},
+              {id: 'd2', __draft: true},
+            ],
+          });
+
+          diffCommentsStub.restore();
+          diffDraftsStub.restore();
+          done();
+        });
+      });
+
+      suite('handle comment-update', function() {
+
+        setup(function() {
+          element._comments = {
+            meta: {
+              changeNum: '42',
+              patchRange: {
+                basePatchNum: 'PARENT',
+                patchNum: 3,
+              },
+              path: '/path/to/foo',
+              projectConfig: {foo: 'bar'},
+            },
+            left: [
+              {id: 'bc1', side: 'PARENT'},
+              {id: 'bc2', side: 'PARENT'},
+              {id: 'bd1', __draft: true, side: 'PARENT'},
+              {id: 'bd2', __draft: true, side: 'PARENT'},
+            ],
+            right: [
+              {id: 'c1'},
+              {id: 'c2'},
+              {id: 'd1', __draft: true},
+              {id: 'd2', __draft: true},
+            ],
+          };
+        });
+
+        test('creating a draft', function() {
+          var comment = {__draft: true, __draftID: 'tempID', side: 'PARENT'};
+          element.fire('comment-update', {comment: comment});
+          assert.include(element._comments.left, comment);
+        });
+
+        test('saving a draft', function() {
+          var draftID = 'tempID';
+          var id = 'savedID';
+          element._comments.left.push(
+              {__draft: true, __draftID: draftID, side: 'PARENT'});
+          element.fire('comment-update', {comment:
+              {id: id, __draft: true, __draftID: draftID, side: 'PARENT'},
+          });
+          var drafts = element._comments.left.filter(function(item) {
+            return item.__draftID === draftID;
+          });
+          assert.equal(drafts.length, 1);
+          assert.equal(drafts[0].id, id);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
new file mode 100644
index 0000000..c496703
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -0,0 +1,65 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-patch-range-select">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .patchRange {
+        display: inline-block;
+      }
+    </style>
+    Patch set:
+    <span class="patchRange">
+      <select id="leftPatchSelect" on-change="_handlePatchChange">
+        <option value="PARENT"
+            selected$="[[_computeLeftSelected('PARENT', patchRange)]]">Base</option>
+        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
+          <option value$="[[patchNum]]"
+              selected$="[[_computeLeftSelected(patchNum, patchRange)]]"
+              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+        </template>
+      </select>
+    </span>
+    <span is="dom-if" if="[[filesWeblinks.meta_a]]">
+      <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
+        <a target="_blank"
+           href$="[[weblink.url]]">[[weblink.name]]</a>
+      </template>
+    </span>
+    &rarr;
+    <span class="patchRange">
+      <select id="rightPatchSelect" on-change="_handlePatchChange">
+        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
+          <option value$="[[patchNum]]"
+              selected$="[[_computeRightSelected(patchNum, patchRange)]]"
+              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+        </template>
+      </select>
+      <span is="dom-if" if="[[filesWeblinks.meta_b]]">
+        <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
+          <a target="_blank"
+             href$="[[weblink.url]]">[[weblink.name]]</a>
+        </template>
+      </span>
+    </span>
+  </template>
+  <script src="gr-patch-range-select.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
new file mode 100644
index 0000000..24d36c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -0,0 +1,55 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-patch-range-select',
+
+    properties: {
+      availablePatches: Array,
+      changeNum: String,
+      filesWeblinks: Object,
+      patchRange: Object,
+      path: String,
+    },
+
+    _handlePatchChange: function(e) {
+      var leftPatch = this.$.leftPatchSelect.value;
+      var rightPatch = this.$.rightPatchSelect.value;
+      var rangeStr = rightPatch;
+      if (leftPatch != 'PARENT') {
+        rangeStr = leftPatch + '..' + rangeStr;
+      }
+      page.show('/c/' + this.changeNum + '/' + rangeStr + '/' + this.path);
+    },
+
+    _computeLeftSelected: function(patchNum, patchRange) {
+      return patchNum == patchRange.basePatchNum;
+    },
+
+    _computeRightSelected: function(patchNum, patchRange) {
+      return patchNum == patchRange.patchNum;
+    },
+
+    _computeLeftDisabled: function(patchNum, patchRange) {
+      return parseInt(patchNum, 10) >= parseInt(patchRange.patchNum, 10);
+    },
+
+    _computeRightDisabled: function(patchNum, patchRange) {
+      if (patchRange.basePatchNum == 'PARENT') { return false; }
+      return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
new file mode 100644
index 0000000..c7e1196
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-patch-range-select</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-patch-range-select.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-patch-range-select auto></gr-patch-range-select>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-patch-range-select tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('enabled/disabled options', function() {
+      var patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+      ['1', '2', '3'].forEach(function(patchNum) {
+        assert.isFalse(element._computeRightDisabled(patchNum, patchRange));
+      });
+      ['PARENT', '1', '2'].forEach(function(patchNum) {
+        assert.isFalse(element._computeLeftDisabled(patchNum, patchRange));
+      });
+      assert.isTrue(element._computeLeftDisabled('3', patchRange));
+
+      patchRange.basePatchNum = '2';
+      assert.isTrue(element._computeLeftDisabled('3', patchRange));
+      assert.isTrue(element._computeRightDisabled('1', patchRange));
+      assert.isTrue(element._computeRightDisabled('2', patchRange));
+      assert.isFalse(element._computeRightDisabled('3', patchRange));
+    });
+
+    test('navigation', function(done) {
+      var showStub = sinon.stub(page, 'show');
+      var leftSelectEl = element.$.leftPatchSelect;
+      var rightSelectEl = element.$.rightPatchSelect;
+      element.changeNum = '42';
+      element.path = 'path/to/file.txt';
+      element.availablePatches = ['1', '2', '3'];
+      flushAsynchronousOperations();
+
+      var numEvents = 0;
+      leftSelectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly(
+              '/c/42/3/path/to/file.txt'),
+              'Should navigate to /c/42/3/path/to/file.txt');
+          leftSelectEl.value = '1';
+          element.fire('change', {}, {node: leftSelectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly(
+              '/c/42/1..3/path/to/file.txt'),
+              'Should navigate to /c/42/1..3/path/to/file.txt');
+          showStub.restore();
+          done();
+        }
+      });
+      leftSelectEl.value = 'PARENT';
+      rightSelectEl.value = '3';
+      element.fire('change', {}, {node: leftSelectEl});
+    });
+
+    test('filesWeblinks', function() {
+      element.filesWeblinks = {
+        meta_a: [
+          {
+            name: 'foo',
+            url: 'f.oo',
+          }
+        ],
+        meta_b: [
+          {
+            name: 'bar',
+            url: 'ba.r',
+          }
+        ],
+      };
+      flushAsynchronousOperations();
+      var domApi = Polymer.dom(element.root);
+      assert.equal(
+          domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
+      assert.equal(
+          domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
new file mode 100644
index 0000000..113e37f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -0,0 +1,21 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<dom-module id="gr-ranged-comment-layer">
+  <script src="../gr-diff-highlight/gr-annotation.js"></script>
+  <script src="gr-ranged-comment-layer.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
new file mode 100644
index 0000000..7496e59
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -0,0 +1,185 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
+  var SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+
+  var RANGE_HIGHLIGHT = 'range';
+  var HOVER_HIGHLIGHT = 'rangeHighlight';
+
+  Polymer({
+    is: 'gr-ranged-comment-layer',
+
+    properties: {
+      comments: Object,
+      _listeners: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _commentMap: {
+        type: Object,
+        value: function() { return {left: [], right: []}; },
+      }
+    },
+
+    observers: [
+      '_handleCommentChange(comments.*)',
+    ],
+
+    /**
+     * Layer method to add annotations to a line.
+     * @param {HTMLElement} el The DIV.contentText element to apply the
+     *     annotation to.
+     * @param {GrDiffLine} line The line object.
+     */
+    annotate: function(el, line) {
+      var ranges = [];
+      if (line.type === GrDiffLine.Type.REMOVE || (
+          line.type === GrDiffLine.Type.BOTH &&
+          el.getAttribute('data-side') !== 'right')) {
+        ranges = ranges.concat(this._getRangesForLine(line, 'left'));
+      }
+      if (line.type === GrDiffLine.Type.ADD || (
+          line.type === GrDiffLine.Type.BOTH &&
+          el.getAttribute('data-side') !== 'left')) {
+        ranges = ranges.concat(this._getRangesForLine(line, 'right'));
+      }
+
+      ranges.forEach(function(range) {
+        GrAnnotation.annotateElement(el, range.start,
+            range.end - range.start,
+            range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
+      });
+    },
+
+    /**
+     * Register a listener for layer updates.
+     * @param {Function<Number, Number, String>} fn The update handler function.
+     *     Should accept as arguments the line numbers for the start and end of
+     *     the update and the side as a string.
+     */
+    addListener: function(fn) {
+      this._listeners.push(fn);
+    },
+
+    /**
+     * Notify Layer listeners of changes to annotations.
+     * @param {Number} start The line where the update starts.
+     * @param {Number} end The line where the update ends.
+     * @param {String} side The side of the update. ('left' or 'right')
+     */
+    _notifyUpdateRange: function(start, end, side) {
+      this._listeners.forEach(function(listener) {
+        listener(start, end, side);
+      });
+    },
+
+    /**
+     * Handle change in the comments by updating the comment maps and by
+     * emitting appropriate update notifications.
+     * @param {Object} record The change record.
+     */
+    _handleCommentChange: function(record) {
+      if (!record.path) { return; }
+
+      // If the entire set of comments was changed.
+      if (record.path === 'comments') {
+        this._commentMap.left = this._computeCommentMap(this.comments.left);
+        this._commentMap.right = this._computeCommentMap(this.comments.right);
+        return;
+      }
+
+      // If the change only changed the `hovering` property of a comment.
+      var match = record.path.match(HOVER_PATH_PATTERN);
+      if (match) {
+        var side = match[1];
+        var index = match[2];
+        var comment = this.comments[side][index];
+        if (comment && comment.range) {
+          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
+          this._notifyUpdateRange(
+              comment.range.start_line, comment.range.end_line, side);
+        }
+        return;
+      }
+
+      // If comments were spliced in or out.
+      match = record.path.match(SPLICE_PATH_PATTERN);
+      if (match) {
+        var side = match[1];
+        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
+        this._handleCommentSplice(record.value, side);
+      }
+    },
+
+    /**
+     * Take a list of comments and return a sparse list mapping line numbers to
+     * partial ranges. Uses an end-character-index of -1 to indicate the end of
+     * the line.
+     * @param {Array<Object>} commentList The list of comments.
+     * @return {Object} The sparse list.
+     */
+    _computeCommentMap: function(commentList) {
+      var result = {};
+      commentList.forEach(function(comment) {
+        if (!comment.range) { return; }
+        var range = comment.range;
+        for (var line = range.start_line; line <= range.end_line; line++) {
+          if (!result[line]) { result[line] = []; }
+          result[line].push({
+            comment: comment,
+            start: line === range.start_line ? range.start_character : 0,
+            end: line === range.end_line ? range.end_character : -1,
+          });
+        }
+      });
+      return result;
+    },
+
+    /**
+     * Translate a splice record into range update notifications.
+     */
+    _handleCommentSplice: function(record, side) {
+      if (!record || !record.indexSplices) { return; }
+      record.indexSplices.forEach(function(splice) {
+        var ranges = splice.removed.length ?
+          splice.removed.map(function(c) { return c.range; }) :
+          [splice.object[splice.index].range];
+        ranges.forEach(function(range) {
+          if (!range) { return; }
+          this._notifyUpdateRange(range.start_line, range.end_line, side);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    _getRangesForLine: function(line, side) {
+      var lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+      var ranges = this.get(['_commentMap', side, lineNum]) || [];
+      return ranges
+          .map(function(range) {
+            return {
+              start: range.start,
+              end: range.end === -1 ? line.text.length : range.end,
+              hovering: !!range.comment.__hovering,
+            };
+          })
+          .sort(function(a, b) {
+            // Sort the ranges so that hovering highlights are on top.
+            return a.hovering && !b.hovering ? 1 : 0;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
new file mode 100644
index 0000000..68b7528
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -0,0 +1,328 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-ranged-comment-layer</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../gr-diff/gr-diff-line.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-ranged-comment-layer.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-ranged-comment-layer></gr-ranged-comment-layer>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-ranged-comment-layer', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      var initialComments = {
+        left: [
+          {
+            id: '12345',
+            line: 39,
+            message: 'range comment',
+            range: {
+              end_character: 9,
+              end_line: 39,
+              start_character: 6,
+              start_line: 36,
+            },
+          }, {
+            id: '23456',
+            line: 100,
+            message: 'non range comment',
+          },
+        ],
+        right: [
+          {
+            id: '34567',
+            line: 10,
+            message: 'range comment',
+            range: {
+              end_character: 22,
+              end_line: 12,
+              start_character: 10,
+              start_line: 10,
+            },
+          }, {
+            id: '45678',
+            line: 100,
+            message: 'single line range comment',
+            range: {
+              end_character: 15,
+              end_line: 100,
+              start_character: 5,
+              start_line: 100,
+            },
+          },
+        ],
+      };
+
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.comments = initialComments;
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    suite('annotate', function() {
+      var sandbox;
+      var el;
+      var line;
+      var annotateElementStub;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        el = document.createElement('div');
+        el.setAttribute('data-side', 'left');
+        line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('type=Remove no-comment', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 40;
+
+        element.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('type=Remove has-comment', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 36;
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line);
+
+        assert.isTrue(annotateElementStub.called);
+        var lastCall = annotateElementStub.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+
+      test('type=Remove has-comment hovering', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 36;
+        element.set(['comments', 'left', 0, '__hovering'], true);
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line);
+
+        assert.isTrue(annotateElementStub.called);
+        var lastCall = annotateElementStub.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'rangeHighlight');
+      });
+
+      test('type=Both has-comment', function() {
+        line.type = GrDiffLine.Type.BOTH;
+        line.beforeNumber = 36;
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line);
+
+        assert.isTrue(annotateElementStub.called);
+        var lastCall = annotateElementStub.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+
+      test('type=Both has-comment off side', function() {
+        line.type = GrDiffLine.Type.BOTH;
+        line.beforeNumber = 36;
+        el.setAttribute('data-side', 'right');
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('type=Add has-comment', function() {
+        line.type = GrDiffLine.Type.ADD;
+        line.afterNumber = 12;
+        el.setAttribute('data-side', 'right');
+
+        var expectedStart = 0;
+        var expectedLength = 22;
+
+        element.annotate(el, line);
+
+        assert.isTrue(annotateElementStub.called);
+        var lastCall = annotateElementStub.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+    });
+
+    test('_handleCommentChange overwrite', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+
+      element.set('comments', {left: [], right: []});
+
+      assert.isTrue(handlerSpy.called);
+      assert.equal(mapSpy.callCount, 2);
+
+      assert.equal(Object.keys(element._commentMap.left).length, 0);
+      assert.equal(Object.keys(element._commentMap.right).length, 0);
+    });
+
+    test('_handleCommentChange hovering', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.set(['comments', 'right', 0, '__hovering'], true);
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 10);
+      assert.equal(lastCall.args[1], 12);
+      assert.equal(lastCall.args[2], 'right');
+    });
+
+    test('_handleCommentChange splice out', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.splice('comments.right', 0, 1);
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 10);
+      assert.equal(lastCall.args[1], 12);
+      assert.equal(lastCall.args[2], 'right');
+    });
+
+    test('_handleCommentChange splice in', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.splice('comments.left', element.comments.left.length, 0, {
+        id: '56123',
+        line: 250,
+        message: 'new range comment',
+        range: {
+          end_character: 15,
+          end_line: 275,
+          start_character: 5,
+          start_line: 250,
+        },
+      });
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 250);
+      assert.equal(lastCall.args[1], 275);
+      assert.equal(lastCall.args[2], 'left');
+    });
+
+    test('_computeCommentMap creates maps correctly', function() {
+      // There is only one ranged comment on the left, but it spans ll.36-39.
+      var leftKeys = [];
+      for (var i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+          leftKeys.sort());
+
+      assert.equal(element._commentMap.left[36].length, 1);
+      assert.equal(element._commentMap.left[36][0].start, 6);
+      assert.equal(element._commentMap.left[36][0].end, -1);
+
+      assert.equal(element._commentMap.left[37].length, 1);
+      assert.equal(element._commentMap.left[37][0].start, 0);
+      assert.equal(element._commentMap.left[37][0].end, -1);
+
+      assert.equal(element._commentMap.left[38].length, 1);
+      assert.equal(element._commentMap.left[38][0].start, 0);
+      assert.equal(element._commentMap.left[38][0].end, -1);
+
+      assert.equal(element._commentMap.left[39].length, 1);
+      assert.equal(element._commentMap.left[39][0].start, 0);
+      assert.equal(element._commentMap.left[39][0].end, 9);
+
+      // The right has two ranged comments, one spanning ll.10-12 and the other
+      // on line 100.
+      var rightKeys = [];
+      for (i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+      rightKeys.push('100');
+      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+          rightKeys.sort());
+
+      assert.equal(element._commentMap.right[10].length, 1);
+      assert.equal(element._commentMap.right[10][0].start, 10);
+      assert.equal(element._commentMap.right[10][0].end, -1);
+
+      assert.equal(element._commentMap.right[11].length, 1);
+      assert.equal(element._commentMap.right[11][0].start, 0);
+      assert.equal(element._commentMap.right[11][0].end, -1);
+
+      assert.equal(element._commentMap.right[12].length, 1);
+      assert.equal(element._commentMap.right[12][0].start, 0);
+      assert.equal(element._commentMap.right[12][0].end, 22);
+
+      assert.equal(element._commentMap.right[100].length, 1);
+      assert.equal(element._commentMap.right[100][0].start, 5);
+      assert.equal(element._commentMap.right[100][0].end, 15);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
new file mode 100644
index 0000000..9a8ea37
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -0,0 +1,50 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+
+<dom-module id="gr-selection-action-box">
+  <template>
+    <style>
+      :host {
+        --gr-arrow-size: .6em;
+
+        background-color: #fff;
+        border: 1px solid #000;
+        border-radius: .5em;
+        cursor: pointer;
+        padding: .3em;
+        position: absolute;
+        white-space: nowrap;
+      }
+      .arrow {
+        background: #fff;
+        border: var(--gr-arrow-size) solid #000;
+        border-width: 0 1px 1px 0;
+        height: var(--gr-arrow-size);
+        left: calc(50% - 1em);
+        margin-top: .05em;
+        position: absolute;
+        transform: rotate(45deg);
+        width: var(--gr-arrow-size);
+      }
+    </style>
+    Press <strong>C</strong> to comment.
+    <div class="arrow"></div>
+  </template>
+  <script src="gr-selection-action-box.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
new file mode 100644
index 0000000..d565a12
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-selection-action-box',
+
+    /**
+     * Fired when the comment creation action was taken (hotkey, click).
+     *
+     * @event create-comment
+     */
+
+    properties: {
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      range: {
+        type: Object,
+        value: {
+          startLine: NaN,
+          startChar: NaN,
+          endLine: NaN,
+          endChar: NaN,
+        },
+      },
+      side: {
+        type: String,
+        value: '',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    listeners: {
+      'tap': '_handleTap',
+    },
+
+    placeAbove: function(el) {
+      var rect = this._getTargetBoundingRect(el);
+      var boxRect = this.getBoundingClientRect();
+      var parentRect = this.parentElement.getBoundingClientRect();
+      this.style.top =
+          rect.top - parentRect.top - boxRect.height - 4 + 'px';
+      this.style.left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+    },
+
+    _getTargetBoundingRect: function(el) {
+      var rect;
+      if (el instanceof Text) {
+        var range = document.createRange();
+        range.selectNode(el);
+        rect = range.getBoundingClientRect();
+        range.detach();
+      } else {
+        rect = el.getBoundingClientRect();
+      }
+      return rect;
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (e.keyCode === 67) { // 'c'
+        e.preventDefault();
+        this._fireCreateComment();
+      }
+    },
+
+    _handleTap: function() {
+      this._fireCreateComment();
+    },
+
+    _fireCreateComment: function() {
+      this.fire('create-comment', {side: this.side, range: this.range});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
new file mode 100644
index 0000000..adc8532
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-selection-action-box</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-selection-action-box.html">
+
+<test-fixture id="basic">
+  <template>
+    <div>
+      <gr-selection-action-box></gr-selection-action-box>
+      <div class="target">some text</div>
+    </div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-selection-action-box', function() {
+    var container;
+    var element;
+
+    setup(function() {
+      container = fixture('basic');
+      element = container.querySelector('gr-selection-action-box');
+      sinon.stub(element, 'fire');
+    });
+
+    teardown(function() {
+      element.fire.restore();
+    });
+
+    test('ignores regular keys', function() {
+      MockInteractions.pressAndReleaseKeyOn(document.body, 27); // 'esc'
+      assert.isFalse(element.fire.called);
+    });
+
+    test('reacts to hotkey', function() {
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      assert.isTrue(element.fire.called);
+    });
+
+    test('event fired contains playload', function() {
+      var side = 'left';
+      var range = {
+        startLine: 1,
+        startChar: 11,
+        endLine: 2,
+        endChar: 42,
+      };
+      element.side = 'left';
+      element.range = range;
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      assert(element.fire.calledWithExactly(
+          'create-comment',
+          {
+            side: side,
+            range: range,
+          }));
+    });
+
+    suite('placeAbove', function() {
+      var target;
+
+      setup(function() {
+        target = container.querySelector('.target');
+        sinon.stub(container, 'getBoundingClientRect').returns(
+            {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+        sinon.stub(element, '_getTargetBoundingRect').returns(
+            {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+        sinon.stub(element, 'getBoundingClientRect').returns(
+            {width: 10, height: 10});
+      });
+
+      teardown(function() {
+        element.getBoundingClientRect.restore();
+        container.getBoundingClientRect.restore();
+        element._getTargetBoundingRect.restore();
+      });
+
+      test('placeAbove for Element argument', function() {
+        element.placeAbove(target);
+        assert.equal(element.style.top, '27px');
+        assert.equal(element.style.left, '72px');
+      });
+
+      test('placeAbove for Text Node argument', function() {
+        element.placeAbove(target.firstChild);
+        assert.equal(element.style.top, '27px');
+        assert.equal(element.style.left, '72px');
+      });
+
+      test('uses document.createRange', function() {
+        sinon.spy(document, 'createRange');
+        element._getTargetBoundingRect.restore();
+        sinon.spy(element, '_getTargetBoundingRect');
+        element.placeAbove(target.firstChild);
+        assert.isTrue(document.createRange.called);
+        document.createRange.restore();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
new file mode 100644
index 0000000..c5c9377
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -0,0 +1,21 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<dom-module id="gr-syntax-layer">
+  <script src="../gr-diff/gr-diff-line.js"></script>
+  <script src="../gr-diff-highlight/gr-annotation.js"></script>
+  <script src="gr-syntax-layer.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
new file mode 100644
index 0000000..478bcc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -0,0 +1,402 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var LANGUAGE_MAP = {
+    'application/dart': 'dart',
+    'application/json': 'json',
+    'application/typescript': 'typescript',
+    'text/css': 'css',
+    'text/html': 'html',
+    'text/javascript': 'js',
+    'text/x-c': 'cpp',
+    'text/x-c++src': 'cpp',
+    'text/x-clojure': 'clojure',
+    'text/x-common-lisp': 'lisp',
+    'text/x-csharp': 'csharp',
+    'text/x-csrc': 'cpp',
+    'text/x-d': 'd',
+    'text/x-go': 'go',
+    'text/x-haskell': 'haskell',
+    'text/x-java': 'java',
+    'text/x-lua': 'lua',
+    'text/x-markdown': 'markdown',
+    'text/x-objectivec': 'objectivec',
+    'text/x-ocaml': 'ocaml',
+    'text/x-perl': 'perl',
+    'text/x-protobuf': 'protobuf',
+    'text/x-python': 'python',
+    'text/x-ruby': 'ruby',
+    'text/x-rustsrc': 'rust',
+    'text/x-scala': 'scala',
+    'text/x-sh': 'bash',
+    'text/x-sql': 'sql',
+    'text/x-swift': 'swift',
+    'text/x-yaml': 'yaml',
+  };
+  var ASYNC_DELAY = 10;
+  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+
+  var CLASS_WHITELIST = {
+    'gr-diff gr-syntax gr-syntax-literal': true,
+    'gr-diff gr-syntax gr-syntax-keyword': true,
+    'gr-diff gr-syntax gr-syntax-selector-tag': true,
+    'gr-diff gr-syntax gr-syntax-built_in': true,
+    'gr-diff gr-syntax gr-syntax-type': true,
+    'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
+    'gr-diff gr-syntax gr-syntax-template-variable': true,
+    'gr-diff gr-syntax gr-syntax-number': true,
+    'gr-diff gr-syntax gr-syntax-regexp': true,
+    'gr-diff gr-syntax gr-syntax-variable': true,
+    'gr-diff gr-syntax gr-syntax-selector-attr': true,
+    'gr-diff gr-syntax gr-syntax-template-tag': true,
+    'gr-diff gr-syntax gr-syntax-string': true,
+    'gr-diff gr-syntax gr-syntax-selector-id': true,
+    'gr-diff gr-syntax gr-syntax-title': true,
+    'gr-diff gr-syntax gr-syntax-params': true,
+    'gr-diff gr-syntax gr-syntax-comment': true,
+    'gr-diff gr-syntax gr-syntax-meta': true,
+    'gr-diff gr-syntax gr-syntax-meta-keyword': true,
+    'gr-diff gr-syntax gr-syntax-tag': true,
+    'gr-diff gr-syntax gr-syntax-name': true,
+    'gr-diff gr-syntax gr-syntax-attr': true,
+    'gr-diff gr-syntax gr-syntax-attribute': true,
+    'gr-diff gr-syntax gr-syntax-emphasis': true,
+    'gr-diff gr-syntax gr-syntax-strong': true,
+    'gr-diff gr-syntax gr-syntax-link': true,
+    'gr-diff gr-syntax gr-syntax-selector-class': true,
+  };
+
+  Polymer({
+    is: 'gr-syntax-layer',
+
+    properties: {
+      diff: {
+        type: Object,
+        observer: '_diffChanged',
+      },
+      enabled: {
+        type: Boolean,
+        value: true,
+      },
+      _baseRanges: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _revisionRanges: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _baseLanguage: String,
+      _revisionLanguage: String,
+      _listeners: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _processHandle: Number,
+    },
+
+    addListener: function(fn) {
+      this.push('_listeners', fn);
+    },
+
+    /**
+     * Annotation layer method to add syntax annotations to the given element
+     * for the given line.
+     * @param {!HTMLElement} el
+     * @param {!GrDiffLine} line
+     */
+    annotate: function(el, line) {
+      if (!this.enabled) { return; }
+
+      // Determine the side.
+      var side;
+      if (line.type === GrDiffLine.Type.REMOVE || (
+          line.type === GrDiffLine.Type.BOTH &&
+          el.getAttribute('data-side') !== 'right')) {
+        side = 'left';
+      } else if (line.type === GrDiffLine.Type.ADD || (
+          el.getAttribute('data-side') !== 'left')) {
+        side = 'right';
+      }
+
+      // Find the relevant syntax ranges, if any.
+      var ranges = [];
+      if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
+        ranges = this._baseRanges[line.beforeNumber - 1] || [];
+      } else if (side === 'right' &&
+          this._revisionRanges.length >= line.afterNumber) {
+        ranges = this._revisionRanges[line.afterNumber - 1] || [];
+      }
+
+      // Apply the ranges to the element.
+      ranges.forEach(function(range) {
+        GrAnnotation.annotateElement(
+            el, range.start, range.length, range.className);
+      });
+    },
+
+    /**
+     * Start processing symtax for the loaded diff and notify layer listeners
+     * as syntax info comes online.
+     * @return {Promise}
+     */
+    process: function() {
+      // Discard existing ranges.
+      this._baseRanges = [];
+      this._revisionRanges = [];
+
+      if (!this.enabled || !this.diff.content.length) {
+        return Promise.resolve();
+      }
+
+      this.cancel();
+
+      if (this.diff.meta_a) {
+        this._baseLanguage = LANGUAGE_MAP[this.diff.meta_a.content_type];
+      }
+      if (this.diff.meta_b) {
+        this._revisionLanguage = LANGUAGE_MAP[this.diff.meta_b.content_type];
+      }
+      if (!this._baseLanguage && !this._revisionLanguage) {
+        return Promise.resolve();
+      }
+
+      var state = {
+        sectionIndex: 0,
+        lineIndex: 0,
+        baseContext: undefined,
+        revisionContext: undefined,
+        lineNums: {left: 1, right: 1},
+        lastNotify: {left: 1, right: 1},
+      };
+
+      return this._loadHLJS().then(function() {
+        return new Promise(function(resolve) {
+          var nextStep = function() {
+            this._processHandle = null;
+            this._processNextLine(state);
+
+            // Move to the next line in the section.
+            state.lineIndex++;
+
+            // If the section has been exhausted, move to the next one.
+            if (this._isSectionDone(state)) {
+              state.lineIndex = 0;
+              state.sectionIndex++;
+            }
+
+            // If all sections have been exhausted, finish.
+            if (state.sectionIndex >= this.diff.content.length) {
+              resolve();
+              this._notify(state);
+              return;
+            }
+
+            if (state.sectionIndex !== 0 && state.lineIndex % 100 === 0) {
+              this._notify(state);
+              this._processHandle = this.async(nextStep, ASYNC_DELAY);
+            } else {
+              nextStep.call(this);
+            }
+          };
+
+          this._processHandle = this.async(nextStep, 1);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    /**
+     * Cancel any asynchronous syntax processing jobs.
+     */
+    cancel: function() {
+      if (this._processHandle) {
+        this.cancelAsync(this._processHandle);
+        this._processHandle = null;
+      }
+    },
+
+    _diffChanged: function() {
+      this.cancel();
+      this._baseRanges = [];
+      this._revisionRanges = [];
+    },
+
+    /**
+     * Take a string of HTML with the (potentially nested) syntax markers
+     * Highlight.js emits and emit a list of text ranges and classes for the
+     * markers.
+     * @param {string} str The string of HTML.
+     * @return {!Array<!Object>} The list of ranges.
+     */
+    _rangesFromString: function(str) {
+      var div = document.createElement('div');
+      div.innerHTML = str;
+      return this._rangesFromElement(div, 0);
+    },
+
+    _rangesFromElement: function(elem, offset) {
+      var result = [];
+      for (var i = 0; i < elem.childNodes.length; i++) {
+        var node = elem.childNodes[i];
+        var nodeLength = GrAnnotation.getLength(node);
+        // Note: HLJS may emit a span with class undefined when it thinks there
+        // may be a syntax error.
+        if (node.tagName === 'SPAN' && node.className !== 'undefined' &&
+            CLASS_WHITELIST.hasOwnProperty(node.className)) {
+          result.push({
+            start: offset,
+            length: nodeLength,
+            className: node.className,
+          });
+          if (node.children.length) {
+            result = result.concat(this._rangesFromElement(node, offset));
+          }
+        }
+        offset += nodeLength;
+      }
+      return result;
+    },
+
+    /**
+     * For a given state, process the syntax for the next line (or pair of
+     * lines).
+     * @param {!Object} state The processing state for the layer.
+     */
+    _processNextLine: function(state) {
+      var baseLine = undefined;
+      var revisionLine = undefined;
+      var hljs = this._getHighlightLib();
+
+      var section = this.diff.content[state.sectionIndex];
+      if (section.ab) {
+        baseLine = section.ab[state.lineIndex];
+        revisionLine = section.ab[state.lineIndex];
+        state.lineNums.left++;
+        state.lineNums.right++;
+      } else {
+        if (section.a && section.a.length > state.lineIndex) {
+          baseLine = section.a[state.lineIndex];
+          state.lineNums.left++;
+        }
+        if (section.b && section.b.length > state.lineIndex) {
+          revisionLine = section.b[state.lineIndex];
+          state.lineNums.right++;
+        }
+      }
+
+      // To store the result of the syntax highlighter.
+      var result;
+
+      if (this._baseLanguage && baseLine !== undefined) {
+        result = hljs.highlight(this._baseLanguage, baseLine, true,
+            state.baseContext);
+        this.push('_baseRanges', this._rangesFromString(result.value));
+        state.baseContext = result.top;
+      }
+
+      if (this._revisionLanguage && revisionLine !== undefined) {
+        result = hljs.highlight(this._revisionLanguage, revisionLine, true,
+            state.revisionContext);
+        this.push('_revisionRanges', this._rangesFromString(result.value));
+        state.revisionContext = result.top;
+      }
+    },
+
+    /**
+     * Tells whether the state has exhausted its current section.
+     * @param {!Object} state
+     * @return {boolean}
+     */
+    _isSectionDone: function(state) {
+      var section = this.diff.content[state.sectionIndex];
+      if (section.ab) {
+        return state.lineIndex >= section.ab.length;
+      } else {
+        return (!section.a || state.lineIndex >= section.a.length) &&
+            (!section.b || state.lineIndex >= section.b.length);
+      }
+    },
+
+    /**
+     * For a given state, notify layer listeners of any processed line ranges
+     * that have not yet been notified.
+     * @param {!Object} state
+     */
+    _notify: function(state) {
+      if (state.lineNums.left - state.lastNotify.left) {
+        this._notifyRange(
+          state.lastNotify.left,
+          state.lineNums.left,
+          'left');
+        state.lastNotify.left = state.lineNums.left;
+      }
+      if (state.lineNums.right - state.lastNotify.right) {
+        this._notifyRange(
+          state.lastNotify.right,
+          state.lineNums.right,
+          'right');
+        state.lastNotify.right = state.lineNums.right;
+      }
+    },
+
+    _notifyRange: function(start, end, side) {
+      this._listeners.forEach(function(fn) {
+        fn(start, end, side);
+      });
+    },
+
+    _getHighlightLib: function() {
+      return window.hljs;
+    },
+
+    _isHighlightLibLoaded: function() {
+      return !!this._getHighlightLib();
+    },
+
+    _configureHighlightLib: function() {
+      this._getHighlightLib().configure(
+          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    },
+
+    _getLibRoot: function() {
+      if (this._cachedLibRoot) { return this._cachedLibRoot; }
+
+      return this._cachedLibRoot = document.head
+          .querySelector('link[rel=import][href$="gr-app.html"]')
+          .href
+          .match(/(.+\/)elements\/gr-app\.html/)[1];
+    },
+    _cachedLibRoot: null,
+
+    /**
+     * Load and configure the HighlightJS library. If the library is already
+     * loaded, then do nothing and resolve.
+     * @return {Promise}
+     */
+    _loadHLJS: function() {
+      if (this._isHighlightLibLoaded()) { return Promise.resolve(); }
+      return new Promise(function(resolve) {
+        var script = document.createElement('script');
+        script.src = this._getLibRoot() + HLJS_PATH;
+        script.onload = function() {
+          this._configureHighlightLib();
+          resolve();
+        }.bind(this);
+        Polymer.dom(this.root).appendChild(script);
+      }.bind(this));
+    }
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
new file mode 100644
index 0000000..5106671
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -0,0 +1,399 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-syntax-layer</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
+<link rel="import" href="gr-syntax-layer.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-syntax-layer></gr-syntax-layer>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-syntax-layer tests', function() {
+    var sandbox;
+    var diff;
+    var element;
+
+    function getMockHLJS() {
+      var html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+          'ipsum</span>';
+      return {
+        configure: function() {},
+        highlight: function(lang, line, ignore, state) {
+          return {
+            value: line.replace(/ipsum/, html),
+            top: state === undefined ? 1 : state + 1,
+          };
+        },
+      };
+    }
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      var mock = document.createElement('mock-diff-response');
+      diff = mock.diffResponse;
+      element.diff = diff;
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('annotate without range does nothing', function() {
+      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      var el = document.createElement('div');
+      el.textContent = 'Etiam dui, blandit wisi.';
+      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.beforeNumber = 12;
+
+      element.annotate(el, line);
+
+      assert.isFalse(annotationSpy.called);
+    });
+
+    test('annotate with range applies it', function() {
+      var str = 'Etiam dui, blandit wisi.';
+      var start = 6;
+      var length = 3;
+      var className = 'foobar';
+
+      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      var el = document.createElement('div');
+      el.textContent = str;
+      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.beforeNumber = 12;
+      element._baseRanges[11] = [{
+        start: start,
+        length: length,
+        className: className,
+      }];
+
+      element.annotate(el, line);
+
+      assert.isTrue(annotationSpy.called);
+      assert.equal(annotationSpy.lastCall.args[0], el);
+      assert.equal(annotationSpy.lastCall.args[1], start);
+      assert.equal(annotationSpy.lastCall.args[2], length);
+      assert.equal(annotationSpy.lastCall.args[3], className);
+      assert.isOk(el.querySelector('hl.' + className));
+    });
+
+    test('annotate with range but disabled does nothing', function() {
+      var str = 'Etiam dui, blandit wisi.';
+      var start = 6;
+      var length = 3;
+      var className = 'foobar';
+
+      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      var el = document.createElement('div');
+      el.textContent = str;
+      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.beforeNumber = 12;
+      element._baseRanges[11] = [{
+        start: start,
+        length: length,
+        className: className,
+      }];
+      element.enabled = false;
+
+      element.annotate(el, line);
+
+      assert.isFalse(annotationSpy.called);
+    });
+
+    test('process on empty diff does nothing', function(done) {
+      element.diff = {
+        meta_a: {content_type: 'application/json'},
+        meta_b: {content_type: 'application/json'},
+        content: [],
+      };
+      var processNextSpy = sandbox.spy(element, '_processNextLine');
+
+      var processPromise = element.process();
+
+      processPromise.then(function() {
+        assert.isFalse(processNextSpy.called);
+        assert.equal(element._baseRanges.length, 0);
+        assert.equal(element._revisionRanges.length, 0);
+        done();
+      });
+    });
+
+    test('process for unsupported languages does nothing', function(done) {
+      element.diff = {
+        meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
+        meta_b: {content_type: 'application/not-a-real-language'},
+        content: [],
+      };
+      var processNextSpy = sandbox.spy(element, '_processNextLine');
+
+      var processPromise = element.process();
+
+      processPromise.then(function() {
+        assert.isFalse(processNextSpy.called);
+        assert.equal(element._baseRanges.length, 0);
+        assert.equal(element._revisionRanges.length, 0);
+        done();
+      });
+    });
+
+    test('process while disabled does nothing', function(done) {
+      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      element.enabled = false;
+      var loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
+
+      var processPromise = element.process();
+
+      processPromise.then(function() {
+        assert.isFalse(processNextSpy.called);
+        assert.equal(element._baseRanges.length, 0);
+        assert.equal(element._revisionRanges.length, 0);
+        assert.isFalse(loadHLJSSpy.called);
+        done();
+      });
+    });
+
+    test('process highlight ipsum', function(done) {
+      element.diff.meta_a.content_type = 'application/json';
+      element.diff.meta_b.content_type = 'application/json';
+
+      var mockHLJS = getMockHLJS();
+      var highlightSpy = sinon.spy(mockHLJS, 'highlight');
+      sandbox.stub(element, '_getHighlightLib',
+          function() { return mockHLJS; });
+      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      var processPromise = element.process();
+
+      processPromise.then(function() {
+        var linesA = diff.meta_a.lines;
+        var linesB = diff.meta_b.lines;
+
+        assert.isTrue(processNextSpy.called);
+        assert.equal(element._baseRanges.length, linesA);
+        assert.equal(element._revisionRanges.length, linesB);
+
+        assert.equal(highlightSpy.callCount, linesA + linesB);
+
+        // The first line of both sides have a range.
+        [element._baseRanges[0], element._revisionRanges[0]]
+            .forEach(function(range) {
+              assert.equal(range.length, 1);
+              assert.equal(range[0].className,
+                  'gr-diff gr-syntax gr-syntax-string');
+              assert.equal(range[0].start, 'lorem '.length);
+              assert.equal(range[0].length, 'ipsum'.length);
+            });
+
+        // There are no ranges from ll.1-12 on the left and ll.1-11 on the
+        // right.
+        element._baseRanges.slice(1, 12)
+            .concat(element._revisionRanges.slice(1, 11))
+            .forEach(function(range) {
+              assert.equal(range.length, 0);
+            });
+
+        // There should be another pair of ranges on l.13 for the left and
+        // l.12 for the right.
+        [element._baseRanges[13], element._revisionRanges[12]]
+            .forEach(function(range) {
+              assert.equal(range.length, 1);
+              assert.equal(range[0].className,
+                  'gr-diff gr-syntax gr-syntax-string');
+              assert.equal(range[0].start, 32);
+              assert.equal(range[0].length, 'ipsum'.length);
+            });
+
+        // The next group should have a similar instance on either side.
+
+        var range = element._baseRanges[15];
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 34);
+        assert.equal(range[0].length, 'ipsum'.length);
+
+        range = element._revisionRanges[14];
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 35);
+        assert.equal(range[0].length, 'ipsum'.length);
+
+        done();
+      });
+    });
+
+    test('_diffChanged calls cancel', function() {
+      var cancelSpy = sandbox.spy(element, '_diffChanged');
+      element.diff = {content: []};
+      assert.isTrue(cancelSpy.called);
+    });
+
+    test('_rangesFromElement no ranges', function() {
+      var elem = document.createElement('span');
+      elem.textContent = 'Etiam dui, blandit wisi.';
+      var offset = 100;
+
+      var result = element._rangesFromElement(elem, offset);
+
+      assert.equal(result.length, 0);
+    });
+
+    test('_rangesFromElement single range', function() {
+      var str0 = 'Etiam ';
+      var str1 = 'dui, blandit';
+      var str2 = ' wisi.';
+      var className = 'gr-diff gr-syntax gr-syntax-string';
+      var offset = 100;
+
+      var elem = document.createElement('span');
+      elem.appendChild(document.createTextNode(str0));
+      var span = document.createElement('span');
+      span.textContent = str1;
+      span.className = className;
+      elem.appendChild(span);
+      elem.appendChild(document.createTextNode(str2));
+
+      var result = element._rangesFromElement(elem, offset);
+
+      assert.equal(result.length, 1);
+      assert.equal(result[0].start, str0.length + offset);
+      assert.equal(result[0].length, str1.length);
+      assert.equal(result[0].className, className);
+    });
+
+    test('_rangesFromElement non-whitelist', function() {
+      var str0 = 'Etiam ';
+      var str1 = 'dui, blandit';
+      var str2 = ' wisi.';
+      var className = 'not-in-the-whitelist';
+      var offset = 100;
+
+      var elem = document.createElement('span');
+      elem.appendChild(document.createTextNode(str0));
+      var span = document.createElement('span');
+      span.textContent = str1;
+      span.className = className;
+      elem.appendChild(span);
+      elem.appendChild(document.createTextNode(str2));
+
+      var result = element._rangesFromElement(elem, offset);
+
+      assert.equal(result.length, 0);
+    });
+
+    test('_rangesFromElement milti range', function() {
+      var str0 = 'Etiam ';
+      var str1 = 'dui,';
+      var str2 = ' blandit';
+      var str3 = ' wisi.';
+      var className = 'gr-diff gr-syntax gr-syntax-string';
+      var offset = 100;
+
+      var elem = document.createElement('span');
+      elem.appendChild(document.createTextNode(str0));
+      var span = document.createElement('span');
+      span.textContent = str1;
+      span.className = className;
+      elem.appendChild(span);
+      elem.appendChild(document.createTextNode(str2));
+      span = document.createElement('span');
+      span.textContent = str3;
+      span.className = className;
+      elem.appendChild(span);
+
+      var result = element._rangesFromElement(elem, offset);
+
+      assert.equal(result.length, 2);
+
+      assert.equal(result[0].start, str0.length + offset);
+      assert.equal(result[0].length, str1.length);
+      assert.equal(result[0].className, className);
+
+      assert.equal(result[1].start,
+          str0.length + str1.length + str2.length + offset);
+      assert.equal(result[1].length, str3.length);
+      assert.equal(result[1].className, className);
+    });
+
+    test('_rangesFromElement nested range', function() {
+      var str0 = 'Etiam ';
+      var str1 = 'dui,';
+      var str2 = ' blandit';
+      var str3 = ' wisi.';
+      var className = 'gr-diff gr-syntax gr-syntax-string';
+      var offset = 100;
+
+      var elem = document.createElement('span');
+      elem.appendChild(document.createTextNode(str0));
+      var span1 = document.createElement('span');
+      span1.textContent = str1;
+      span1.className = className;
+      elem.appendChild(span1);
+      var span2 = document.createElement('span');
+      span2.textContent = str2;
+      span2.className = className;
+      span1.appendChild(span2);
+      elem.appendChild(document.createTextNode(str3));
+
+      var result = element._rangesFromElement(elem, offset);
+
+      assert.equal(result.length, 2);
+
+      assert.equal(result[0].start, str0.length + offset);
+      assert.equal(result[0].length, str1.length + str2.length);
+      assert.equal(result[0].className, className);
+
+      assert.equal(result[1].start, str0.length + str1.length + offset);
+      assert.equal(result[1].length, str2.length);
+      assert.equal(result[1].className, className);
+    });
+
+    test('_isSectionDone', function() {
+      var state = {sectionIndex: 0, lineIndex: 0};
+      assert.isFalse(element._isSectionDone(state));
+
+      state = {sectionIndex: 0, lineIndex: 2};
+      assert.isFalse(element._isSectionDone(state));
+
+      state = {sectionIndex: 0, lineIndex: 4};
+      assert.isTrue(element._isSectionDone(state));
+
+      state = {sectionIndex: 1, lineIndex: 2};
+      assert.isFalse(element._isSectionDone(state));
+
+      state = {sectionIndex: 1, lineIndex: 3};
+      assert.isTrue(element._isSectionDone(state));
+
+      state = {sectionIndex: 3, lineIndex: 0};
+      assert.isFalse(element._isSectionDone(state));
+
+      state = {sectionIndex: 3, lineIndex: 3};
+      assert.isFalse(element._isSectionDone(state));
+
+      state = {sectionIndex: 3, lineIndex: 4};
+      assert.isTrue(element._isSectionDone(state));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
new file mode 100644
index 0000000..e2abc52
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
@@ -0,0 +1,97 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-theme-default">
+  <template>
+    <style>
+      /**
+       * @overview Highlight.js emits the following classes that do not have
+       * styles here:
+       *    subst, symbol, class, function, doctag, meta-string, section,
+       *    builtin-name, bulletm, code, formula, quote, addition, deletion
+       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+       */
+
+      .gr-syntax-literal,
+      .gr-syntax-keyword,
+      .gr-syntax-selector-tag {
+        font-weight: bold;
+        color: #00f;
+      }
+      .gr-syntax-built_in {
+        color: #555;
+      }
+      .gr-syntax-type,
+      .gr-syntax-selector-pseudo,
+      .gr-syntax-template-variable {
+        color: #ff00e7;
+      }
+      .gr-syntax-number {
+        color: violet;
+      }
+      .gr-syntax-regexp,
+      .gr-syntax-variable,
+      .gr-syntax-selector-attr,
+      .gr-syntax-template-tag {
+        color: #FA8602;
+      }
+      .gr-syntax-string,
+      .gr-syntax-selector-id {
+        color: #018846;
+      }
+      .gr-syntax-title {
+        color: teal;
+      }
+      .gr-syntax-params {
+        color: red;
+      }
+      .gr-syntax-comment {
+        color: #af72a9;
+        font-style: italic;
+      }
+      .gr-syntax-meta {
+        color: #0091AD;
+      }
+      .gr-syntax-meta-keyword {
+        color: #00426b;
+        font-weight: bold;
+      }
+      .gr-syntax-tag {
+        color: #DB7C00;
+      }
+      .gr-syntax-name { /* XML/HTML Tag Name */
+        color: brown;
+      }
+      .gr-syntax-attr { /* XML/HTML Attribute */
+        color: #8C7250;
+      }
+      .gr-syntax-attribute { /* CSS Property */
+        color: #299596;
+      }
+      .gr-syntax-emphasis {
+        font-style: italic;
+      }
+      .gr-syntax-strong {
+        font-weight: bold;
+      }
+      .gr-syntax-link {
+        color: blue;
+      }
+      .gr-syntax-selector-class {
+        color: #1F71FF;
+      }
+    </style>
+  </template>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
new file mode 100644
index 0000000..c20795b
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -0,0 +1,141 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../styles/app-theme.html">
+
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
+<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
+<link rel="import" href="./core/gr-main-header/gr-main-header.html">
+<link rel="import" href="./core/gr-router/gr-router.html">
+
+<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
+<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
+<link rel="import" href="./change/gr-change-view/gr-change-view.html">
+<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
+
+<link rel="import" href="./shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<script src="../scripts/util.js"></script>
+
+<dom-module id="gr-app">
+  <template>
+    <style>
+      :host {
+        display: flex;
+        min-height: 100vh;
+        flex-direction: column;
+      }
+      gr-main-header,
+      footer {
+        color: var(--primary-text-color);
+        padding: .5rem var(--default-horizontal-margin);
+      }
+      gr-main-header {
+        background-color: var(--header-background-color, #eee);
+      }
+      footer {
+        background-color: var(--footer-background-color, #eee);
+      }
+      main {
+        flex: 1;
+        position: relative;
+      }
+      .errorView {
+        align-items: center;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+      }
+      .errorEmoji {
+        font-size: 2.6em;
+      }
+      .errorText,
+      .errorMoreInfo {
+        margin-top: .75em;
+      }
+      .errorText {
+        font-size: 1.2em;
+      }
+      .errorMoreInfo {
+        color: #999;
+      }
+      .feedback {
+        color: #b71c1c;
+      }
+    </style>
+    <gr-main-header search-query="{{params.query}}"></gr-main-header>
+    <main>
+      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
+        <gr-change-list-view
+            params="[[params]]"
+            view-state="{{_viewState.changeListView}}"
+            logged-in="[[_computeLoggedIn(_account)]]"></gr-change-list-view>
+      </template>
+      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
+        <gr-dashboard-view
+            account="[[_account]]"
+            params="[[params]]"
+            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
+      </template>
+      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+        <gr-change-view
+            params="[[params]]"
+            server-config="[[_serverConfig]]"
+            view-state="{{_viewState.changeView}}"></gr-change-view>
+      </template>
+      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+        <gr-diff-view
+            params="[[params]]"
+            change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+      </template>
+      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
+        <gr-settings-view></gr-settings-view>
+      </template>
+      <div id="errorView" class="errorView" hidden>
+        <div class="errorEmoji">[[_lastError.emoji]]</div>
+        <div class="errorText">[[_lastError.text]]</div>
+        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
+      </div>
+    </main>
+    <footer role="contentinfo">
+      Powered by <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a>
+      ([[_version]])
+      |
+      <a class="feedback"
+          href="https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue"
+          target="_blank">
+        Report PolyGerrit Bug
+      </a>
+    </footer>
+    <gr-overlay id="keyboardShortcuts" with-backdrop>
+      <gr-keyboard-shortcuts-dialog
+          view="[[params.view]]"
+          on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
+    </gr-overlay>
+    <gr-error-manager></gr-error-manager>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-app.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
new file mode 100644
index 0000000..0833a72
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -0,0 +1,175 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-app',
+
+    /**
+     * Fired when the URL location changes.
+     *
+     * @event location-change
+     */
+
+    properties: {
+      params: Object,
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+
+      _account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+      _serverConfig: Object,
+      _version: String,
+      _showChangeListView: Boolean,
+      _showDashboardView: Boolean,
+      _showChangeView: Boolean,
+      _showDiffView: Boolean,
+      _showSettingsView: Boolean,
+      _viewState: Object,
+      _lastError: Object,
+    },
+
+    listeners: {
+      'page-error': '_handlePageError',
+      'title-change': '_handleTitleChange',
+    },
+
+    observers: [
+      '_viewChanged(params.view)',
+      '_loadPlugins(_serverConfig.plugin.js_resource_paths)',
+    ],
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    attached: function() {
+      this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this));
+      this.$.restAPI.getConfig().then(function(config) {
+        this._serverConfig = config;
+      }.bind(this));
+      this.$.restAPI.getVersion().then(function(version) {
+        this._version = version;
+      }.bind(this));
+    },
+
+    ready: function() {
+      this._viewState = {
+        changeView: {
+          changeNum: null,
+          patchRange: null,
+          selectedFileIndex: 0,
+          showReplyDialog: false,
+          diffMode: null,
+        },
+        changeListView: {
+          query: null,
+          offset: 0,
+          selectedChangeIndex: 0,
+        },
+        dashboardView: {
+          selectedChangeIndex: 0,
+        },
+      };
+    },
+
+    _accountChanged: function(account) {
+      // Preferences are cached when a user is logged in; warm them.
+      this.$.restAPI.getPreferences();
+      this.$.restAPI.getDiffPreferences();
+    },
+
+    _viewChanged: function(view) {
+      this.$.errorView.hidden = true;
+      this.set('_showChangeListView', view === 'gr-change-list-view');
+      this.set('_showDashboardView', view === 'gr-dashboard-view');
+      this.set('_showChangeView', view === 'gr-change-view');
+      this.set('_showDiffView', view === 'gr-diff-view');
+      this.set('_showSettingsView', view === 'gr-settings-view');
+    },
+
+    _loadPlugins: function(plugins) {
+      for (var i = 0; i < plugins.length; i++) {
+        var scriptEl = document.createElement('script');
+        scriptEl.defer = true;
+        scriptEl.src = '/' + plugins[i];
+        document.body.appendChild(scriptEl);
+      }
+    },
+
+    _loginTapHandler: function(e) {
+      e.preventDefault();
+      page.show('/login/' + encodeURIComponent(
+          window.location.pathname + window.location.hash));
+    },
+
+    // Argument used for binding update only.
+    _computeLoggedIn: function(account) {
+      return !!(account && Object.keys(account).length > 0);
+    },
+
+    _handlePageError: function(e) {
+      [
+        '_showChangeListView',
+        '_showDashboardView',
+        '_showChangeView',
+        '_showDiffView',
+        '_showSettingsView',
+      ].forEach(function(showProp) {
+        this.set(showProp, false);
+      }.bind(this));
+
+      this.$.errorView.hidden = false;
+      var response = e.detail.response;
+      var err = {text: [response.status, response.statusText].join(' ')};
+      if (response.status === 404) {
+        err.emoji = '¯\\_(ツ)_/¯';
+        this._lastError = err;
+      } else {
+        err.emoji = 'o_O';
+        response.text().then(function(text) {
+          err.moreInfo = text;
+          this._lastError = err;
+        }.bind(this));
+      }
+    },
+
+    _handleTitleChange: function(e) {
+      if (e.detail.title) {
+        document.title = e.detail.title + ' · Gerrit Code Review';
+      } else {
+        document.title = '';
+      }
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      if (e.keyCode === 191 && e.shiftKey) {  // '/' or '?' with shift key.
+        this.$.keyboardShortcuts.open();
+      }
+    },
+
+    _handleKeyboardShortcutDialogClose: function() {
+      this.$.keyboardShortcuts.close();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
new file mode 100644
index 0000000..b34925a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -0,0 +1,66 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-account-info">
+  <template>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <section>
+        <span class="title">ID</span>
+        <span class="value">[[_account._account_id]]</span>
+      </section>
+      <section>
+        <span class="title">Email</span>
+        <span class="value">[[_account.email]]</span>
+      </section>
+      <section>
+        <span class="title">Registered</span>
+        <span class="value">
+          <gr-date-formatter
+              date-str="[[_account.registered_on]]"></gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">Username</span>
+        <span class="value">[[_account.username]]</span>
+      </section>
+      <section id="nameSection">
+        <span class="title">Full Name</span>
+        <span
+            hidden$="[[mutable]]"
+            class="value">[[_account.name]]</span>
+        <span
+            hidden$="[[!mutable]]"
+            class="value">
+          <input
+              is="iron-input"
+              disabled="[[_saving]]"
+              on-keydown="_handleNameKeydown"
+              bind-value="{{_account.name}}">
+        </span>
+      </section>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-info.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
new file mode 100644
index 0000000..3930a78
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -0,0 +1,94 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-info',
+
+    properties: {
+      mutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeMutable(_serverConfig)',
+      },
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      _account: Object,
+      _serverConfig: Object,
+    },
+
+    observers: [
+      '_nameChanged(_account.name)',
+    ],
+
+    loadData: function() {
+      var promises = [];
+
+      this._loading = true;
+
+      promises.push(this.$.restAPI.getConfig().then(function(config) {
+        this._serverConfig = config;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
+        this._loading = false;
+      }.bind(this));
+    },
+
+    save: function() {
+      if (!this.mutable || !this.hasUnsavedChanges) {
+        return Promise.resolve();
+      }
+
+      this._saving = true;
+      return this.$.restAPI.setAccountName(this._account.name).then(function() {
+        this.hasUnsavedChanges = false;
+        this._saving = false;
+      }.bind(this));
+    },
+
+    _computeMutable: function(config) {
+      return config.auth.editable_account_fields.indexOf('FULL_NAME') !== -1;
+    },
+
+    _nameChanged: function() {
+      if (this._loading) { return; }
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleNameKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation();
+        this.save();
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
new file mode 100644
index 0000000..9e1472d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-info</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-info.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-info></gr-account-info>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-info tests', function() {
+    var element;
+    var account;
+    var config;
+    var nameInput;
+
+    function valueOf(title) {
+      var sections = Polymer.dom(element.root).querySelectorAll('section');
+      var titleEl;
+      for (var i = 0; i < sections.length; i++) {
+        titleEl = sections[i].querySelector('.title');
+        if (titleEl.textContent === title) {
+          return sections[i].querySelector('.value');
+        }
+      }
+    }
+
+    setup(function(done) {
+      account = {
+        _account_id: 123,
+        name: 'user name',
+        email: 'user@email',
+        username: 'user username',
+        registered: '2000-01-01 00:00:00.000000000',
+      };
+      config = {auth: {editable_account_fields: []}};
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getConfig: function() { return Promise.resolve(config); },
+        getPreferences: function() {
+          return Promise.resolve({time_format: 'HHMM_12'});
+        },
+      });
+      element = fixture('basic');
+
+      nameInput = element.$.nameSection.querySelector('.value input');
+
+      // Allow the element to render.
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('basic account info render', function() {
+      assert.isFalse(element._loading);
+
+      assert.equal(valueOf('ID').textContent, account._account_id);
+      assert.equal(valueOf('Email').textContent, account.email);
+      assert.equal(valueOf('Username').textContent, account.username);
+    });
+
+    test('user name render (immutable)', function() {
+      var section = element.$.nameSection;
+      var displaySpan = section.querySelectorAll('.value')[0];
+      var inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.mutable);
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.name);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('user name render (mutable)', function() {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      var section = element.$.nameSection;
+      var displaySpan = section.querySelectorAll('.value')[0];
+      var inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isTrue(element.mutable);
+      assert.isTrue(displaySpan.hasAttribute('hidden'));
+      assert.equal(nameInput.bindValue, account.name);
+      assert.isFalse(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('account info edit', function(done) {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      var setStub = sinon.stub(element.$.restAPI, 'setAccountName',
+          function(name) { return Promise.resolve(); });
+
+      var nameChangedSpy = sinon.spy(element, '_nameChanged');
+
+      assert.isTrue(element.mutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
+
+      MockInteractions.pressAndReleaseKeyOn(nameInput, 13);
+
+      assert.isTrue(setStub.called);
+      setStub.lastCall.returnValue.then(function() {
+        assert.equal(setStub.lastCall.args[0], 'new name');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
new file mode 100644
index 0000000..5339c5e
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -0,0 +1,85 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-email-editor">
+  <template>
+    <style>
+      th {
+        color: #666;
+        text-align: left;
+      }
+      th.emailHeader {
+        width: 32.5em;
+      }
+      th.preferredHeader {
+        text-align: center;
+        width: 6em;
+      }
+      tbody tr:nth-child(even) {
+        background-color: #f4f4f4;
+      }
+      td.preferredControl {
+        cursor: pointer;
+        text-align: center;
+      }
+      td.preferredControl:hover {
+        border: 1px solid #ddd;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="emailHeader">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_emails]]">
+            <tr>
+              <td>[[item.email]]</td>
+              <td class="preferredControl" on-tap="_handlePreferredControlTap">
+                <input
+                    is="iron-input"
+                    type="radio"
+                    on-change="_handlePreferredChange"
+                    name="preferred"
+                    value="[[item.email]]"
+                    checked$="[[item.preferred]]">
+              </td>
+              <td>
+                <gr-button
+                    data-index$="[[index]]"
+                    on-tap="_handleDeleteButton"
+                    disabled="[[item.preferred]]"
+                    class="remove-button">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-email-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
new file mode 100644
index 0000000..90dd119c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -0,0 +1,91 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-email-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      _emails: Array,
+      _emailsToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _newPreferred: {
+        type: String,
+        value: null,
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountEmails().then(function(emails) {
+        this._emails = emails;
+      }.bind(this));
+    },
+
+    save: function() {
+      var promises = [];
+
+      for (var i = 0; i < this._emailsToRemove.length; i++) {
+        promises.push(this.$.restAPI.deleteAccountEmail(
+            this._emailsToRemove[i].email));
+      }
+
+      if (this._newPreferred) {
+        promises.push(this.$.restAPI.setPreferredAccountEmail(
+            this._newPreferred));
+      }
+
+      return Promise.all(promises).then(function() {
+        this._emailsToRemove = [];
+        this._newPreferred = null;
+        this.hasUnsavedChanges = false;
+      }.bind(this));
+    },
+
+    _handleDeleteButton: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'));
+      var email = this._emails[index];
+      this.push('_emailsToRemove', email);
+      this.splice('_emails', index, 1);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handlePreferredControlTap: function(e) {
+      if (e.target.classList.contains('preferredControl')) {
+        e.target.firstElementChild.click();
+      }
+    },
+
+    _handlePreferredChange: function(e) {
+      var preferred = e.target.value;
+      for (var i = 0; i < this._emails.length; i++) {
+        if (preferred === this._emails[i].email) {
+          this.set(['_emails', i, 'preferred'], true);
+          this._newPreferred = preferred;
+          this.hasUnsavedChanges = true;
+        } else if (this._emails[i].preferred) {
+          this.set(['_emails', i, 'preferred'], false);
+        }
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
new file mode 100644
index 0000000..030b81c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -0,0 +1,144 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-email-editor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-email-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-email-editor></gr-email-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-email-editor tests', function() {
+    var element;
+
+    setup(function(done) {
+      var emails = [
+        {email: 'email@one.com'},
+        {email: 'email@two.com', preferred: true},
+        {email: 'email@three.com'},
+      ];
+
+      stub('gr-rest-api-interface', {
+        getAccountEmails: function() { return Promise.resolve(emails); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(done);
+    });
+
+    test('renders', function() {
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 3);
+
+      assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
+      assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+
+      assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
+      assert.isOk(rows[1].querySelector('gr-button').disabled);
+
+      assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
+      assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+
+    test('edit preferred', function() {
+      var preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+      var radios = element.$$('table').querySelectorAll('input[type=radio]');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+      assert.isNotOk(radios[0].checked);
+      assert.isOk(radios[1].checked);
+      assert.isFalse(preferredChangedSpy.called);
+
+      radios[0].click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+      assert.isOk(radios[0].checked);
+      assert.isNotOk(radios[1].checked);
+      assert.isTrue(preferredChangedSpy.called);
+    });
+
+    test('delete email', function() {
+      var buttons = element.$$('table').querySelectorAll('gr-button');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+
+      buttons[2].click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 1);
+      assert.equal(element._emails.length, 2);
+
+      assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+    });
+
+    test('save changes', function(done) {
+      var deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+      var setPreferredStub = sinon.stub(element.$.restAPI,
+          'setPreferredAccountEmail');
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+
+      // Delete the first email and set the last as preferred.
+      rows[0].querySelector('gr-button').click();
+      rows[2].querySelector('input[type=radio]').click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.equal(element._newPreferred, 'email@three.com');
+      assert.equal(element._emailsToRemove.length, 1);
+      assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+      assert.equal(element._emails.length, 2);
+
+      // Save the changes.
+      element.save().then(function() {
+        assert.equal(deleteEmailStub.callCount, 1);
+        assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+        assert.isTrue(setPreferredStub.called);
+        assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
new file mode 100644
index 0000000..d68cc33
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -0,0 +1,59 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-group-list">
+  <template>
+    <style>
+      .nameHeader {
+        width: 15em;
+      }
+      .descriptionHeader {
+        width: 21.5em;
+      }
+      .visibleCell {
+        text-align: center;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="nameHeader">Name</th>
+            <th class="descriptionHeader">Description</th>
+            <th>Visible to All</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_groups]]">
+            <tr>
+              <td>[[item.name]]</td>
+              <td>[[item.description]]</td>
+              <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-group-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
new file mode 100644
index 0000000..d14c755
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-group-list',
+
+    properties: {
+      _groups: Array,
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountGroups().then(function(groups) {
+        this._groups = groups.sort(function(a, b) {
+          return a.name.localeCompare(b.name);
+        });
+      }.bind(this));
+    },
+
+    _computeVisibleToAll: function(group) {
+      return group.options.visible_to_all ? 'Yes' : 'No';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
new file mode 100644
index 0000000..56a476e
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-group-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-group-list></gr-group-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-group-list tests', function() {
+    var element;
+    var groups;
+
+    setup(function(done) {
+      groups = [{
+        url: 'some url',
+        options: {},
+        description: 'Group 1 description',
+        group_id: 1,
+        owner: 'Administrators',
+        owner_id: '123',
+        id: 'abc',
+        name: 'Group 1',
+      },{
+        options: {visible_to_all: true},
+        id: '456',
+        name: 'Group 2',
+      },{
+        options: {},
+        id: '789',
+        name: 'Group 3',
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountGroups: function() { return Promise.resolve(groups); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('renders', function() {
+      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 3);
+
+      var nameCells = rows.map(
+          function(row) { return row.querySelectorAll('td')[0].textContent; });
+
+      assert.equal(nameCells[0], 'Group 1');
+      assert.equal(nameCells[1], 'Group 2');
+      assert.equal(nameCells[2], 'Group 3');
+    });
+
+    test('_computeVisibleToAll', function() {
+      assert.equal(element._computeVisibleToAll(groups[0]), 'No');
+      assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
new file mode 100644
index 0000000..e58f1f2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -0,0 +1,63 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-http-password">
+  <template>
+    <style>
+      .password {
+        font-family: var(--monospace-font-family);
+      }
+      .noPassword {
+        color: #777;
+        font-style: italic;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <section>
+        <span class="title">Username</span>
+        <span class="value">[[_username]]</span>
+      </section>
+      <section>
+        <span class="title">Password</span>
+        <span hidden$="[[!_hasPassword]]">
+          <span class="value" hidden$="[[_passwordVisible]]">
+            <gr-button
+                link
+                on-tap="_handleViewPasswordTap">Click to view</gr-button>
+          </span>
+          <span
+              class="value password"
+              hidden$="[[!_passwordVisible]]">[[_password]]</span>
+        </span>
+        <span class="value noPassword" hidden$="[[_hasPassword]]">(None)</span>
+      </section>
+      <gr-button
+          id="generateButton"
+          on-tap="_handleGenerateTap">Generate New Password</gr-button>
+      <gr-button
+          id="clearButton"
+          on-tap="_handleClearTap"
+          disabled="[[!_hasPassword]]">Clear Password</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-http-password.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
new file mode 100644
index 0000000..9248632
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -0,0 +1,81 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-http-password',
+
+    /**
+     * Fired when getting the password fails with non-404.
+     *
+     * @event network-error
+     */
+
+    properties: {
+      _serverConfig: Object,
+      _username: String,
+      _password: String,
+      _passwordVisible: {
+        type: Boolean,
+        value: false,
+      },
+      _hasPassword: Boolean,
+    },
+
+    loadData: function() {
+      var promises = [];
+
+      promises.push(this.$.restAPI.getAccount().then(function(account) {
+        this._username = account.username;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI
+          .getAccountHttpPassword(this._handleGetPasswordError.bind(this))
+          .then(function(pass) {
+            this._password = pass;
+            this._hasPassword = !!pass;
+          }.bind(this)));
+
+      return Promise.all(promises);
+    },
+
+    _handleGetPasswordError: function(response) {
+      if (response.status === 404) {
+        this._hasPassword = false;
+      } else {
+        this.fire('network-error', {response: response});
+      }
+    },
+
+    _handleViewPasswordTap: function() {
+      this._passwordVisible = true;
+    },
+
+    _handleGenerateTap: function() {
+      this.$.restAPI.generateAccountHttpPassword().then(function(newPassword) {
+        this._hasPassword = true;
+        this._passwordVisible = true;
+        this._password = newPassword;
+      }.bind(this));
+    },
+
+    _handleClearTap: function() {
+      this.$.restAPI.deleteAccountHttpPassword().then(function() {
+        this._password = '';
+        this._hasPassword = false;
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
new file mode 100644
index 0000000..36c9abf
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -0,0 +1,156 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-http-password.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-http-password></gr-http-password>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-http-password tests (already has password)', function() {
+    var element;
+    var account;
+    var password;
+
+    setup(function(done) {
+      account = {username: 'user name'};
+      password = 'the password';
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getAccountHttpPassword: function() {
+          return Promise.resolve(password);
+        },
+      });
+
+      element = fixture('basic');
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('loads data', function() {
+      assert.equal(element._username, 'user name');
+      assert.equal(element._password, 'the password');
+      assert.isFalse(element._passwordVisible);
+      assert.isTrue(element._hasPassword);
+    });
+
+    test('view password', function() {
+      var button = element.$$('.value gr-button');
+      assert.isFalse(element._passwordVisible);
+      MockInteractions.tap(button);
+      assert.isTrue(element._passwordVisible);
+    });
+
+    test('generate password', function() {
+      var button = element.$.generateButton;
+      var nextPassword = 'the new password';
+      var generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', function() {
+            return Promise.resolve(nextPassword);
+          });
+
+      assert.isTrue(element._hasPassword);
+      assert.isFalse(element._passwordVisible);
+      assert.equal(element._password, 'the password');
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(generateStub.called);
+      generateStub.lastCall.returnValue.then(function() {
+        assert.isTrue(element._passwordVisible);
+        assert.isTrue(element._hasPassword);
+        assert.equal(element._password, 'the new password');
+      });
+    });
+
+    test('clear password', function() {
+      var button = element.$.clearButton;
+      var clearStub = sinon.stub(element.$.restAPI, 'deleteAccountHttpPassword',
+          function() { return Promise.resolve(); });
+
+      assert.isTrue(element._hasPassword);
+      assert.equal(element._password, 'the password');
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(clearStub.called);
+      clearStub.lastCall.returnValue.then(function() {
+        assert.isFalse(element._hasPassword);
+        assert.equal(element._password, '');
+      });
+    });
+  });
+
+  suite('gr-http-password tests (has no password)', function() {
+    var element;
+    var account;
+
+    setup(function(done) {
+      account = {username: 'user name'};
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getAccountHttpPassword: function(errFn) {
+          errFn({status: 404});
+          return Promise.resolve('');
+        },
+      });
+
+      element = fixture('basic');
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('loads data', function() {
+      assert.equal(element._username, 'user name');
+      assert.isNotOk(element._password);
+      assert.isFalse(element._passwordVisible);
+      assert.isFalse(element._hasPassword);
+    });
+
+    test('generate password', function() {
+      var button = element.$.generateButton;
+      var nextPassword = 'the new password';
+      var generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', function() {
+            return Promise.resolve(nextPassword);
+          });
+
+      assert.isFalse(element._hasPassword);
+      assert.isFalse(element._passwordVisible);
+      assert.isNotOk(element._password);
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(generateStub.called);
+      generateStub.lastCall.returnValue.then(function() {
+        assert.isTrue(element._passwordVisible);
+        assert.isOk(element._hasPassword);
+        assert.equal(element._password, 'the new password');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
new file mode 100644
index 0000000..0eace7d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -0,0 +1,108 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-menu-editor">
+  <template>
+    <style>
+      th.nameHeader {
+        width: 11em;
+      }
+      tbody tr:first-of-type td .move-up-button,
+      tbody tr:last-of-type td .move-down-button {
+        display: none;
+      }
+      .newTitleInput {
+        width: 10em;
+      }
+      .newUrlInput {
+        width: 23em;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="nameHeader">Name</th>
+            <th class="url-header">URL</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[menuItems]]">
+            <tr>
+              <td>[[item.name]]</td>
+              <td>[[item.url]]</td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleMoveUpButton"
+                    class="move-up-button">↑</gr-button>
+              </td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleMoveDownButton"
+                    class="move-down-button">↓</gr-button>
+              </td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleDeleteButton"
+                    class="remove-button">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <input
+                  class="newTitleInput"
+                  is="iron-input"
+                  placeholder="New Title"
+                  on-keydown="_handleInputKeydown"
+                  bind-value="{{_newName}}">
+            </th>
+            <th>
+              <input
+                  class="newUrlInput"
+                  is="iron-input"
+                  placeholder="New URL"
+                  on-keydown="_handleInputKeydown"
+                  bind-value="{{_newUrl}}">
+            </th>
+            <th></th>
+            <th></th>
+            <th>
+              <gr-button
+                  disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
+                  on-tap="_handleAddButton">Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>
+  </template>
+  <script src="gr-menu-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
new file mode 100644
index 0000000..d3a2e2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -0,0 +1,71 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-menu-editor',
+
+    properties: {
+      menuItems: Array,
+      _newName: String,
+      _newUrl: String,
+    },
+
+    _handleMoveUpButton: function(e) {
+      var index = e.target.dataIndex;
+      if (index === 0) { return; }
+      var row = this.menuItems[index];
+      var prev = this.menuItems[index - 1];
+      this.splice('menuItems', index - 1, 2, row, prev);
+    },
+
+    _handleMoveDownButton: function(e) {
+      var index = e.target.dataIndex;
+      if (index === this.menuItems.length - 1) { return; }
+      var row = this.menuItems[index];
+      var next = this.menuItems[index + 1];
+      this.splice('menuItems', index, 2, next, row);
+    },
+
+    _handleDeleteButton: function(e) {
+      var index = e.target.dataIndex;
+      this.splice('menuItems', index, 1);
+    },
+
+    _handleAddButton: function() {
+      if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
+
+      this.splice('menuItems', this.menuItems.length, 0, {
+        name: this._newName,
+        url: this._newUrl,
+        target: '_blank',
+      });
+
+      this._newName = '';
+      this._newUrl = '';
+    },
+
+    _computeAddDisabled: function(newName, newUrl) {
+      return !newName.length || !newUrl.length;
+    },
+
+    _handleInputKeydown: function(e) {
+      if (e.keyCode === 13) {
+        e.stopPropagation();
+        this._handleAddButton();
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
new file mode 100644
index 0000000..74e9c6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -0,0 +1,162 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-menu-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-menu-editor></gr-menu-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-settings-view tests', function() {
+    var element;
+    var menu;
+
+    function assertMenuNamesEqual(element, expected) {
+      var names = element.menuItems.map(function(i) { return i.name; });
+      assert.equal(names.length, expected.length);
+      for (var i = 0; i < names.length; i++) {
+        assert.equal(names[i], expected[i]);
+      }
+    }
+
+    // Click the up/down button (according to direction) for the index'th row.
+    // The index of the first row is 0, corresponding to the array.
+    function move(element, index, direction) {
+      var selector =
+          'tr:nth-child(' + (index + 1) + ') .move-' + direction + '-button';
+      var button = element.$$('tbody').querySelector(selector);
+      MockInteractions.tap(button);
+    }
+
+    setup(function() {
+      element = fixture('basic');
+      menu = [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+        {url: '/third/url', name: 'third name', target: '_blank'},
+      ];
+      element.set('menuItems', menu);
+      Polymer.dom.flush();
+    });
+
+    test('renders', function() {
+      var rows = element.$$('tbody').querySelectorAll('tr');
+      var tds;
+
+      assert.equal(rows.length, menu.length);
+      for (var i = 0; i < menu.length; i++) {
+        tds = rows[i].querySelectorAll('td');
+        assert.equal(tds[0].textContent, menu[i].name);
+        assert.equal(tds[1].textContent, menu[i].url);
+      }
+
+      assert.isTrue(element._computeAddDisabled(element._newName,
+          element._newUrl));
+    });
+
+    test('_computeAddDisabled', function() {
+      assert.isTrue(element._computeAddDisabled('', ''));
+      assert.isTrue(element._computeAddDisabled('name', ''));
+      assert.isTrue(element._computeAddDisabled('', 'url'));
+      assert.isFalse(element._computeAddDisabled('name', 'url'));
+    });
+
+    test('add a new menu item', function() {
+      var newName = 'new name';
+      var newUrl = 'new url';
+
+      element._newName = newName;
+      element._newUrl = newUrl;
+      assert.isFalse(element._computeAddDisabled(element._newName,
+          element._newUrl));
+
+      var originalMenuLength = element.menuItems.length;
+
+      element._handleAddButton();
+
+      assert.equal(element.menuItems.length, originalMenuLength + 1);
+      assert.equal(element.menuItems[element.menuItems.length - 1].name,
+          newName);
+      assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+    });
+
+    test('move items down', function() {
+      assertMenuNamesEqual(element,
+          ['first name', 'second name', 'third name']);
+
+      // Move the middle item down
+      move(element, 1, 'down');
+      assertMenuNamesEqual(element,
+          ['first name', 'third name', 'second name']);
+
+      // Moving the bottom item down is a no-op.
+      move(element, 2, 'down');
+      assertMenuNamesEqual(element,
+          ['first name', 'third name', 'second name']);
+    });
+
+    test('move items up', function() {
+      assertMenuNamesEqual(element,
+          ['first name', 'second name', 'third name']);
+
+      // Move the last item up twice to be the first.
+      move(element, 2, 'up');
+      move(element, 1, 'up');
+      assertMenuNamesEqual(element,
+          ['third name', 'first name', 'second name']);
+
+      // Moving the top item up is a no-op.
+      move(element, 0, 'up');
+      assertMenuNamesEqual(element,
+          ['third name', 'first name', 'second name']);
+    });
+
+    test('remove item', function() {
+      assertMenuNamesEqual(element,
+          ['first name', 'second name', 'third name']);
+
+      // Tap the delete button for the middle item.
+      MockInteractions.tap(
+          element.$$('tbody').querySelector('tr:nth-child(2) .remove-button'));
+
+      assertMenuNamesEqual(element, ['first name', 'third name']);
+
+      // Delete remaining items.
+      for (var i = 0; i < 2; i++) {
+        MockInteractions.tap(
+            element.$$('tbody').querySelector('tr:first-child .remove-button'));
+      }
+      assertMenuNamesEqual(element, []);
+
+      // Add item to empty menu.
+      element._newName = 'new name';
+      element._newUrl = 'new url';
+      element._handleAddButton();
+      assertMenuNamesEqual(element, ['new name']);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
new file mode 100644
index 0000000..4f1cb87
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -0,0 +1,343 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../gr-account-info/gr-account-info.html">
+<link rel="import" href="../gr-email-editor/gr-email-editor.html">
+<link rel="import" href="../gr-group-list/gr-group-list.html">
+<link rel="import" href="../gr-http-password/gr-http-password.html">
+<link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
+<link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
+<link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-settings-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      main {
+        margin: 2em auto;
+        max-width: 46em;
+      }
+      h1 {
+        margin-bottom: .1em;
+      }
+      h2.edited:after {
+        color: #444;
+        content: ' *';
+      }
+      .loading {
+        color: #666;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      #newEmailInput {
+        width: 20em;
+      }
+      nav {
+        border: 1px solid #eee;
+        border-top: none;
+        position: absolute;
+        top: 0;
+        width: 14em;
+      }
+      nav.pinned {
+        position: fixed;
+      }
+      nav ul {
+        margin: 1em 2em;
+      }
+      nav a {
+        color: black;
+        display: inline-block;
+        margin: .4em 0;
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: 2em 0 2em 15em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
+        .loading {
+          padding: 0 var(--default-horizontal-margin);
+        }
+        main {
+          margin: 2em 1em;
+        }
+        nav {
+          display: none;
+        }
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <nav id="settingsNav">
+        <ul>
+          <li><a href="#Profile">Profile</a></li>
+          <li><a href="#Preferences">Preferences</a></li>
+          <li><a href="#DiffPreferences">Diff Preferences</a></li>
+          <li><a href="#Notifications">Notifications</a></li>
+          <li><a href="#EmailAddresses">Email Addresses</a></li>
+          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
+          <li hidden$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
+            SSH Keys
+          </a></li>
+          <li><a href="#Groups">Groups</a></li>
+        </ul>
+      </nav>
+      <main class="gr-settings-styles">
+        <h1>User Settings</h1>
+        <h2
+            id="Profile"
+            class$="[[_computeHeaderClass(_accountInfoChanged)]]">Profile</h2>
+        <fieldset id="profile">
+          <gr-account-info
+              id="accountInfo"
+              mutable="{{_accountInfoMutable}}"
+              has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
+          <gr-button
+              on-tap="_handleSaveAccountInfo"
+              hidden$="[[!_accountInfoMutable]]"
+              disabled="[[!_accountInfoChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <h2
+            id="Preferences"
+            class$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
+        <fieldset id="preferences">
+          <section>
+            <span class="title">Changes Per Page</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.changes_per_page}}">
+                <option value="10">10 rows per page</option>
+                <option value="25">25 rows per page</option>
+                <option value="50">50 rows per page</option>
+                <option value="100">100 rows per page</option>
+              </select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Date/Time Format</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.date_format}}">
+                <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                <option value="US">06/03 ; 06/03/16</option>
+                <option value="ISO">06-03 ; 2016-06-03</option>
+                <option value="EURO">3. Jun ; 03.06.2016</option>
+                <option value="UK">03/06 ; 03/06/2016</option>
+              </select>
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.time_format}}">
+                <option value="HHMM_12">4:10 PM</option>
+                <option value="HHMM_24">16:10</option>
+              </select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Email Notifications</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.email_strategy}}">
+                <option value="ENABLED">Enabled</option>
+                <option
+                    value="CC_ON_OWN_COMMENTS">CC Me On Comments I Write</option>
+                <option value="DISABLED">Disabled</option>
+              </select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Diff View</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.diff_view}}">
+                <option value="SIDE_BY_SIDE">Side by Side</option>
+                <option value="UNIFIED_DIFF">Unified Diff</option>
+              </select>
+            </span>
+          </section>
+          <gr-button
+              id="savePrefs"
+              on-tap="_handleSavePreferences"
+              disabled="[[!_prefsChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <h2
+            id="DiffPreferences"
+            class$="[[_computeHeaderClass(_diffPrefsChanged)]]">
+          Diff Preferences
+        </h2>
+        <fieldset id="diffPreferences">
+          <section>
+            <span class="title">Context</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_diffPrefs.context}}">
+                <option value="3">3 lines</option>
+                <option value="10">10 lines</option>
+                <option value="25">25 lines</option>
+                <option value="50">50 lines</option>
+                <option value="75">75 lines</option>
+                <option value="100">100 lines</option>
+                <option value="-1">Whole file</option>
+              </select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Columns</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.line_length}}">
+            </span>
+          </section>
+          <section>
+            <span class="title">Tab Width</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.tab_size}}">
+            </span>
+          </section>
+          <section>
+            <span class="title">Show Tabs</span>
+            <span class="value">
+              <input
+                  id="showTabs"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.show_tabs]]"
+                  on-change="_handleShowTabsChanged">
+            </span>
+          </section>
+          <section>
+            <span class="title">Syntax Highlighting</span>
+            <span class="value">
+              <input
+                  id="syntaxHighlighting"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.syntax_highlighting]]"
+                  on-change="_handleSyntaxHighlightingChanged">
+            </span>
+          </section>
+          <gr-button
+              id="saveDiffPrefs"
+              on-tap="_handleSaveDiffPreferences"
+              disabled$="[[!_diffPrefsChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <h2 class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
+        <fieldset id="menu">
+          <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
+          <gr-button
+              id="saveMenu"
+              on-tap="_handleSaveMenu"
+              disabled="[[!_menuChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <h2
+            id="Notifications"
+            class$="[[_computeHeaderClass(_watchedProjectsChanged)]]">
+          Notifications
+        </h2>
+        <fieldset id="watchedProjects">
+          <gr-watched-projects-editor
+              has-unsaved-changes="{{_watchedProjectsChanged}}"
+              id="watchedProjectsEditor"></gr-watched-projects-editor>
+          <gr-button
+              on-tap="_handleSaveWatchedProjects"
+              disabled$="[[!_watchedProjectsChanged]]"
+              id="_handleSaveWatchedProjects">Save Changes</gr-button>
+        </fieldset>
+        <h2
+            id="EmailAddresses"
+            class$="[[_computeHeaderClass(_emailsChanged)]]">
+          Email Addresses
+        </h2>
+        <fieldset id="email">
+          <gr-email-editor
+              id="emailEditor"
+              has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
+          <gr-button
+              on-tap="_handleSaveEmails"
+              disabled$="[[!_emailsChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <fieldset id="newEmail">
+          <section>
+            <span class="title">New Email Address</span>
+            <span class="value">
+              <input
+                  id="newEmailInput"
+                  bind-value="{{_newEmail}}"
+                  is="iron-input"
+                  type="text"
+                  disabled="[[_addingEmail]]"
+                  on-keydown="_handleNewEmailKeydown"
+                  placeholder="email@example.com">
+            </span>
+          </section>
+          <section
+              id="verificationSentMessage"
+              hidden$="[[!_lastSentVerificationEmail]]">
+            <p>
+              A verification email was sent to
+              <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
+            </p>
+          </section>
+          <gr-button
+              disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
+              on-tap="_handleAddEmailButton">Send Verification</gr-button>
+        </fieldset>
+        <h2 id="HTTPCredentials">HTTP Credentials</h2>
+        <fieldset>
+          <gr-http-password id="httpPass"></gr-http-password>
+        </fieldset>
+        <div hidden$="[[!_serverConfig.sshd]]">
+          <h2
+              id="SSHKeys"
+              class$="[[_computeHeaderClass(_keysChanged)]]">SSH Keys</h2>
+          <gr-ssh-editor
+              id="sshEditor"
+              has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
+        </div>
+        <h2 id="Groups">Groups</h2>
+        <fieldset>
+          <gr-group-list id="groupList"></gr-group-list>
+        </fieldset>
+      </main>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="../../../scripts/util.js"></script>
+  <script src="gr-settings-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
new file mode 100644
index 0000000..6c62408
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -0,0 +1,268 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var PREFS_SECTION_FIELDS = [
+    'changes_per_page',
+    'date_format',
+    'time_format',
+    'email_strategy',
+    'diff_view',
+  ];
+
+  Polymer({
+    is: 'gr-settings-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      prefs: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _accountInfoMutable: Boolean,
+      _accountInfoChanged: Boolean,
+      _diffPrefs: Object,
+      _localPrefs: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _localMenu: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _prefsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _diffPrefsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _menuChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _watchedProjectsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _keysChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _newEmail: String,
+      _addingEmail: {
+        type: Boolean,
+        value: false,
+      },
+      _lastSentVerificationEmail: {
+        type: String,
+        value: null,
+      },
+      _serverConfig: Object,
+      _headerHeight: Number,
+
+      /**
+       * For testing purposes.
+       */
+      _loadingPromise: Object,
+    },
+
+    observers: [
+      '_handlePrefsChanged(_localPrefs.*)',
+      '_handleDiffPrefsChanged(_diffPrefs.*)',
+      '_handleMenuChanged(_localMenu.splices)',
+    ],
+
+    attached: function() {
+      this.fire('title-change', {title: 'Settings'});
+
+      var promises = [
+        this.$.accountInfo.loadData(),
+        this.$.watchedProjectsEditor.loadData(),
+        this.$.emailEditor.loadData(),
+        this.$.groupList.loadData(),
+        this.$.httpPass.loadData(),
+      ];
+
+      promises.push(this.$.restAPI.getPreferences().then(function(prefs) {
+        this.prefs = prefs;
+        this._copyPrefs('_localPrefs', 'prefs');
+        this._cloneMenu();
+      }.bind(this)));
+
+      promises.push(this.$.restAPI.getDiffPreferences().then(function(prefs) {
+        this._diffPrefs = prefs;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI.getConfig().then(function(config) {
+        this._serverConfig = config;
+        if (this._serverConfig.sshd) {
+          return this.$.sshEditor.loadData();
+        }
+      }.bind(this)));
+
+      this._loadingPromise = Promise.all(promises).then(function() {
+        this._loading = false;
+      }.bind(this));
+
+      this.listen(window, 'scroll', '_handleBodyScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleBodyScroll');
+    },
+
+    _handleBodyScroll: function(e) {
+      if (this._headerHeight === undefined) {
+        var top = this.$.settingsNav.offsetTop;
+        for (var offsetParent = this.$.settingsNav.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+          top += offsetParent.offsetTop;
+        }
+        this._headerHeight = top;
+      }
+
+      this.$.settingsNav.classList.toggle('pinned',
+          window.scrollY >= this._headerHeight);
+    },
+
+    _isLoading: function() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _copyPrefs: function(to, from) {
+      for (var i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+        this.set([to, PREFS_SECTION_FIELDS[i]],
+            this[from][PREFS_SECTION_FIELDS[i]]);
+      }
+    },
+
+    _cloneMenu: function() {
+      var menu = [];
+      this.prefs.my.forEach(function(item) {
+        menu.push({
+          name: item.name,
+          url: item.url,
+          target: item.target,
+        });
+      });
+      this._localMenu = menu;
+    },
+
+    _handlePrefsChanged: function(prefs) {
+      if (this._isLoading()) { return; }
+      this._prefsChanged = true;
+    },
+
+    _handleDiffPrefsChanged: function() {
+      if (this._isLoading()) { return; }
+      this._diffPrefsChanged = true;
+    },
+
+    _handleMenuChanged: function() {
+      if (this._isLoading()) { return; }
+      this._menuChanged = true;
+    },
+
+    _handleSaveAccountInfo: function() {
+      this.$.accountInfo.save();
+    },
+
+    _handleSavePreferences: function() {
+      this._copyPrefs('prefs', '_localPrefs');
+
+      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+        this._prefsChanged = false;
+      }.bind(this));
+    },
+
+    _handleShowTabsChanged: function() {
+      this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
+    },
+
+    _handleSyntaxHighlightingChanged: function() {
+      this.set('_diffPrefs.syntax_highlighting',
+          this.$.syntaxHighlighting.checked);
+    },
+
+    _handleSaveDiffPreferences: function() {
+      return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
+          .then(function() {
+            this._diffPrefsChanged = false;
+          }.bind(this));
+    },
+
+    _handleSaveMenu: function() {
+      this.set('prefs.my', this._localMenu);
+      this._cloneMenu();
+      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+        this._menuChanged = false;
+      }.bind(this));
+    },
+
+    _handleSaveWatchedProjects: function() {
+      this.$.watchedProjectsEditor.save();
+    },
+
+    _computeHeaderClass: function(changed) {
+      return changed ? 'edited' : '';
+    },
+
+    _handleSaveEmails: function() {
+      this.$.emailEditor.save();
+    },
+
+    _handleNewEmailKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation();
+        this._handleAddEmailButton();
+      }
+    },
+
+    _isNewEmailValid: function(newEmail) {
+      return newEmail.indexOf('@') !== -1;
+    },
+
+    _computeAddEmailButtonEnabled: function(newEmail, addingEmail) {
+      return this._isNewEmailValid(newEmail) && !addingEmail;
+    },
+
+    _handleAddEmailButton: function() {
+      if (!this._isNewEmailValid(this._newEmail)) { return; }
+
+      this._addingEmail = true;
+      this.$.restAPI.addAccountEmail(this._newEmail).then(function(response) {
+        this._addingEmail = false;
+
+        // If it was unsuccessful.
+        if (response.status < 200 || response.status >= 300) { return; }
+
+        this._lastSentVerificationEmail = this._newEmail;
+        this._newEmail = '';
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
new file mode 100644
index 0000000..4e98b43
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -0,0 +1,318 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-settings-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-settings-view></gr-settings-view>
+  </template>
+</test-fixture>
+
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-settings-view tests', function() {
+    var element;
+    var account;
+    var preferences;
+    var diffPreferences;
+    var config;
+
+    function valueOf(title, fieldsetid) {
+      var sections = element.$[fieldsetid].querySelectorAll('section');
+      var titleEl;
+      for (var i = 0; i < sections.length; i++) {
+        titleEl = sections[i].querySelector('.title');
+        if (titleEl.textContent === title) {
+          return sections[i].querySelector('.value');
+        }
+      }
+    }
+
+    // Because deepEqual isn't behaving in Safari.
+    function assertMenusEqual(actual, expected) {
+      assert.equal(actual.length, expected.length);
+      for (var i = 0; i < actual.length; i++) {
+        assert.equal(actual[i].name, expected[i].name);
+        assert.equal(actual[i].url, expected[i].url);
+      }
+    }
+
+    function stubAddAccountEmail(statusCode) {
+      return sinon.stub(element.$.restAPI, 'addAccountEmail',
+          function() { return Promise.resolve({status: statusCode}); });
+    }
+
+    setup(function(done) {
+      account = {
+        _account_id: 123,
+        name: 'user name',
+        email: 'user@email',
+        username: 'user username',
+        registered: '2000-01-01 00:00:00.000000000',
+      };
+      preferences = {
+        changes_per_page: 25,
+        date_format: 'UK',
+        time_format: 'HHMM_12',
+        diff_view: 'UNIFIED_DIFF',
+        email_strategy: 'ENABLED',
+
+        my: [
+          {url: '/first/url', name: 'first name', target: '_blank'},
+          {url: '/second/url', name: 'second name', target: '_blank'},
+        ],
+      };
+      diffPreferences = {
+        context: 10,
+        tab_size: 8,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE'
+      };
+      config = {auth: {editable_account_fields: []}};
+
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(true); },
+        getAccount: function() { return Promise.resolve(account); },
+        getPreferences: function() { return Promise.resolve(preferences); },
+        getDiffPreferences: function() {
+          return Promise.resolve(diffPreferences);
+        },
+        getWatchedProjects: function() {
+          return Promise.resolve([]);
+        },
+        getAccountEmails: function() { return Promise.resolve(); },
+        getConfig: function() { return Promise.resolve(config); },
+        getAccountGroups: function() { return Promise.resolve([]); },
+        getAccountHttpPassword: function() { return Promise.resolve(''); },
+      });
+      element = fixture('basic');
+
+      // Allow the element to render.
+      element._loadingPromise.then(done);
+    });
+
+    test('calls the title-change event', function() {
+      var titleChangedStub = sinon.stub();
+
+      // Create a new view.
+      var newElement = document.createElement('gr-settings-view');
+      newElement.addEventListener('title-change', titleChangedStub);
+
+      // Attach it to the fixture.
+      var blank = fixture('blank');
+      blank.appendChild(newElement);
+
+      Polymer.dom.flush();
+
+      assert.isTrue(titleChangedStub.called);
+      assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+          'Settings');
+    });
+
+    test('user preferences', function(done) {
+      // Rendered with the expected preferences selected.
+      assert.equal(valueOf('Changes Per Page', 'preferences')
+          .firstElementChild.bindValue, preferences.changes_per_page);
+      assert.equal(valueOf('Date/Time Format', 'preferences')
+          .firstElementChild.bindValue, preferences.date_format);
+      assert.equal(valueOf('Date/Time Format', 'preferences')
+          .lastElementChild.bindValue, preferences.time_format);
+      assert.equal(valueOf('Email Notifications', 'preferences')
+          .firstElementChild.bindValue, preferences.email_strategy);
+      assert.equal(valueOf('Diff View', 'preferences')
+          .firstElementChild.bindValue, preferences.diff_view);
+
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+
+      // Change the diff view element.
+      var diffSelect = valueOf('Diff View', 'preferences').firstElementChild;
+      diffSelect.bindValue = 'SIDE_BY_SIDE';
+      diffSelect.fire('change');
+
+      assert.isTrue(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences: function(prefs) {
+          assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+          assertMenusEqual(prefs.my, preferences.my);
+          return Promise.resolve();
+        }
+      });
+
+      // Save the change.
+      element._handleSavePreferences().then(function() {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
+        done();
+      });
+    });
+
+    test('diff preferences', function(done) {
+      // Rendered with the expected preferences selected.
+      assert.equal(valueOf('Context', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.context);
+      assert.equal(valueOf('Columns', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.line_length);
+      assert.equal(valueOf('Tab Width', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.tab_size);
+      assert.equal(valueOf('Show Tabs', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_tabs);
+
+      assert.isFalse(element._diffPrefsChanged);
+
+      var showTabsCheckbox = valueOf('Show Tabs', 'diffPreferences')
+          .firstElementChild;
+      showTabsCheckbox.checked = false;
+      element._handleShowTabsChanged();
+
+      assert.isTrue(element._diffPrefsChanged);
+
+      stub('gr-rest-api-interface', {
+        saveDiffPreferences: function(prefs) {
+          assert.equal(prefs.show_tabs, false);
+          return Promise.resolve();
+        }
+      });
+
+      // Save the change.
+      element._handleSaveDiffPreferences().then(function() {
+        assert.isFalse(element._diffPrefsChanged);
+        done();
+      });
+    });
+
+    test('menu', function(done) {
+      assert.isFalse(element._menuChanged);
+      assert.isFalse(element._prefsChanged);
+
+      assertMenusEqual(element._localMenu, preferences.my);
+
+      var menu = element.$.menu.firstElementChild;
+      var tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
+      assert.equal(tableRows.length, preferences.my.length);
+
+      // Add a menu item:
+      element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
+      Polymer.dom.flush();
+
+      tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
+      assert.equal(tableRows.length, preferences.my.length + 1);
+
+      assert.isTrue(element._menuChanged);
+      assert.isFalse(element._prefsChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences: function(prefs) {
+          assertMenusEqual(prefs.my, element._localMenu);
+          return Promise.resolve();
+        }
+      });
+
+      element._handleSaveMenu().then(function() {
+        assert.isFalse(element._menuChanged);
+        assert.isFalse(element._prefsChanged);
+        assertMenusEqual(element.prefs.my, element._localMenu);
+        done();
+      });
+    });
+
+    test('add email validation', function() {
+      assert.isFalse(element._isNewEmailValid('invalid email'));
+      assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+
+      assert.isFalse(
+          element._computeAddEmailButtonEnabled('invalid email'), true);
+      assert.isFalse(
+          element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
+      assert.isTrue(
+          element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+    });
+
+    test('add email does not save invalid', function() {
+      var addEmailStub = stubAddAccountEmail(201);
+
+      assert.isFalse(element._addingEmail);
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'invalid email';
+
+      element._handleAddEmailButton();
+
+      assert.isFalse(element._addingEmail);
+      assert.isFalse(addEmailStub.called);
+      assert.isNotOk(element._lastSentVerificationEmail);
+
+      assert.isFalse(addEmailStub.called);
+    });
+
+    test('add email does save valid', function(done) {
+      var addEmailStub = stubAddAccountEmail(201);
+
+      assert.isFalse(element._addingEmail);
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'valid@email.com';
+
+      element._handleAddEmailButton();
+
+      assert.isTrue(element._addingEmail);
+      assert.isTrue(addEmailStub.called);
+
+      assert.isTrue(addEmailStub.called);
+      addEmailStub.lastCall.returnValue.then(function() {
+        assert.isOk(element._lastSentVerificationEmail);
+        done();
+      });
+    });
+
+    test('add email does not set last-email if error', function(done) {
+      var addEmailStub = stubAddAccountEmail(500);
+
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'valid@email.com';
+
+      element._handleAddEmailButton();
+
+      assert.isTrue(addEmailStub.called);
+      addEmailStub.lastCall.returnValue.then(function() {
+        assert.isNotOk(element._lastSentVerificationEmail);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
new file mode 100644
index 0000000..28de9d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -0,0 +1,125 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-ssh-editor">
+  <template>
+    <style>
+      .commentHeader {
+        width: 27em;
+      }
+      .statusHeader {
+        width: 4em;
+      }
+      .keyHeader {
+        width: 7.5em;
+      }
+      #viewKeyOverlay {
+        padding: 2em;
+        width: 50em;
+      }
+      .publicKey {
+        font-family: var(--monospace-font-family);
+        overflow-x: scroll;
+        overflow-wrap: break-word;
+        width: 30em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <fieldset>
+        <table>
+          <thead>
+            <tr>
+              <th class="commentHeader">Comment</th>
+              <th class="statusHeader">Status</th>
+              <th class="keyHeader">Public Key</th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[_keys]]" as="key">
+              <tr>
+                <td>[[key.comment]]</td>
+                <td>[[_getStatusLabel(key.valid)]]</td>
+                <td>
+                  <gr-button
+                      on-tap="_showKey"
+                      data-index$="[[index]]"
+                      link>Click to View</gr-button>
+                </td>
+                <td>
+                  <gr-button
+                      data-index$="[[index]]"
+                      on-tap="_handleDeleteKey">Delete</gr-button>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+        <gr-overlay id="viewKeyOverlay" with-backdrop>
+          <fieldset>
+            <section>
+              <span class="title">Algorithm</span>
+              <span class="value">[[_keyToView.algorithm]]</span>
+            </section>
+            <section>
+              <span class="title">Public Key</span>
+              <span class="value publicKey">[[_keyToView.encoded_key]]</span>
+            </section>
+            <section>
+              <span class="title">Comment</span>
+              <span class="value">[[_keyToView.comment]]</span>
+            </section>
+          </fieldset>
+          <gr-button
+              class="closeButton"
+              on-tap="_closeOverlay">Close</gr-button>
+        </gr-overlay>
+        <gr-button
+            on-tap="save"
+            disabled$="[[!hasUnsavedChanges]]">Save Changes</gr-button>
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title">New SSH Key</span>
+          <span class="value">
+            <iron-autogrow-textarea
+                id="newKey"
+                bind-value="{{_newKey}}"
+                placeholder="New SSH Key"></iron-autogrow-textarea>
+          </span>
+        </section>
+        <gr-button
+            id="addButton"
+            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+            on-tap="_handleAddKey">Add New SSH Key</gr-button>
+      </fieldset>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-ssh-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
new file mode 100644
index 0000000..2a05033
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-ssh-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+      _keys: Array,
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountSSHKeys().then(function(keys) {
+        this._keys = keys;
+      }.bind(this));
+    },
+
+    save: function() {
+      var promises = this._keysToRemove.map(function(key) {
+        this.$.restAPI.deleteAccountSSHKey(key.seq);
+      }.bind(this));
+
+      return Promise.all(promises).then(function() {
+        this._keysToRemove = [];
+        this.hasUnsavedChanges = false;
+      }.bind(this));
+    },
+
+    _getStatusLabel: function(isValid) {
+      return isValid ? 'Valid' : 'Invalid';
+    },
+
+    _showKey: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      this._keyToView = this._keys[index];
+      this.$.viewKeyOverlay.open();
+    },
+
+    _closeOverlay: function() {
+      this.$.viewKeyOverlay.close();
+    },
+
+    _handleDeleteKey: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      this.push('_keysToRemove', this._keys[index]);
+      this.splice('_keys', index, 1);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleAddKey: function() {
+      this.$.addButton.disabled = true;
+      this.$.newKey.disabled = true;
+      return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
+          .then(function(key) {
+            this.$.newKey.disabled = false;
+            this._newKey = '';
+            this.push('_keys', key);
+          }.bind(this))
+          .catch(function() {
+            this.$.addButton.disabled = false;
+            this.$.newKey.disabled = false;
+          }.bind(this));
+    },
+
+    _computeAddButtonDisabled: function(newKey) {
+      return !newKey.length;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
new file mode 100644
index 0000000..b248029
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-ssh-editor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-ssh-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-ssh-editor></gr-ssh-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-ssh-editor tests', function() {
+    var element;
+    var keys;
+
+    setup(function(done) {
+      keys = [{
+        seq: 1,
+        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+        encoded_key: '<key 1>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-one@machine-one',
+        valid: true,
+      }, {
+        seq: 2,
+        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+        encoded_key: '<key 2>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-two@machine-two',
+        valid: true,
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountSSHKeys: function() { return Promise.resolve(keys); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('renders', function() {
+      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 2);
+
+      var cells = rows[0].querySelectorAll('td');
+      assert.equal(cells[0].textContent, keys[0].comment);
+
+      cells = rows[1].querySelectorAll('td');
+      assert.equal(cells[0].textContent, keys[1].comment);
+    });
+
+    test('remove key', function(done) {
+      var lastKey = keys[1];
+
+      var saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+          function() { return Promise.resolve(); });
+
+      assert.equal(element._keysToRemove.length, 0);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      // Get the delete button for the last row.
+      var button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keys.length, 1);
+      assert.equal(element._keysToRemove.length, 1);
+      assert.equal(element._keysToRemove[0], lastKey);
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isFalse(saveStub.called);
+
+      element.save().then(function() {
+        assert.isTrue(saveStub.called);
+        assert.equal(saveStub.lastCall.args[0], lastKey.seq);
+        assert.equal(element._keysToRemove.length, 0);
+        assert.isFalse(element.hasUnsavedChanges);
+        done();
+      });
+    });
+
+    test('show key', function() {
+      var openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+      // Get the show button for the last row.
+      var button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(3) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keyToView, keys[1]);
+      assert.isTrue(openSpy.called);
+    });
+
+    test('add key', function(done) {
+      var newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+      var newKeyObject = {
+        seq: 3,
+        ssh_public_key: newKeyString,
+        encoded_key: '<key 3>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-three@machine-three',
+        valid: true,
+      };
+
+      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          function() { return Promise.resolve(newKeyObject); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(function() {
+        assert.isTrue(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 3);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.equal(addStub.lastCall.args[0], newKeyString);
+    });
+
+    test('add invalid key', function(done) {
+      var newKeyString = 'not even close to valid';
+
+      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          function() { return Promise.reject(); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(function() {
+        assert.isFalse(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 2);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.equal(addStub.lastCall.args[0], newKeyString);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
new file mode 100644
index 0000000..61d35f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -0,0 +1,131 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-watched-projects-editor">
+  <template>
+    <style>
+      th.projectHeader {
+        width: 11em;
+      }
+      th.notificationHeader {
+        text-align: center;
+      }
+      th.notifType {
+        text-align: center;
+        padding: 0 0.4em;
+      }
+      td.notifControl {
+        cursor: pointer;
+        text-align: center;
+      }
+      td.notifControl:hover {
+        border: 1px solid #ddd;
+      }
+      .projectFilter {
+        color: #777;
+        font-style: italic;
+        margin-left: 1em;
+      }
+      input {
+        font-size: 1em;
+      }
+      .newProjectInput {
+        width: 10em;
+      }
+      .newFilterInput {
+        width: 26em;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="projectHeader">Project</th>
+            <template is="dom-repeat" items="[[_getTypes()]]">
+              <th class="notifType">[[item.name]]</th>
+            </template>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template
+              is="dom-repeat"
+              items="[[_projects]]"
+              as="project"
+              index-as="projectIndex">
+            <tr>
+              <td>
+                [[project.project]]
+                <template is="dom-if" if="[[project.filter]]">
+                  <div class="projectFilter">[[project.filter]]</div>
+                </template>
+              </td>
+              <template
+                  is="dom-repeat"
+                  items="[[_getTypes()]]"
+                  as="type">
+                <td class="notifControl" on-tap="_handleNotifCellTap">
+                  <input
+                      type="checkbox"
+                      data-index$="[[projectIndex]]"
+                      data-key$="[[type.key]]"
+                      on-change="_handleCheckboxChange"
+                      checked$="[[_computeCheckboxChecked(project, type.key)]]">
+                </td>
+              </template>
+              <td class="delete-column">
+                <gr-button
+                    data-index$="[[projectIndex]]"
+                    on-tap="_handleRemoveProject">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <gr-autocomplete
+                  id="newProject"
+                  class="newProjectInput"
+                  is="iron-input"
+                  query="[[_query]]"
+                  threshold="3"
+                  placeholder="Project"></gr-autocomplete>
+            </th>
+            <th colspan$="[[_getTypeCount()]]">
+              <input
+                  id="newFilter"
+                  class="newFilterInput"
+                  is="iron-input"
+                  placeholder="branch:name, or other search expression">
+            </th>
+            <th>
+              <gr-button on-tap="_handleAddProject">Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-watched-projects-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
new file mode 100644
index 0000000..d65f512
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var NOTIFICATION_TYPES = [
+    {name: 'Changes', key: 'notify_new_changes'},
+    {name: 'Patches', key: 'notify_new_patch_sets'},
+    {name: 'Comments', key: 'notify_all_comments'},
+    {name: 'Submits', key: 'notify_submitted_changes'},
+    {name: 'Abandons', key: 'notify_abandoned_changes'},
+  ];
+
+  Polymer({
+    is: 'gr-watched-projects-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+
+      _projects: Array,
+      _projectsToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _query: {
+        type: Function,
+        value: function() {
+          return this._getProjectSuggestions.bind(this);
+        },
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getWatchedProjects().then(function(projs) {
+        this._projects = projs;
+      }.bind(this));
+    },
+
+    save: function() {
+      var deletePromise;
+      if (this._projectsToRemove.length) {
+        deletePromise = this.$.restAPI.deleteWatchedProjects(
+            this._projectsToRemove);
+      } else {
+        deletePromise = Promise.resolve();
+      }
+
+      return deletePromise
+          .then(function() {
+            return this.$.restAPI.saveWatchedProjects(this._projects);
+          }.bind(this))
+          .then(function(projects) {
+            this._projects = projects;
+            this._projectsToRemove = [];
+            this.hasUnsavedChanges = false;
+          }.bind(this));
+    },
+
+    _getTypes: function() {
+      return NOTIFICATION_TYPES;
+    },
+
+    _getTypeCount: function() {
+      return this._getTypes().length;
+    },
+
+    _computeCheckboxChecked: function(project, key) {
+      return project.hasOwnProperty(key);
+    },
+
+    _getProjectSuggestions: function(input) {
+      return this.$.restAPI.getSuggestedProjects(input)
+        .then(function(response) {
+          var projects = [];
+          for (var key in response) {
+            projects.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return projects;
+        });
+    },
+
+    _handleRemoveProject: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      var project = this._projects[index];
+      this.splice('_projects', index, 1);
+      this.push('_projectsToRemove', project);
+      this.hasUnsavedChanges = true;
+    },
+
+    _canAddProject: function(project, filter) {
+      if (!project || !project.id) { return false; }
+
+      // Check if the project with filter is already in the list. Compare
+      // filters using == to coalesce null and undefined.
+      for (var i = 0; i < this._projects.length; i++) {
+        if (this._projects[i].project === project.id &&
+            this._projects[i].filter == filter) {
+          return false;
+        }
+      }
+
+      return true;
+    },
+
+    _getNewProjectIndex: function(name, filter) {
+      for (var i = 0; i < this._projects.length; i++) {
+        if (this._projects[i].project > name ||
+            (this._projects[i].project === name &&
+                this._projects[i].filter > filter)) {
+          break;
+        }
+      }
+      return i;
+    },
+
+    _handleAddProject: function() {
+      var newProject = this.$.newProject.value;
+      var newProjectName = this.$.newProject.text;
+      var filter = this.$.newFilter.value || null;
+
+      if (!this._canAddProject(newProject, filter)) { return; }
+
+      var insertIndex = this._getNewProjectIndex(newProjectName, filter);
+
+      this.splice('_projects', insertIndex, 0, {
+        project: newProjectName,
+        filter: filter,
+        _is_local: true,
+      });
+
+      this.$.newProject.clear();
+      this.$.newFilter.bindValue = '';
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleCheckboxChange: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      var key = e.target.getAttribute('data-key');
+      var checked = e.target.checked;
+      this.set(['_projects', index, key], !!checked);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleNotifCellTap: function(e) {
+      var checkbox = Polymer.dom(e.target).querySelector('input');
+      if (checkbox) { checkbox.click(); }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
new file mode 100644
index 0000000..66576a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -0,0 +1,195 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-watched-projects-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-watched-projects-editor></gr-watched-projects-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-watched-projects-editor tests', function() {
+    var element;
+
+    setup(function(done) {
+      var projects = [{
+          project: 'project a',
+          notify_submitted_changes: true,
+          notify_abandoned_changes: true,
+        }, {
+          project: 'project b',
+          filter: 'filter 1',
+          notify_new_changes: true,
+        }, {
+          project: 'project b',
+          filter: 'filter 2',
+        }, {
+          project: 'project c',
+          notify_new_changes: true,
+          notify_new_patch_sets: true,
+          notify_all_comments: true,
+        },
+      ];
+
+      stub('gr-rest-api-interface', {
+        getSuggestedProjects: function(input) {
+          if (input.indexOf('the') === 0) {
+            return Promise.resolve({'the project': {
+              id: 'the project',
+              state: 'ACTIVE',
+              web_links: [],
+            }});
+          } else {
+            return Promise.resolve({});
+          }
+        },
+        getWatchedProjects: function() {
+          return Promise.resolve(projects);
+        },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('renders', function() {
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+      assert.equal(rows.length, 4);
+
+      function getKeysOfRow(row) {
+        var boxes = rows[row].querySelectorAll('input[checked]');
+        return Array.prototype.map.call(boxes,
+            function(e) { return e.getAttribute('data-key'); });
+      }
+
+      var checkedKeys = getKeysOfRow(0);
+      assert.equal(checkedKeys.length, 2);
+      assert.equal(checkedKeys[0], 'notify_submitted_changes');
+      assert.equal(checkedKeys[1], 'notify_abandoned_changes');
+
+      checkedKeys = getKeysOfRow(1);
+      assert.equal(checkedKeys.length, 1);
+      assert.equal(checkedKeys[0], 'notify_new_changes');
+
+      checkedKeys = getKeysOfRow(2);
+      assert.equal(checkedKeys.length, 0);
+
+      checkedKeys = getKeysOfRow(3);
+      assert.equal(checkedKeys.length, 3);
+      assert.equal(checkedKeys[0], 'notify_new_changes');
+      assert.equal(checkedKeys[1], 'notify_new_patch_sets');
+      assert.equal(checkedKeys[2], 'notify_all_comments');
+    });
+
+    test('_getProjectSuggestions empty', function(done) {
+      element._getProjectSuggestions('nonexistent').then(function(projects) {
+        assert.equal(projects.length, 0);
+        done();
+      });
+    });
+
+    test('_getProjectSuggestions non-empty', function(done) {
+      element._getProjectSuggestions('the project').then(function(projects) {
+        assert.equal(projects.length, 1);
+        assert.equal(projects[0].name, 'the project');
+        done();
+      });
+    });
+
+    test('_canAddProject', function() {
+      assert.isFalse(element._canAddProject(null, null));
+      assert.isFalse(element._canAddProject({}, null));
+
+      // Can add a project that is not in the list.
+      assert.isTrue(element._canAddProject({id: 'project d'}, null));
+      assert.isTrue(element._canAddProject({id: 'project d'}, 'filter 3'));
+
+      // Cannot add a project that is in the list with no filter.
+      assert.isFalse(element._canAddProject({id: 'project a'}, null));
+
+      // Can add a project that is in the list if the filter differs.
+      assert.isTrue(element._canAddProject({id: 'project a'}, 'filter 4'));
+
+      // Cannot add a project that is in the list with the same filter.
+      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 1'));
+      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 2'));
+
+      // Can add a projec that is in the list using a new filter.
+      assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
+    });
+
+    test('_getNewProjectIndex', function() {
+      // Projects are sorted in ASCII order.
+      assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
+      assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+
+      // Projects are sorted by filter when the names are equal
+      assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
+      assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
+      assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+
+      // Projects with filters follow those without
+      assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+    });
+
+    test('_handleAddProject', function() {
+      element.$.newProject.value = {id: 'project d'};
+      element.$.newProject.setText('project d');
+      element.$.newFilter.bindValue = '';
+
+      element._handleAddProject();
+
+      assert.equal(element._projects.length, 5);
+      assert.equal(element._projects[4].project, 'project d');
+      assert.isNotOk(element._projects[4].filter);
+      assert.isTrue(element._projects[4]._is_local);
+    });
+
+    test('_handleAddProject with invalid inputs', function() {
+      element.$.newProject.value = {id: 'project b'};
+      element.$.newProject.setText('project b');
+      element.$.newFilter.bindValue = 'filter 1';
+
+      element._handleAddProject();
+
+      assert.equal(element._projects.length, 4);
+    });
+
+    test('_handleRemoveProject', function() {
+      assert.equal(element._projectsToRemove, 0);
+
+      var button = element.$$('table tbody tr:nth-child(2) gr-button');
+      MockInteractions.tap(button);
+
+      var rows = element.$$('table tbody').querySelectorAll('tr');
+      assert.equal(rows.length, 3);
+
+      assert.equal(element._projectsToRemove.length, 1);
+      assert.equal(element._projectsToRemove[0].project, 'project b');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
new file mode 100644
index 0000000..360c281
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -0,0 +1,66 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-account-link/gr-account-link.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-account-chip">
+  <template>
+    <style>
+      :host {
+        display: block;
+        overflow: hidden;
+      }
+      .container {
+        align-items: center;
+        background: #eee;
+        border-radius: .75em;
+        display: inline-flex;
+        padding: 0 .5em;
+      }
+      :host([show-avatar]) .container {
+        padding-left: 0;
+      }
+      gr-button.remove,
+      gr-button.remove:hover,
+      gr-button.remove:focus {
+        border-color: transparent;
+        color: #333;
+      }
+      gr-button.remove {
+        background: #eee;
+        color: #666;
+        font-size: 1.7em;
+        font-weight: normal;
+        height: .6em;
+        line-height: .6em;
+        margin-left: .15em;
+        padding: 0;
+        text-decoration: none;
+      }
+    </style>
+    <div class="container">
+      <gr-account-link account="[[account]]"></gr-account-link>
+      <gr-button
+          hidden$="[[!removable]]" hidden
+          class="remove" on-tap="_handleRemoveTap">×</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-chip.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
new file mode 100644
index 0000000..45bf8fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-chip',
+
+    properties: {
+      account: Object,
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      showAvatar: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
+    },
+
+    ready: function() {
+      this._getHasAvatars().then(function(hasAvatars) {
+        this.showAvatar = hasAvatars;
+      }.bind(this));
+    },
+
+    _handleRemoveTap: function(e) {
+      e.preventDefault();
+      this.fire('remove', {account: this.account});
+    },
+
+    _getHasAvatars: function() {
+      return this.$.restAPI.getConfig().then(function(cfg) {
+        return Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars));
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
new file mode 100644
index 0000000..f136907
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -0,0 +1,51 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-avatar/gr-avatar.html">
+
+<dom-module id="gr-account-label">
+  <template>
+    <style>
+      :host {
+        display: inline;
+      }
+      :host::after {
+        content: var(--account-label-suffix);
+      }
+      gr-avatar {
+        height: 1.3em;
+        width: 1.3em;
+        margin-right: .15em;
+        vertical-align: -.25em;
+      }
+      .text:hover {
+        @apply(--gr-account-label-text-hover-style);
+      }
+    </style>
+    <span title$="[[_computeAccountTitle(account)]]">
+      <gr-avatar account="[[account]]"
+          image-size="[[avatarImageSize]]"></gr-avatar>
+      <span class="text">
+        <span>[[account.name]]</span>
+        <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
+          ([[account.email]])
+        </span>
+      </span>
+    </span>
+  </template>
+  <script src="gr-account-label.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
new file mode 100644
index 0000000..98871cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-label',
+
+    properties: {
+      account: Object,
+      avatarImageSize: {
+        type: Number,
+        value: 32,
+      },
+      showEmail: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    _computeAccountTitle: function(account) {
+      if (!account || !account.name) { return; }
+      var result = util.escapeHTML(account.name);
+      if (account.email) {
+        result += ' <' + util.escapeHTML(account.email) + '>';
+      }
+      return result;
+    },
+
+    _computeShowEmail: function(showEmail, account) {
+      return !!(showEmail && account && account.email);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
new file mode 100644
index 0000000..eacd710
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-label</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-account-label.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-label></gr-account-label>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-label tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeAccountTitle(
+          {
+            name: 'Andrew Bonventre',
+            email: 'andybons+gerrit@gmail.com'
+          }),
+          'Andrew Bonventre <andybons+gerrit@gmail.com>');
+
+      assert.equal(element._computeAccountTitle(
+          {name: 'Andrew Bonventre'}),
+          'Andrew Bonventre');
+
+      assert.equal(element._computeShowEmail(true,
+          {
+            name: 'Andrew Bonventre',
+            email: 'andybons+gerrit@gmail.com'
+          }), true);
+
+      assert.equal(element._computeShowEmail(true,
+          {name: 'Andrew Bonventre'}), false);
+
+      assert.equal(element._computeShowEmail(false,
+          {name: 'Andrew Bonventre'}), false);
+
+      assert.equal(element._computeShowEmail(
+          true, undefined), false);
+
+      assert.equal(element._computeShowEmail(
+          false, undefined), false);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
new file mode 100644
index 0000000..d3585ef
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -0,0 +1,44 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-account-label/gr-account-label.html">
+
+<dom-module id="gr-account-link">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      gr-account-label {
+        --gr-account-label-text-hover-style: {
+          text-decoration: underline;
+        };
+      }
+    </style>
+    <span>
+      <a href$="[[_computeOwnerLink(account)]]">
+        <gr-account-label account="[[account]]"
+            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+      </a>
+    </span>
+  </template>
+  <script src="gr-account-link.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
new file mode 100644
index 0000000..058b27d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-link',
+
+    properties: {
+      account: Object,
+      avatarImageSize: {
+        type: Number,
+        value: 32,
+      },
+    },
+
+    _computeOwnerLink: function(account) {
+      if (!account) { return; }
+      var accountID = account.email || account._account_id;
+      return '/q/owner:' + encodeURIComponent(accountID) + '+status:open';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
new file mode 100644
index 0000000..2b5b831
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-link</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-account-link.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-link></gr-account-link>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-link tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeOwnerLink(
+          {
+            _account_id: 123,
+            email: 'andybons+gerrit@gmail.com'
+          }),
+          '/q/owner:andybons%2Bgerrit%40gmail.com+status:open');
+
+      assert.equal(element._computeOwnerLink({_account_id: 42}),
+          '/q/owner:42+status:open');
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
new file mode 100644
index 0000000..10eadf9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -0,0 +1,67 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+
+<dom-module id="gr-alert">
+  <template>
+    <style>
+      /**
+       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
+       * HOW THEY ARE USED IN THE CODE.
+       */
+      :host([toast]) {
+        background-color: #333;
+        bottom: 1.25rem;
+        border-radius: 3px;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        color: #fff;
+        left: 1.25rem;
+        padding: 1em 1.5em;
+        position: fixed;
+        transform: translateY(5rem);
+        transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
+        z-index: 1000;
+      }
+      :host([shown]) {
+        transform: translateY(0);
+      }
+      .text {
+        display: inline-block;
+        max-width: calc(100vw - 2.5rem);
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: bottom;
+        white-space: nowrap;
+      }
+      .action {
+        color: #a1c2fa;
+        font-weight: bold;
+        margin-left: 1em;
+        text-decoration: none;
+      }
+    </style>
+    <span class="text">[[text]]</span>
+    <gr-button
+        link
+        class="action"
+        hidden$="[[_hideActionButton]]"
+        on-tap="_handleActionTap">[[actionText]]</gr-button>
+  </template>
+  <script src="gr-alert.js"></script>
+</dom-module>
+
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
new file mode 100644
index 0000000..84846fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-alert',
+
+    /**
+     * Fired when the action button is pressed.
+     *
+     * @event action
+     */
+
+    properties: {
+      text: String,
+      actionText: String,
+      shown: {
+        type: Boolean,
+        value: true,
+        readOnly: true,
+        reflectToAttribute: true,
+      },
+      toast: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+
+      _hideActionButton: Boolean,
+      _boundTransitionEndHandler: {
+        type: Function,
+        value: function() { return this._handleTransitionEnd.bind(this); },
+      },
+    },
+
+    attached: function() {
+      this.addEventListener('transitionend', this._boundTransitionEndHandler);
+    },
+
+    detached: function() {
+      this.removeEventListener('transitionend',
+          this._boundTransitionEndHandler);
+    },
+
+    show: function(text, opt_actionText) {
+      this.text = text;
+      this.actionText = opt_actionText;
+      this._hideActionButton = !opt_actionText;
+      document.body.appendChild(this);
+      this._setShown(true);
+    },
+
+    hide: function() {
+      this._setShown(false);
+      if (this._hasZeroTransitionDuration()) {
+        document.body.removeChild(this);
+      }
+    },
+
+    _hasZeroTransitionDuration: function() {
+      var style = window.getComputedStyle(this);
+      // transitionDuration is always given in seconds.
+      var duration = Math.round(parseFloat(style.transitionDuration) * 100);
+      return duration === 0;
+    },
+
+    _handleTransitionEnd: function(e) {
+      if (this.shown) { return; }
+
+      document.body.removeChild(this);
+    },
+
+    _handleActionTap: function(e) {
+      e.preventDefault();
+      this.fire('action', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
new file mode 100644
index 0000000..067ac5b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-alert</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-alert.html">
+
+<script>
+  suite('gr-alert tests', function() {
+    var element;
+
+    setup(function() {
+      element = document.createElement('gr-alert');
+    });
+
+    teardown(function() {
+      if (element.parentNode) {
+        element.parentNode.removeChild(element);
+      }
+    });
+
+    test('show/hide', function() {
+      assert.isNull(element.parentNode);
+      element.show();
+      assert.equal(element.parentNode, document.body);
+      element.customStyle['--gr-alert-transition-duration'] = '0ms';
+      element.updateStyles();
+      element.hide();
+      assert.isNull(element.parentNode);
+    });
+
+    test('action event', function(done) {
+      element.show();
+      element.addEventListener('action', function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('.action'));
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
new file mode 100644
index 0000000..cda2492
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -0,0 +1,78 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-autocomplete">
+  <template>
+    <style>
+      input {
+        font-size: 1em;
+        height: 100%;
+        width: 100%;
+      }
+      input.borderless,
+      input.borderless:focus {
+        border: none;
+        outline: none;
+      }
+      #suggestions {
+        background-color: #fff;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        position: absolute;
+        z-index: 10;
+      }
+      ul {
+        list-style: none;
+      }
+      li {
+        cursor: pointer;
+        padding: .5em .75em;
+      }
+      li.selected {
+        background-color: #eee;
+      }
+    </style>
+    <input
+        id="input"
+        class$="[[_computeClass(borderless)]]"
+        is="iron-input"
+        disabled$="[[disabled]]"
+        bind-value="{{text}}"
+        placeholder="[[placeholder]]"
+        on-keydown="_handleInputKeydown"
+        on-focus="_updateSuggestions"
+        autocomplete="off" />
+    <div
+        id="suggestions"
+        hidden$="[[_computeSuggestionsHidden(_suggestions)]]">
+      <ul>
+        <template is="dom-repeat" items="[[_suggestions]]">
+          <li
+              data-index$="[[index]]"
+              on-tap="_handleSuggestionTap">[[item.name]]</li>
+        </template>
+      </ul>
+    </div>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{_index}}"
+        cursor-target-class="selected"
+        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
+  </template>
+  <script src="gr-autocomplete.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
new file mode 100644
index 0000000..0fc6b07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -0,0 +1,248 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-autocomplete',
+
+    /**
+     * Fired when a value is chosen.
+     *
+     * @event commit
+     */
+
+    /**
+     * Fired when the user cancels.
+     *
+     * @event cancel
+     */
+
+    properties: {
+
+      /**
+       * Query for requesting autocomplete suggestions. The function should
+       * accept the input as a string parameter and return a promise. The
+       * promise should yield an array of suggestion objects with "name" and
+       * "value" properties. The "name" property will be displayed in the
+       * suggestion entry. The "value" property will be emitted if that
+       * suggestion is selected.
+       *
+       * @type {function(String): Promise<Array<Object>>}
+       */
+      query: {
+        type: Function,
+        value: function() {
+          return function() {
+            return Promise.resolve([]);
+          };
+        },
+      },
+
+      /**
+       * The number of characters that must be typed before suggestions are
+       * made.
+       */
+      threshold: {
+        type: Number,
+        value: 1,
+      },
+
+      borderless: Boolean,
+      disabled: Boolean,
+
+      text: {
+        type: String,
+        value: '',
+        observer: '_updateSuggestions',
+        notify: true,
+      },
+
+      placeholder: String,
+
+      clearOnCommit: {
+        type: Boolean,
+        value: false,
+      },
+
+      value: Object,
+
+      /**
+       * Multi mode appends autocompleted entries to the value.
+       * If false, autocompleted entries replace value.
+       */
+      multi: {
+        type: Boolean,
+        value: false,
+      },
+
+      _suggestions: {
+        type: Array,
+        value: function() { return []; },
+      },
+
+      _index: Number,
+
+      _disableSuggestions: {
+        type: Boolean,
+        value: false,
+      },
+
+    },
+
+    attached: function() {
+      this.listen(document.body, 'click', '_handleBodyClick');
+    },
+
+    detached: function() {
+      this.unlisten(document.body, 'click', '_handleBodyClick');
+    },
+
+    get focusStart() {
+      return this.$.input;
+    },
+
+    focus: function() {
+      this.$.input.focus();
+    },
+
+    clear: function() {
+      this.text = '';
+    },
+
+    /**
+     * Set the text of the input without triggering the suggestion dropdown.
+     * @param {String} text The new text for the input.
+     */
+    setText: function(text) {
+      this._disableSuggestions = true;
+      this.text = text;
+      this._disableSuggestions = false;
+    },
+
+    _updateSuggestions: function() {
+      if (!this.text || this._disableSuggestions) { return; }
+      if (this.text.length < this.threshold) {
+        this._suggestions = [];
+        this.value = null;
+        return;
+      }
+      var text = this.text;
+
+      this.query(text).then(function(suggestions) {
+        if (text !== this.text) {
+          // Late response.
+          return;
+        }
+        this._suggestions = suggestions;
+        this.$.cursor.moveToStart();
+        if (this._index === -1) {
+          this.value = null;
+        }
+      }.bind(this));
+    },
+
+    _computeSuggestionsHidden: function(suggestions) {
+      return !suggestions.length;
+    },
+
+    _computeClass: function(borderless) {
+      return borderless ? 'borderless' : '';
+    },
+
+    _getSuggestionElems: function() {
+      Polymer.dom.flush();
+      return this.$.suggestions.querySelectorAll('li');
+    },
+
+    _handleInputKeydown: function(e) {
+      switch (e.keyCode) {
+        case 38: // Up
+          e.preventDefault();
+          this.$.cursor.previous();
+          break;
+        case 40: // Down
+          e.preventDefault();
+          this.$.cursor.next();
+          break;
+        case 27: // Escape
+          e.preventDefault();
+          this._cancel();
+          break;
+        case 9: // Tab
+        case 13: // Enter
+          e.preventDefault();
+          this._commit();
+          this._suggestions = [];
+          break;
+      }
+    },
+
+    _cancel: function() {
+      this._suggestions = [];
+      this.fire('cancel');
+    },
+
+    _updateValue: function(suggestions, index) {
+      if (!suggestions.length || index === -1) { return; }
+      var completed = suggestions[index].value;
+      if (this.multi) {
+        // Append the completed text to the end of the string.
+        var shortStr = this.text.substring(0, this.text.lastIndexOf(' ') + 1);
+        this.value = shortStr + completed;
+      } else {
+        this.value = completed;
+      }
+    },
+
+    _handleBodyClick: function(e) {
+      var eventPath = Polymer.dom(e).path;
+      for (var i = 0; i < eventPath.length; i++) {
+        if (eventPath[i] == this) {
+          return;
+        }
+      }
+      this._suggestions = [];
+    },
+
+    _handleSuggestionTap: function(e) {
+      this.$.cursor.setCursor(e.target);
+      this._commit();
+    },
+
+    _commit: function() {
+      // Allow values that are not in suggestion list iff suggestions are empty.
+      if (this._suggestions.length > 0) {
+        this._updateValue(this._suggestions, this._index);
+      } else {
+        this.value = this.text;
+      }
+
+      var value = this.value;
+
+      // Value and text are mirrors of each other in multi mode.
+      if (this.multi) {
+        this.setText(this.value);
+      } else {
+        if (!this.clearOnCommit && this._suggestions[this._index]) {
+          this.setText(this._suggestions[this._index].name);
+        } else {
+          this.clear();
+        }
+      }
+
+      this.fire('commit', {value: value});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
new file mode 100644
index 0000000..f8b16b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -0,0 +1,245 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-autocomplete.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-autocomplete></gr-autocomplete>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-autocomplete tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('renders', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      assert.isTrue(queryStub.called);
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var suggestions = element.$.suggestions.querySelectorAll('li');
+        assert.equal(suggestions.length, 5);
+
+        for (var i = 0; i < 5; i++) {
+          assert.equal(suggestions[i].textContent, 'blah ' + i);
+        }
+
+        assert.notEqual(element.$.cursor.index, -1);
+
+        done();
+      });
+    });
+
+    test('emits cancel', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([
+          {name: 'blah', value: 123},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var cancelHandler = sinon.spy();
+        element.addEventListener('cancel', cancelHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
+
+        assert.isTrue(cancelHandler.called);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('emits commit and handles cursor movement', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        assert.equal(element.$.cursor.index, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38); // Up
+
+        assert.equal(element.$.cursor.index, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.equal(element.value, 1);
+        assert.isTrue(commitHandler.called);
+        assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('clear-on-commit behavior (off)', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      });
+      element.query = queryStub;
+      element.text = 'blah';
+
+      promise.then(function() {
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(element.text, 'suggestion');
+        done();
+      });
+    });
+
+    test('clear-on-commit behavior (on)', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      });
+      element.query = queryStub;
+      element.text = 'blah';
+      element.clearOnCommit = true;
+
+      promise.then(function() {
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(element.text, '');
+        done();
+      });
+    });
+
+    test('threshold guards the query', function() {
+      var queryStub = sinon.spy(function() {
+        return Promise.resolve([]);
+      });
+      element.query = queryStub;
+
+      element.threshold = 2;
+
+      element.text = 'a';
+
+      assert.isFalse(queryStub.called);
+
+      element.text = 'ab';
+
+      assert.isTrue(queryStub.called);
+    });
+
+    test('_computeClass respects border property', function() {
+      assert.equal(element._computeClass(), '');
+      assert.equal(element._computeClass(false), '');
+      assert.equal(element._computeClass(true), 'borderless');
+    });
+
+    test('undefined or empty text results in no suggestions', function() {
+      sinon.spy(element, '_updateSuggestions');
+      element.text = undefined;
+      assert(element._updateSuggestions.calledOnce);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('multi completes only the last part of the query', function(done) {
+      var promise;
+      var queryStub = sinon.stub()
+          .returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
+      element.query = queryStub;
+      element.text = 'blah blah';
+      element.multi = true;
+
+      promise.then(function() {
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(element.text, 'blah 0');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
new file mode 100644
index 0000000..55655c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
@@ -0,0 +1,33 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-avatar">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+        border-radius: 50%;
+        background-size: cover;
+        background-color: var(--background-color, #f1f2f3);
+      }
+    </style>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-avatar.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
new file mode 100644
index 0000000..38e9924
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-avatar',
+
+    properties: {
+      account: {
+        type: Object,
+        observer: '_accountChanged',
+      },
+      imageSize: {
+        type: Number,
+        value: 16,
+      },
+    },
+
+    created: function() {
+      this.hidden = true;
+    },
+
+    attached: function() {
+      this.$.restAPI.getConfig().then(function(cfg) {
+        var hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+        if (hasAvatars) {
+          this.hidden = false;
+          // src needs to be set if avatar becomes visible
+          this._updateAvatarURL(this.account);
+        }
+      }.bind(this));
+    },
+
+    _accountChanged: function(account) {
+      this._updateAvatarURL(account);
+    },
+
+    _updateAvatarURL: function(account) {
+      if (!this.hidden && account) {
+        var url = this._buildAvatarURL(this.account);
+        if (url) {
+          this.style.backgroundImage = 'url("' + url + '")';
+        }
+      }
+    },
+
+    _buildAvatarURL: function(account) {
+      if (!account) { return ''; }
+      var avatars = account.avatars || [];
+      for (var i = 0; i < avatars.length; i++) {
+        if (avatars[i].height === this.imageSize) {
+          return avatars[i].url;
+        }
+      }
+      return '/accounts/' + account._account_id + '/avatar?s=' + this.imageSize;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
new file mode 100644
index 0000000..ae514ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-avatar</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-avatar.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-avatar></gr-avatar>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-avatar tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('methods', function() {
+      assert.equal(element._buildAvatarURL(
+          {
+            _account_id: 123
+          }),
+          '/accounts/123/avatar?s=16');
+      assert.equal(element._buildAvatarURL(
+          {
+            _account_id: 123,
+            avatars: [
+              {
+                url: 'https://cdn.example.com/s12-p/photo.jpg',
+                height: 12
+              },
+              {
+                url: 'https://cdn.example.com/s16-p/photo.jpg',
+                height: 16
+              },
+              {
+                url: 'https://cdn.example.com/s100-p/photo.jpg',
+                height: 100
+              },
+            ],
+          }),
+          'https://cdn.example.com/s16-p/photo.jpg');
+      assert.equal(element._buildAvatarURL(
+          {
+            _account_id: 123,
+            avatars: [
+              {
+                url: 'https://cdn.example.com/s95-p/photo.jpg',
+                height: 95
+              },
+            ],
+          }),
+          '/accounts/123/avatar?s=16');
+    });
+
+    test('dom for existing account', function() {
+      assert.isTrue(element.hasAttribute('hidden'),
+          'element not hidden initially');
+      element.hidden = false;
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123
+      };
+      assert.isFalse(element.hasAttribute('hidden'), 'element hidden');
+      assert.isTrue(element.style.backgroundImage.indexOf(
+          '/accounts/123/avatar?s=64') > -1);
+    });
+
+    test('dom for non available account', function() {
+      assert.isTrue(element.hasAttribute('hidden'),
+          'element not hidden initially');
+      element.account = undefined;
+      assert.isTrue(element.hasAttribute('hidden'), 'element not hidden');
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
new file mode 100644
index 0000000..c815ffd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -0,0 +1,107 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+
+<dom-module id="gr-button">
+  <template strip-whitespace>
+    <style>
+      :host {
+        background-color: #fff;
+        border: 1px solid #d1d2d3;
+        border-radius: 2px;
+        box-sizing: border-box;
+        color: #333;
+        cursor: pointer;
+        display: inline-block;
+        font-family: var(--font-family);
+        font-size: 13px;
+        font-weight: bold;
+        outline-width: 0;
+        padding: .3em .65em;
+        position: relative;
+        text-align: center;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        -webkit-user-select: none;
+        user-select: none;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      :host([primary]) {
+        background-color: #4d90fe;
+        border-color: #3079ed;
+        color: #fff;
+      }
+      :host([small]) {
+        font-size: 12px;
+      }
+      :host([link]) {
+        background-color: transparent;
+        border: none;
+        color: #00f;
+        font-size: inherit;
+        font-weight: normal;
+        padding: 0;
+        text-decoration: underline;
+      }
+      :host([loading]),
+      :host([disabled]) {
+        background-color: #efefef;
+        color: #aaa;
+      }
+      :host([disabled]) {
+        cursor: default;
+        pointer-events: none;
+      }
+      :host([loading]),
+      :host([loading][disabled]) {
+        cursor: wait;
+      }
+      :host(:focus),
+      :host(:hover) {
+        border-color: #666;
+      }
+      :host(:active) {
+        border-color: #d1d2d3;
+        color: #aaa;
+      }
+      :host([primary]:focus) {
+        border-color: #fff;
+        box-shadow: 0 0 1px #00f;
+      }
+      :host([primary]:hover) {
+        border-color: #00F;
+      }
+      :host([primary]:active) {
+        border-color: #0c2188;
+        box-shadow: none;
+        color: #fff;
+      }
+      :host([primary][loading]),
+      :host([primary][disabled]) {
+        background-color: #7caeff;
+        border-color: transparent;
+        color: #fff;
+      }
+    </style>
+    <content></content>
+  </template>
+  <script src="gr-button.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
new file mode 100644
index 0000000..e109896
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-button',
+
+    properties: {
+      disabled: {
+        type: Boolean,
+        observer: '_disabledChanged',
+        reflectToAttribute: true,
+      },
+      _enabledTabindex: {
+        type: String,
+        value: '0',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.TooltipBehavior,
+    ],
+
+    hostAttributes: {
+      role: 'button',
+      tabindex: '0',
+    },
+
+    _disabledChanged: function(disabled) {
+      if (disabled) {
+        this._enabledTabindex = this.getAttribute('tabindex');
+      }
+      this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
+    },
+
+    _handleKey: function(e) {
+      switch (e.keyCode) {
+        case 32:  // 'spacebar'
+        case 13:  // 'enter'
+          e.preventDefault();
+          this.click();
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
new file mode 100644
index 0000000..05fe10b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -0,0 +1,52 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-change-star">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+        overflow: hidden;
+      }
+      .starButton {
+        background-color: transparent;
+        border-color: transparent;
+        cursor: pointer;
+        font-size: 1.1em;
+        width: 1.2em;
+        height: 1.2em;
+        outline: none;
+      }
+      .starButton svg {
+        fill: #ccc;
+        width: 1em;
+        height: 1em;
+      }
+      .starButton-active svg {
+        fill: #ffac33;
+      }
+    </style>
+    <button class$="[[_computeStarClass(change.starred)]]" on-tap="_handleStarTap">
+      <!-- Public Domain image from the Noun Project: https://thenounproject.com/search/?q=star&i=25969 -->
+      <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M26.439,95.601c-5.608,2.949-9.286,0.276-8.216-5.968l4.5-26.237L3.662,44.816c-4.537-4.423-3.132-8.746,3.137-9.657  l26.343-3.829L44.923,7.46c2.804-5.682,7.35-5.682,10.154,0l11.78,23.87l26.343,3.829c6.27,0.911,7.674,5.234,3.138,9.657  L77.277,63.397l4.501,26.237c1.07,6.244-2.608,8.916-8.216,5.968L50,83.215L26.439,95.601z"></path></svg>
+    </button>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-change-star.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
new file mode 100644
index 0000000..23c56b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-star',
+
+    properties: {
+      change: {
+        type: Object,
+        notify: true,
+      },
+
+      _xhrPromise: Object,  // Used for testing.
+    },
+
+    _computeStarClass: function(starred) {
+      var classes = ['starButton'];
+      if (starred) {
+        classes.push('starButton-active');
+      }
+      return classes.join(' ');
+    },
+
+    _handleStarTap: function() {
+      var newVal = !this.change.starred;
+      this.set('change.starred', newVal);
+      this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
+          newVal);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
new file mode 100644
index 0000000..969f7dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-star</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-star.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-star></gr-change-star>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-star tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        saveChangeStarred: function() { return Promise.resolve({ok: true}); },
+      });
+      element = fixture('basic');
+      element.change = {
+        _number: 2,
+        starred: true,
+      };
+    });
+
+    test('star visibility states', function() {
+      element.set('change.starred', true);
+      assert.isTrue(element.$$('button').classList.contains('starButton'));
+      assert.isTrue(
+          element.$$('button').classList.contains('starButton-active'));
+
+      element.set('change.starred', false);
+      assert.isTrue(element.$$('button').classList.contains('starButton'));
+      assert.isFalse(
+          element.$$('button').classList.contains('starButton-active'));
+    });
+
+    test('starring', function(done) {
+      element.set('change.starred', false);
+      MockInteractions.tap(element.$$('button'));
+
+      element._xhrPromise.then(function(req) {
+        assert.equal(element.change.starred, true);
+        done();
+      });
+    });
+
+    test('unstarring', function(done) {
+      element.set('change.starred', true);
+      MockInteractions.tap(element.$$('button'));
+
+      element._xhrPromise.then(function(req) {
+        assert.equal(element.change.starred, false);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
new file mode 100644
index 0000000..d8fc1df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -0,0 +1,48 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+
+<dom-module id="gr-confirm-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      header {
+        border-bottom: 1px solid #ddd;
+        font-weight: bold;
+      }
+      header,
+      main,
+      footer {
+        padding: .5em .65em;
+      }
+      footer {
+        display: flex;
+        justify-content: space-between;
+      }
+    </style>
+    <header><content select=".header"></content></header>
+    <main><content select=".main"></content></main>
+    <footer>
+      <gr-button primary on-tap="_handleConfirmTap">[[confirmLabel]]</gr-button>
+      <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
+    </footer>
+  </template>
+  <script src="gr-confirm-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
new file mode 100644
index 0000000..0f20e0a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      confirmLabel: {
+        type: String,
+        value: 'Confirm',
+      }
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
new file mode 100644
index 0000000..812f32a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-dialog></gr-confirm-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('events', function(done) {
+      var numEvents = 0;
+      function handler() { if (++numEvents == 2) { done(); } }
+
+      element.addEventListener('confirm', handler);
+      element.addEventListener('cancel', handler);
+
+      MockInteractions.tap(element.$$('gr-button[primary]'));
+      MockInteractions.tap(element.$$('gr-button:not([primary])'));
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
new file mode 100644
index 0000000..f213312
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
@@ -0,0 +1,22 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-cursor-manager">
+  <template></template>
+  <script src="gr-cursor-manager.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
new file mode 100644
index 0000000..0d3ea3d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -0,0 +1,228 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var ScrollBehavior = {
+    ALWAYS: 'always',
+    NEVER: 'never',
+    KEEP_VISIBLE: 'keep-visible',
+  };
+
+  Polymer({
+    is: 'gr-cursor-manager',
+
+    properties: {
+      stops: {
+        type: Array,
+        value: function() {
+          return [];
+        },
+        observer: '_updateIndex',
+      },
+      target: {
+        type: Object,
+        notify: true,
+        observer: '_scrollToTarget',
+      },
+
+      /**
+       * The index of the current target (if any). -1 otherwise.
+       */
+      index: {
+        type: Number,
+        value: -1,
+        notify: true,
+      },
+
+      /**
+       * The class to apply to the current target. Use null for no class.
+       */
+      cursorTargetClass: {
+        type: String,
+        value: null,
+      },
+
+      /**
+       * The scroll behavior for the cursor. Values are 'never', 'always' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
+      scroll: {
+        type: String,
+        value: ScrollBehavior.NEVER,
+      },
+
+      /**
+       * When using the 'keep-visible' scroll behavior, set an offset to the top
+       * of the window for what is considered above the upper fold.
+       */
+      foldOffsetTop: {
+        type: Number,
+        value: 0,
+      },
+    },
+
+    detached: function() {
+      this.unsetCursor();
+    },
+
+    next: function(opt_condition) {
+      this._moveCursor(1, opt_condition);
+    },
+
+    previous: function(opt_condition) {
+      this._moveCursor(-1, opt_condition);
+    },
+
+    /**
+     * Set the cursor to an arbitrary element.
+     * @param {DOMElement} element
+     */
+    setCursor: function(element) {
+      this.unsetCursor();
+      this.target = element;
+      this._updateIndex();
+      this._decorateTarget();
+    },
+
+    unsetCursor: function() {
+      this._unDecorateTarget();
+      this.index = -1;
+      this.target = null;
+    },
+
+    isAtStart: function() {
+      return this.index === 0;
+    },
+
+    isAtEnd: function() {
+      return this.index === this.stops.length - 1;
+    },
+
+    moveToStart: function() {
+      if (this.stops.length) {
+        this.setCursor(this.stops[0]);
+      }
+    },
+
+    /**
+     * Move the cursor forward or backward by delta. Noop if moving past either
+     * end of the stop list.
+     * @param {Number} delta either -1 or 1.
+     * @param {Function} opt_condition Optional stop condition. If a condition
+     *    is passed the cursor will continue to move in the specified direction
+     *    until the condition is met.
+     * @private
+     */
+    _moveCursor: function(delta, opt_condition) {
+      if (!this.stops.length) {
+        this.unsetCursor();
+        return;
+      }
+
+      this._unDecorateTarget();
+
+      var newIndex = this._getNextindex(delta, opt_condition);
+
+      var newTarget = null;
+      if (newIndex != -1) {
+        newTarget = this.stops[newIndex];
+      }
+
+      this.index = newIndex;
+      this.target = newTarget;
+
+      this._decorateTarget();
+    },
+
+    _decorateTarget: function() {
+      if (this.target && this.cursorTargetClass) {
+        this.target.classList.add(this.cursorTargetClass);
+      }
+    },
+
+    _unDecorateTarget: function() {
+      if (this.target && this.cursorTargetClass) {
+        this.target.classList.remove(this.cursorTargetClass);
+      }
+    },
+
+    /**
+     * Get the next stop index indicated by the delta direction.
+     * @param {Number} delta either -1 or 1.
+     * @param {Function} opt_condition Optional stop condition.
+     * @return {Number} the new index.
+     * @private
+     */
+    _getNextindex: function(delta, opt_condition) {
+      if (!this.stops.length || this.index === -1) {
+        return -1;
+      }
+
+      var newIndex = this.index;
+      do {
+        newIndex = newIndex + delta;
+      } while (newIndex > 0 &&
+               newIndex < this.stops.length - 1 &&
+               opt_condition && !opt_condition(this.stops[newIndex]));
+
+      newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
+
+      // If we failed to satisfy the condition:
+      if (opt_condition && !opt_condition(this.stops[newIndex])) {
+        return this.index;
+      }
+
+      return newIndex;
+    },
+
+    _updateIndex: function() {
+      if (!this.target) {
+        this.index = -1;
+        return;
+      }
+
+      var newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+      if (newIndex === -1) {
+        this.unsetCursor();
+      } else {
+        this.index = newIndex;
+      }
+    },
+
+    _scrollToTarget: function() {
+      if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; }
+
+      // Calculate where the element is relative to the window.
+      var top = this.target.offsetTop;
+      for (var offsetParent = this.target.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+
+      if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
+          top > window.pageYOffset + this.foldOffsetTop &&
+          top < window.pageYOffset + window.innerHeight) { return; }
+
+      // Scroll the element to the middle of the window. Dividing by a third
+      // instead of half the inner height feels a bit better otherwise the
+      // element appears to be below the center of the window even when it
+      // isn't.
+      window.scrollTo(0, top - (window.innerHeight / 3) +
+          (this.target.offsetHeight / 2));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
new file mode 100644
index 0000000..1ad014d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-cursor-manager</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-cursor-manager.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-cursor-manager
+        cursor-stop-selector="li"
+        cursor-target-class="targeted"></gr-cursor-manager>
+    <ul>
+      <li>A</li>
+      <li>B</li>
+      <li>C</li>
+      <li>D</li>
+    </ul>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-cursor-manager tests', function() {
+    var element;
+    var list;
+
+    setup(function() {
+      var fixtureElements = fixture('basic');
+      element = fixtureElements[0];
+      list = fixtureElements[1];
+    });
+
+    test('core cursor functionality', function() {
+      // The element is initialized into the proper state.
+      assert.isArray(element.stops);
+      assert.equal(element.stops.length, 0);
+      assert.equal(element.index, -1);
+      assert.isNotOk(element.target);
+
+      // Initialize the cursor with its stops.
+      element.stops = list.querySelectorAll('li');
+
+      // It should have the stops but it should not be targeting any of them.
+      assert.isNotNull(element.stops);
+      assert.equal(element.stops.length, 4);
+      assert.equal(element.index, -1);
+      assert.isNotOk(element.target);
+
+      // Select the third stop.
+      element.setCursor(list.children[2]);
+
+      // It should update its internal state and update the element's class.
+      assert.equal(element.index, 2);
+      assert.equal(element.target, list.children[2]);
+      assert.isTrue(list.children[2].classList.contains('targeted'));
+      assert.isFalse(element.isAtStart());
+      assert.isFalse(element.isAtEnd());
+
+      // Progress the cursor.
+      element.next();
+
+      // Confirm that the next stop is selected and that the previous stop is
+      // unselected.
+      assert.equal(element.index, 3);
+      assert.equal(element.target, list.children[3]);
+      assert.isTrue(element.isAtEnd());
+      assert.isFalse(list.children[2].classList.contains('targeted'));
+      assert.isTrue(list.children[3].classList.contains('targeted'));
+
+      // Progress the cursor.
+      element.next();
+
+      // We should still be at the end.
+      assert.equal(element.index, 3);
+      assert.equal(element.target, list.children[3]);
+      assert.isTrue(element.isAtEnd());
+
+      // Wind the cursor all the way back to the first stop.
+      element.previous();
+      element.previous();
+      element.previous();
+
+      // The element state should reflect the end of the list.
+      assert.equal(element.index, 0);
+      assert.equal(element.target, list.children[0]);
+      assert.isTrue(element.isAtStart());
+      assert.isTrue(list.children[0].classList.contains('targeted'));
+
+      var newLi = document.createElement('li');
+      newLi.textContent = 'Z';
+      list.insertBefore(newLi, list.children[0]);
+      element.stops = list.querySelectorAll('li');
+
+      assert.equal(element.index, 1);
+
+      // De-select all targets.
+      element.unsetCursor();
+
+      // There should now be no cursor target.
+      assert.isFalse(list.children[1].classList.contains('targeted'));
+      assert.isNotOk(element.target);
+      assert.equal(element.index, -1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
new file mode 100644
index 0000000..3d0cf5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -0,0 +1,34 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+
+<script src="../../../bower_components/moment/moment.js"></script>
+
+<dom-module id="gr-date-formatter">
+  <template>
+    <style>
+      :host {
+        display: inline;
+      }
+    </style>
+    <span title$="[[_computeFullDateStr(dateStr, _timeFormat)]]"
+        >[[_computeDateStr(dateStr, _timeFormat, _relative)]]</span>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-date-formatter.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
new file mode 100644
index 0000000..5b12c8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -0,0 +1,133 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var Duration = {
+    HOUR: 1000 * 60 * 60,
+    DAY: 1000 * 60 * 60 * 24,
+  };
+
+  var TimeFormats = {
+    TIME_12: 'h:mm A', // 2:14 PM
+    TIME_24: 'H:mm', // 14:14
+    MONTH_DAY: 'MMM DD', // Aug 29
+    MONTH_DAY_YEAR: 'MMM DD, YYYY', // Aug 29, 1997
+  };
+
+  Polymer({
+    is: 'gr-date-formatter',
+
+    properties: {
+      dateStr: {
+        type: String,
+        value: null,
+        notify: true,
+      },
+
+      _timeFormat: String, // No default value to prevent flickering.
+      _relative: Boolean, // No default value to prevent flickering.
+    },
+
+    attached: function() {
+      this._loadPreferences();
+    },
+
+    _loadPreferences: function() {
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) {
+          this._timeFormat = TimeFormats.TIME_24;
+          this._relative = false;
+          return;
+        }
+        return Promise.all([
+          this._loadTimeFormat(),
+          this._loadRelative(),
+        ]);
+      }.bind(this));
+    },
+
+    _loadTimeFormat: function() {
+      return this._getPreferences().then(function(preferences) {
+        var timeFormat = preferences && preferences.time_format;
+        switch (timeFormat) {
+          case 'HHMM_12':
+            this._timeFormat = TimeFormats.TIME_12;
+            break;
+          case 'HHMM_24':
+            this._timeFormat = TimeFormats.TIME_24;
+            break;
+          default:
+            throw Error('Invalid time format: ' + timeFormat);
+        }
+      }.bind(this));
+    },
+
+    _loadRelative: function() {
+      return this._getPreferences().then(function(prefs) {
+        // prefs.relative_date_in_change_table is not set when false.
+        this._relative = !!(prefs && prefs.relative_date_in_change_table);
+      }.bind(this));
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _getPreferences: function() {
+      return this.$.restAPI.getPreferences();
+    },
+
+    /**
+     * Return true if date is within 24 hours and on the same day.
+     */
+    _isWithinDay: function(now, date) {
+      var diff = -date.diff(now);
+      return diff < Duration.DAY && date.day() === now.getDay();
+    },
+
+    /**
+     * Returns true if date is from one to six months.
+     */
+    _isWithinHalfYear: function(now, date) {
+      var diff = -date.diff(now);
+      return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
+          diff < 180 * Duration.DAY;
+    },
+
+    _computeDateStr: function(dateStr, timeFormat, relative) {
+      if (!dateStr) { return ''; }
+      var date = moment(util.parseDate(dateStr));
+      if (!date.isValid()) { return ''; }
+      if (relative) {
+        return date.fromNow();
+      }
+      var now = new Date();
+      var format = TimeFormats.MONTH_DAY_YEAR;
+      if (this._isWithinDay(now, date)) {
+        format = timeFormat;
+      } else if (this._isWithinHalfYear(now, date)) {
+        format = TimeFormats.MONTH_DAY;
+      }
+      return date.format(format);
+    },
+
+    _computeFullDateStr: function(dateStr, timeFormat) {
+      if (!dateStr) { return ''; }
+      var date = moment(util.parseDate(dateStr));
+      if (!date.isValid()) { return ''; }
+      return date.format(TimeFormats.MONTH_DAY_YEAR + ', ' + timeFormat);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
new file mode 100644
index 0000000..d1886e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-date-formatter</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-date-formatter.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-date-formatter tests', function() {
+    var element;
+
+    /**
+     * Parse server-formatter date and normalize into current timezone.
+     */
+    function normalizedDate(dateStr) {
+      var d = util.parseDate(dateStr);
+      d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+      return d;
+    }
+
+    function testDates(nowStr, dateStr, expected, expectedTooltip, done) {
+      // Normalize and convert the date to mimic server response.
+      dateStr = normalizedDate(dateStr)
+          .toJSON().replace('T', ' ').slice(0, -1);
+      var clock = sinon.useFakeTimers(normalizedDate(nowStr).getTime());
+      element.dateStr = dateStr;
+      flush(function() {
+        var span = element.$$('span');
+        assert.equal(span.textContent, expected);
+        assert.equal(span.title, expectedTooltip);
+        clock.restore();
+        done();
+      });
+    }
+
+    function stubRestAPI(preferences) {
+      var loggedInPromise = Promise.resolve(preferences !== null);
+      var preferencesPromise = Promise.resolve(preferences);
+      stub('gr-rest-api-interface', {
+        getLoggedIn: sinon.stub().returns(loggedInPromise),
+        getPreferences: sinon.stub().returns(preferencesPromise),
+      });
+      return Promise.all([loggedInPromise, preferencesPromise]);
+    }
+
+    suite('24 hours time format preference', function() {
+      setup(function(done) {
+        return stubRestAPI(
+          {time_format: 'HHMM_24', relative_date_in_change_table: false}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('invalid dates are quietly rejected', function() {
+        assert.notOk((new Date('foo')).valueOf());
+        assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
+      });
+
+      test('Within 24 hours on same day', function(done) {
+        testDates('2015-07-29 20:34:14.985000000',
+                  '2015-07-29 15:34:14.985000000',
+                  '15:34', 'Jul 29, 2015, 15:34', done);
+      });
+
+      test('Within 24 hours on different days', function(done) {
+        testDates('2015-07-29 03:34:14.985000000',
+                  '2015-07-28 20:25:14.985000000',
+                  'Jul 28', 'Jul 28, 2015, 20:25', done);
+      });
+
+      test('More than 24 hours but less than six months', function(done) {
+        testDates('2015-07-29 20:34:14.985000000',
+                  '2015-06-15 03:25:14.985000000',
+                  'Jun 15', 'Jun 15, 2015, 3:25', done);
+      });
+
+      test('More than six months', function(done) {
+        testDates('2015-09-15 20:34:00.000000000',
+                  '2015-01-15 03:25:00.000000000',
+                  'Jan 15, 2015', 'Jan 15, 2015, 3:25', done);
+      });
+    });
+
+    suite('12 hours time format preference', function() {
+      setup(function(done) {
+        // relative_date_in_change_table is not set when false.
+        return stubRestAPI(
+          {time_format: 'HHMM_12'}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('Within 24 hours on same day', function(done) {
+        testDates('2015-07-29 20:34:14.985000000',
+                  '2015-07-29 15:34:14.985000000',
+                  '3:34 PM', 'Jul 29, 2015, 3:34 PM', done);
+      });
+    });
+
+    suite('relative date preference', function() {
+      setup(function(done) {
+        return stubRestAPI(
+          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('Within 24 hours on same day', function(done) {
+        testDates('2015-07-29 20:34:14.985000000',
+                  '2015-07-29 15:34:14.985000000',
+                  '5 hours ago', 'Jul 29, 2015, 3:34 PM', done);
+      });
+
+      test('More than six months', function(done) {
+        testDates('2015-09-15 20:34:00.000000000',
+                  '2015-01-15 03:25:00.000000000',
+                  '8 months ago', 'Jan 15, 2015, 3:25 AM', done);
+      });
+    });
+
+    suite('logged in', function() {
+      setup(function(done) {
+        return stubRestAPI(
+          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+        ).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('Preferences are respected', function() {
+        assert.equal(element._timeFormat, 'h:mm A');
+        assert.isTrue(element._relative);
+      });
+    });
+
+    suite('logged out', function() {
+      setup(function(done) {
+        return stubRestAPI(null).then(function() {
+          element = fixture('basic');
+          element._loadPreferences().then(function() { done(); });
+        });
+      });
+
+      test('Default preferences are respected', function() {
+        assert.equal(element._timeFormat, 'H:mm');
+        assert.isFalse(element._relative);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
new file mode 100644
index 0000000..5b49dcc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -0,0 +1,59 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+
+<dom-module id="gr-editable-content">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([disabled]) iron-autogrow-textarea {
+        opacity: .5;
+      }
+      iron-autogrow-textarea {
+        width: 100%;
+
+        --iron-autogrow-textarea: {
+          white-space: pre;
+        };
+      }
+      .editButtons {
+        display: flex;
+        justify-content: space-between;
+      }
+    </style>
+    <div hidden$="[[editing]]">
+      <content></content>
+    </div>
+    <div class="editor" hidden$="[[!editing]]">
+      <iron-autogrow-textarea
+          bind-value="{{_newContent}}"
+          disabled="[[disabled]]"></iron-autogrow-textarea>
+      <div class="editButtons">
+        <gr-button primary
+            on-tap="_handleSave"
+            disabled="[[_saveDisabled]]">Save</gr-button>
+        <gr-button
+            on-tap="_handleCancel"
+            disabled="[[disabled]]">Cancel</gr-button>
+      </div>
+    </div>
+  </template>
+  <script src="gr-editable-content.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
new file mode 100644
index 0000000..77e2272
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -0,0 +1,79 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-editable-content',
+
+    /**
+     * Fired when the save button is pressed.
+     *
+     * @event editable-content-save
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event editable-content-cancel
+     */
+
+    properties: {
+      content: {
+        notify: true,
+        type: String,
+      },
+      disabled: {
+        reflectToAttribute: true,
+        type: Boolean,
+        value: false,
+      },
+      editing: {
+        observer: '_editingChanged',
+        type: Boolean,
+        value: false,
+      },
+      _saveDisabled: {
+        computed: '_computeSaveDisabled(disabled, content, _newContent)',
+        type: Boolean,
+        value: true,
+      },
+      _newContent: String,
+    },
+
+    focusTextarea: function() {
+      this.$$('iron-autogrow-textarea').textarea.focus();
+    },
+
+    _editingChanged: function(editing) {
+      if (!editing) { return; }
+      this._newContent = this.content;
+    },
+
+    _computeSaveDisabled: function(disabled, content, newContent) {
+      return disabled || (content === newContent);
+    },
+
+    _handleSave: function(e) {
+      e.preventDefault();
+      this.fire('editable-content-save', {content: this._newContent});
+    },
+
+    _handleCancel: function(e) {
+      e.preventDefault();
+      this.editing = false;
+      this.fire('editable-content-cancel');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
new file mode 100644
index 0000000..999f171
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-editable-content</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+
+<link rel="import" href="gr-editable-content.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-editable-content></gr-editable-content>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-editable-content tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('save event', function(done) {
+      element._newContent = 'foo';
+      element.addEventListener('editable-content-save', function(e) {
+        assert.equal(e.detail.content, 'foo');
+        done();
+      });
+      MockInteractions.tap(element.$$('gr-button[primary]'));
+    });
+
+    test('cancel event', function(done) {
+      element.addEventListener('editable-content-cancel', function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('gr-button:not([primary])'));
+    });
+
+    test('enabling editing updates edit field contents', function() {
+      element.content = 'current content';
+      element._newContent = 'stale content';
+      element.editing = true;
+      assert.equal(element._newContent, 'current content');
+    });
+
+    test('disabling editing does not update edit field contents', function() {
+      element.content = 'current content';
+      element.editing = true;
+      element._newContent = 'stale content';
+      element.editing = false;
+      assert.equal(element._newContent, 'stale content');
+    });
+
+    suite('editing', function() {
+      setup(function() {
+        element.content = 'current content';
+        element.editing = true;
+      });
+
+      test('save button is disabled initially', function() {
+        assert.isTrue(element.$$('gr-button[primary]').disabled);
+      });
+
+      test('save button is enabled when content changes', function() {
+        element._newContent = 'new content';
+        assert.isFalse(element.$$('gr-button[primary]').disabled);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
new file mode 100644
index 0000000..76a9c77
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -0,0 +1,53 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<dom-module id="gr-editable-label">
+  <template>
+    <style>
+      input {
+        font: inherit;
+        max-width: 8em;
+      }
+      label {
+        color: #777;
+        display: inline-block;
+        max-width: 8em;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      label.editable {
+        cursor: pointer;
+      }
+      label.editable.placeholder {
+        color: #00f;
+        text-decoration: underline;
+      }
+    </style>
+    <input
+        is="iron-input"
+        id="input"
+        hidden$="[[!editing]]"
+        on-keydown="_handleInputKeydown"
+        bind-value="{{_inputText}}">
+    <label
+        hidden$="[[editing]]"
+        class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+        on-tap="_open">[[_computeLabel(value, placeholder)]]</label>
+  </template>
+  <script src="gr-editable-label.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
new file mode 100644
index 0000000..eb604f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-editable-label',
+
+    /**
+     * Fired when the value is changed.
+     *
+     * @event changed
+     */
+
+    properties: {
+      editing: {
+        type: Boolean,
+        value: false,
+      },
+      value: {
+        type: String,
+        notify: true,
+        value: null,
+        observer: '_updateTitle',
+      },
+      placeholder: {
+        type: String,
+        value: null,
+      },
+      readOnly: {
+        type: Boolean,
+        value: false,
+      },
+      _inputText: String,
+    },
+
+    _usePlaceholder: function(value, placeholder) {
+      return (!value || !value.length) && placeholder;
+    },
+
+    _computeLabel: function(value, placeholder) {
+      if (this._usePlaceholder(value, placeholder)) {
+        return placeholder;
+      }
+      return value;
+    },
+
+    _open: function() {
+      if (this.readOnly || this.editing) { return; }
+
+      this._inputText = this.value;
+      this.editing = true;
+
+      this.async(function() {
+        this.$.input.focus();
+        this.$.input.setSelectionRange(0, this.$.input.value.length);
+      });
+    },
+
+    _save: function() {
+      if (!this.editing) { return; }
+
+      this.value = this._inputText;
+      this.editing = false;
+      this.fire('changed', this.value);
+    },
+
+    _cancel: function() {
+      if (!this.editing) { return; }
+
+      this.editing = false;
+      this._inputText = this.value;
+    },
+
+    _handleInputKeydown: function(e) {
+      if (e.keyCode === 13) {  // Enter key
+        e.preventDefault();
+        this._save();
+      } else if (e.keyCode === 27) { // Escape key
+        e.preventDefault();
+        this._cancel();
+      }
+    },
+
+    _computeLabelClass: function(readOnly, value, placeholder) {
+      var classes = [];
+      if (!readOnly) { classes.push('editable'); }
+      if (this._usePlaceholder(value, placeholder)) {
+        classes.push('placeholder');
+      }
+      return classes.join(' ');
+    },
+
+    _updateTitle: function(value) {
+      this.setAttribute('title', (value && value.length) ? value : null);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
new file mode 100644
index 0000000..a87e5f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -0,0 +1,130 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-editable-label</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+
+<link rel="import" href="gr-editable-label.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-editable-label
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+  </template>
+</test-fixture>
+
+<test-fixture id="read-only">
+  <template>
+    <gr-editable-label
+        read-only
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-editable-label tests', function() {
+    var element;
+    var input;
+    var label;
+
+    setup(function() {
+      element = fixture('basic');
+
+      input = element.$$('input');
+      label = element.$$('label');
+    });
+
+    test('element render', function() {
+      // The input is hidden and the label is visible:
+      assert.isNotNull(input.getAttribute('hidden'));
+      assert.isNull(label.getAttribute('hidden'));
+
+      assert.isTrue(label.classList.contains('editable'));
+
+      assert.equal(label.textContent, 'value text');
+
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      // The input is visible and the label is hidden:
+      assert.isNull(input.getAttribute('hidden'));
+      assert.isNotNull(label.getAttribute('hidden'));
+
+      assert.equal(input.value, 'value text');
+    });
+
+    test('edit value', function(done) {
+      var editedStub = sinon.stub();
+      element.addEventListener('changed', editedStub);
+
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      element._inputText = 'new text';
+
+      assert.isFalse(editedStub.called);
+
+      element.async(function() {
+        assert.isTrue(editedStub.called);
+        assert.equal(input.value, 'new text');
+        done();
+      });
+
+      // Press enter:
+      MockInteractions.keyDownOn(input, 13);
+    });
+  });
+
+  suite('gr-editable-label read-only tests', function() {
+    var element;
+    var input;
+    var label;
+
+    setup(function() {
+      element = fixture('read-only');
+
+      input = element.$$('input');
+      label = element.$$('label');
+    });
+
+    test('disallows edit when read-only', function() {
+      // The input is hidden and the label is visible:
+      assert.isNotNull(input.getAttribute('hidden'));
+      assert.isNull(label.getAttribute('hidden'));
+
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      // The input is still hidden and the label is still visible:
+      assert.isNotNull(input.getAttribute('hidden'));
+      assert.isNull(label.getAttribute('hidden'));
+    });
+
+    test('label is not marked as editable', function() {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
new file mode 100644
index 0000000..f7c337b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  function GrChangeActionsInterface(el) {
+    this._el = el;
+    this.RevisionActions = el.RevisionActions;
+    this.ChangeActions = el.ChangeActions;
+    this.ActionType = el.ActionType;
+  }
+
+  GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
+    if (this._el.primaryActionKeys.indexOf(key) !== -1) { return; }
+
+    this._el.push('primaryActionKeys', key);
+  };
+
+  GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
+    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(function(k) {
+      return k !== key;
+    });
+  };
+
+  GrChangeActionsInterface.prototype.add = function(type, label) {
+    return this._el.addActionButton(type, label);
+  };
+
+  GrChangeActionsInterface.prototype.remove = function(key) {
+    return this._el.removeActionButton(key);
+  };
+
+  GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
+    this._el.addEventListener(key + '-tap', handler);
+  };
+
+  GrChangeActionsInterface.prototype.removeTapListener = function(key,
+      handler) {
+    this._el.removeEventListener(key + '-tap', handler);
+  };
+
+  GrChangeActionsInterface.prototype.setLabel = function(key, text) {
+    this._el.setActionButtonProp(key, 'label', text);
+  };
+
+  GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
+    this._el.setActionButtonProp(key, 'enabled', enabled);
+  };
+
+  window.GrChangeActionsInterface = GrChangeActionsInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
new file mode 100644
index 0000000..4919a5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-actions-js-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<!--
+This must refer to the element this interface is wrapping around. Otherwise
+breaking changes to gr-change-actions won’t be noticed.
+-->
+<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-actions></gr-change-actions>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-js-api-interface tests', function() {
+    var element;
+    var changeActions;
+
+    // Because deepEqual doesn’t behave in Safari.
+    function assertArraysEqual(actual, expected) {
+      assert.equal(actual.length, expected.length);
+      for (var i = 0; i < actual.length; i++) {
+        assert.equal(actual[i], expected[i]);
+      }
+    }
+
+    setup(function() {
+      element = fixture('basic');
+      var plugin;
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeActions = plugin.changeActions();
+    });
+
+    teardown(function() {
+      changeActions = null;
+    });
+
+    test('property existence', function() {
+      [
+        'ActionType',
+        'ChangeActions',
+        'RevisionActions',
+      ].forEach(function(p) {
+        assertArraysEqual(changeActions[p], element[p]);
+      });
+    });
+
+    test('add/remove primary action keys', function() {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      var handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      flush(function() {
+        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.removeTapListener(key, handler);
+        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.remove(key);
+        flush(function() {
+          assert.isNull(element.$$('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+    });
+
+    test('action button properties', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        var button = element.$$('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.equal(button.getAttribute('data-label'), 'Bork!');
+        assert.isFalse(button.disabled);
+        changeActions.setLabel(key, 'Yo');
+        changeActions.setEnabled(key, false);
+        flush(function() {
+          assert.equal(button.getAttribute('data-label'), 'Yo');
+          assert.isTrue(button.disabled);
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
new file mode 100644
index 0000000..9d6b83b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  function GrChangeReplyInterface(el) {
+    this._el = el;
+  }
+
+  GrChangeReplyInterface.prototype.setLabelValue = function(label, value) {
+    this._el.setLabelValue(label, value);
+  };
+
+  GrChangeReplyInterface.prototype.send = function() {
+    return this._el.send();
+  };
+
+  window.GrChangeReplyInterface = GrChangeReplyInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
new file mode 100644
index 0000000..2e5aa56
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-reply-js-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<!--
+This must refer to the element this interface is wrapping around. Otherwise
+breaking changes to gr-reply-dialog won’t be noticed.
+-->
+<link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dialog></gr-reply-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-reply-js-api tests', function() {
+    var element;
+    var sandbox;
+    var changeReply;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(null); },
+      });
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      var plugin;
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+    });
+
+    teardown(function() {
+      changeReply = null;
+      sandbox.restore();
+    });
+
+    test('calls', function() {
+      var setLabelValueStub = sinon.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
+
+      var sendStub = sinon.stub(element, 'send');
+      changeReply.send();
+      assert(sendStub.calledWithExactly());
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
new file mode 100644
index 0000000..1967b80
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -0,0 +1,26 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-js-api-interface">
+  <template></template>
+  <script src="gr-change-actions-js-api.js"></script>
+  <script src="gr-change-reply-js-api.js"></script>
+  <script src="gr-js-api-interface.js"></script>
+  <script src="gr-public-js-api.js"></script>
+</dom-module>
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
new file mode 100644
index 0000000..4dfcf48
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var EventType = {
+    HISTORY: 'history',
+    LABEL_CHANGE: 'labelchange',
+    SHOW_CHANGE: 'showchange',
+    SUBMIT_CHANGE: 'submitchange',
+    COMMENT: 'comment',
+    REVERT: 'revert',
+  };
+
+  var Element = {
+    CHANGE_ACTIONS: 'changeactions',
+    REPLY_DIALOG: 'replydialog',
+  };
+
+  Polymer({
+    is: 'gr-js-api-interface',
+
+    properties: {
+      _elements: {
+        type: Object,
+        value: {},  // Shared across all instances.
+      },
+      _eventCallbacks: {
+        type: Object,
+        value: {},  // Shared across all instances.
+      },
+    },
+
+    Element: Element,
+    EventType: EventType,
+
+    handleEvent: function(type, detail) {
+      switch (type) {
+        case EventType.HISTORY:
+          this._handleHistory(detail);
+          break;
+        case EventType.SHOW_CHANGE:
+          this._handleShowChange(detail);
+          break;
+        case EventType.COMMENT:
+          this._handleComment(detail);
+          break;
+        case EventType.LABEL_CHANGE:
+          this._handleLabelChange(detail);
+          break;
+        default:
+          console.warn('handleEvent called with unsupported event type:', type);
+          break;
+      }
+    },
+
+    addElement: function(key, el) {
+      this._elements[key] = el;
+    },
+
+    getElement: function(key) {
+      return this._elements[key];
+    },
+
+    addEventCallback: function(eventName, callback) {
+      if (!this._eventCallbacks[eventName]) {
+        this._eventCallbacks[eventName] = [];
+      }
+      this._eventCallbacks[eventName].push(callback);
+    },
+
+    canSubmitChange: function() {
+      var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
+      var cancelSubmit = submitCallbacks.some(function(callback) {
+        try {
+          return callback() === false;
+        } catch (err) {
+          console.error(err);
+        }
+        return false;
+      });
+
+      return !cancelSubmit;
+    },
+
+    _removeEventCallbacks: function() {
+      for (var k in EventType) {
+        this._eventCallbacks[EventType[k]] = [];
+      }
+    },
+
+    _handleHistory: function(detail) {
+      this._getEventCallbacks(EventType.HISTORY).forEach(function(cb) {
+        try {
+          cb(detail.path);
+        } catch (err) {
+          console.error(err);
+        }
+      });
+    },
+
+    _handleShowChange: function(detail) {
+      this._getEventCallbacks(EventType.SHOW_CHANGE).forEach(function(cb) {
+        var change = detail.change;
+        var patchNum = detail.patchNum;
+        var revision;
+        for (var rev in change.revisions) {
+          if (change.revisions[rev]._number == patchNum) {
+            revision = change.revisions[rev];
+            break;
+          }
+        }
+        try {
+          cb(change, revision);
+        } catch (err) {
+          console.error(err);
+        }
+      });
+    },
+
+    _handleComment: function(detail) {
+      this._getEventCallbacks(EventType.COMMENT).forEach(function(cb) {
+        try {
+          cb(detail.node);
+        } catch (err) {
+          console.error(err);
+        }
+      });
+    },
+
+    _handleLabelChange: function(detail) {
+      this._getEventCallbacks(EventType.LABEL_CHANGE).forEach(function(cb) {
+        try {
+          cb(detail.change);
+        } catch (err) {
+          console.error(err);
+        }
+      });
+    },
+
+    modifyRevertMsg: function(change, msg) {
+      this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
+        try {
+          msg = callback(change, msg);
+        } catch (err) {
+          console.error(err);
+        }
+      });
+      return msg;
+    },
+
+    _getEventCallbacks: function(type) {
+      return this._eventCallbacks[type] || [];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
new file mode 100644
index 0000000..46a555a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-api-interface</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="gr-js-api-interface.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-js-api-interface></gr-js-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-js-api-interface tests', function() {
+    var element;
+    var plugin;
+    var errorStub;
+    var throwErrFn = function() {
+      throw Error('Unfortunately, this handler has stopped');
+    };
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getAccount: function() {
+          return Promise.resolve({name: 'Judy Hopps'});
+        },
+      });
+      element = fixture('basic');
+      errorStub = sinon.stub(console, 'error');
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+    });
+
+    teardown(function() {
+      element._removeEventCallbacks();
+      plugin = null;
+      errorStub.restore();
+    });
+
+    test('url', function() {
+      assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+      assert.equal(plugin.url('/static/test.js'),
+          'http://test.com/plugins/testplugin/static/test.js');
+    });
+
+    test('history event', function(done) {
+      plugin.on(element.EventType.HISTORY, throwErrFn);
+      plugin.on(element.EventType.HISTORY, function(path) {
+        assert.equal(path, '/path/to/awesomesauce');
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.HISTORY,
+          {path: '/path/to/awesomesauce'});
+    });
+
+    test('showchange event', function(done) {
+      var testChange = {
+        _number: 42,
+        revisions: {
+          def: {_number: 2},
+          abc: {_number: 1},
+        },
+      };
+      plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
+      plugin.on(element.EventType.SHOW_CHANGE, function(change, revision) {
+        assert.deepEqual(change, testChange);
+        assert.deepEqual(revision, testChange.revisions.abc);
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.SHOW_CHANGE,
+          {change: testChange, patchNum: 1});
+    });
+
+    test('comment event', function(done) {
+      var testCommentNode = {foo: 'bar'};
+      plugin.on(element.EventType.COMMENT, throwErrFn);
+      plugin.on(element.EventType.COMMENT, function(commentNode) {
+        assert.deepEqual(commentNode, testCommentNode);
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
+    });
+
+    test('revert event', function(done) {
+      function appendToRevertMsg(c, msg) {
+        return msg + '\ninfo';
+      }
+      done();
+
+      assert.equal(element.modifyRevertMsg(null, 'test'), 'test');
+      assert.equal(errorStub.callCount, 0);
+
+      plugin.on(element.EventType.REVERT, throwErrFn);
+      plugin.on(element.EventType.REVERT, appendToRevertMsg);
+      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo');
+      assert.isTrue(errorStub.calledOnce);
+
+      plugin.on(element.EventType.REVERT, appendToRevertMsg);
+      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo\ninfo');
+      assert.isTrue(errorStub.calledTwice);
+    });
+
+    test('labelchange event', function(done) {
+      var testChange = {_number: 42};
+      plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
+      plugin.on(element.EventType.LABEL_CHANGE, function(change) {
+        assert.deepEqual(change, testChange);
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
+    });
+
+    test('submitchange', function() {
+      plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
+      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      assert.isTrue(element.canSubmitChange());
+      assert.isTrue(errorStub.calledOnce);
+      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return false; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      assert.isFalse(element.canSubmitChange());
+      assert.isTrue(errorStub.calledTwice);
+    });
+
+    test('versioning', function() {
+      var callback = sinon.spy();
+      Gerrit.install(callback, '0.0pre-alpha');
+      assert(callback.notCalled);
+    });
+
+    test('getAccount', function(done) {
+      Gerrit.getLoggedIn().then(function(loggedIn) {
+        assert.isTrue(loggedIn);
+        done();
+      });
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
new file mode 100644
index 0000000..21d76f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  var API_VERSION = '0.1';
+
+  // GWT JSNI uses $wnd to refer to window.
+  // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
+  window.$wnd = window;
+
+  function Plugin(opt_url) {
+    if (!opt_url) {
+      console.warn('Plugin not being loaded from /plugins base path.',
+          'Unable to determine name.');
+      return;
+    }
+
+    this._url = new URL(opt_url);
+    if (this._url.pathname.indexOf('/plugins') !== 0) {
+      console.warn('Plugin not being loaded from /plugins base path:',
+          this._url.href, '— Unable to determine name.');
+      return;
+    }
+    this._name = this._url.pathname.split('/')[2];
+  }
+
+  Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
+
+  Plugin.prototype._name = '';
+
+  Plugin.prototype.getPluginName = function() {
+    return this._name;
+  };
+
+  Plugin.prototype.on = function(eventName, callback) {
+    Plugin._sharedAPIElement.addEventCallback(eventName, callback);
+  };
+
+  Plugin.prototype.url = function(opt_path) {
+    return this._url.origin + '/plugins/' + this._name + (opt_path || '/');
+  };
+
+  Plugin.prototype.changeActions = function() {
+    return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
+        Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
+  };
+
+  Plugin.prototype.changeReply = function() {
+    return new GrChangeReplyInterface(Plugin._sharedAPIElement.getElement(
+        Plugin._sharedAPIElement.Element.REPLY_DIALOG));
+  };
+
+  var Gerrit = window.Gerrit || {};
+
+  Gerrit.getPluginName = function() {
+    console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
+        'Please use self.getPluginName() instead.');
+  };
+
+  Gerrit.css = function(rulesStr) {
+    if (!Gerrit._customStyleSheet) {
+      var styleEl = document.createElement('style');
+      document.head.appendChild(styleEl);
+      Gerrit._customStyleSheet = styleEl.sheet;
+    }
+
+    var name = '__pg_js_api_class_' + Gerrit._customStyleSheet.cssRules.length;
+    Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
+    return name;
+  };
+
+  Gerrit.install = function(callback, opt_version, opt_src) {
+    if (opt_version && opt_version !== API_VERSION) {
+      console.warn('Only version ' + API_VERSION +
+          ' is supported in PolyGerrit. ' + opt_version + ' was given.');
+      return;
+    }
+
+    // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
+    var src = opt_src || (document.currentScript && document.currentScript.src);
+    callback(new Plugin(src));
+  };
+
+  Gerrit.getLoggedIn = function() {
+    return document.createElement('gr-rest-api-interface').getLoggedIn();
+  };
+
+  Gerrit.installGwt = function() {
+    // NOOP since PolyGerrit doesn’t support GWT plugins.
+  };
+
+  window.Gerrit = Gerrit;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
new file mode 100644
index 0000000..04b12e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
+<dom-module id="gr-label">
+  <template strip-whitespace>
+    <content></content>
+  </template>
+  <script src="gr-label.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
new file mode 100644
index 0000000..37e1f77
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-label',
+
+    behaviors: [
+      Gerrit.TooltipBehavior,
+    ],
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/ba-linkify.js b/polygerrit-ui/app/elements/shared/gr-linked-text/ba-linkify.js
new file mode 100644
index 0000000..26dacd6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/ba-linkify.js
@@ -0,0 +1,191 @@
+/*!
+ * JavaScript Linkify - v0.3 - 6/27/2009
+ * http://benalman.com/projects/javascript-linkify/
+ *
+ * Copyright (c) 2009 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ *
+ * Some regexps adapted from http://userscripts.org/scripts/review/7122
+ */
+
+// Script: JavaScript Linkify: Process links in text!
+//
+// *Version: 0.3, Last updated: 6/27/2009*
+//
+// Project Home - http://benalman.com/projects/javascript-linkify/
+// GitHub       - http://github.com/cowboy/javascript-linkify/
+// Source       - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.js
+// (Minified)   - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.min.js (2.8kb)
+//
+// About: License
+//
+// Copyright (c) 2009 "Cowboy" Ben Alman,
+// Dual licensed under the MIT and GPL licenses.
+// http://benalman.com/about/license/
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+
+window.linkify = (function(){
+  var
+  SCHEME = "[a-z\\d.-]+://",
+  IPV4 = "(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])",
+  HOSTNAME = "(?:(?:[^\\s!@#$%^&*()_=+[\\]{}\\\\|;:'\",.<>/?]+)\\.)+",
+  TLD = "(?:ac|ad|aero|ae|af|ag|ai|al|am|an|ao|aq|arpa|ar|asia|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|biz|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|cat|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|coop|com|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|info|int|in|io|iq|ir|is|it|je|jm|jobs|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mobi|mo|mp|mq|mr|ms|mt|museum|mu|mv|mw|mx|my|mz|name|na|nc|net|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pro|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|travel|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)",
+  HOST_OR_IP = "(?:" + HOSTNAME + TLD + "|" + IPV4 + ")",
+  PATH = "(?:[;/][^#?<>\\s]*)?",
+  QUERY_FRAG = "(?:\\?[^#<>\\s]*)?(?:#[^<>\\s]*)?",
+  URI1 = "\\b" + SCHEME + "[^<>\\s]+",
+  URI2 = "\\b" + HOST_OR_IP + PATH + QUERY_FRAG + "(?!\\w)",
+
+  MAILTO = "mailto:",
+  EMAIL = "(?:" + MAILTO + ")?[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" + HOST_OR_IP + QUERY_FRAG + "(?!\\w)",
+
+  URI_RE = new RegExp( "(?:" + URI1 + "|" + URI2 + "|" + EMAIL + ")", "ig" ),
+  SCHEME_RE = new RegExp( "^" + SCHEME, "i" ),
+
+  quotes = {
+    "'": "`",
+    '>': '<',
+    ')': '(',
+    ']': '[',
+    '}': '{',
+    '»': '«',
+    '›': '‹'
+  },
+
+  default_options = {
+    callback: function( text, href ) {
+      return href ? '<a href="' + href + '" title="' + href + '">' + text + '</a>' : text;
+    },
+    punct_regexp: /(?:[!?.,:;'"]|(?:&|&amp;)(?:lt|gt|quot|apos|raquo|laquo|rsaquo|lsaquo);)$/
+  };
+
+  return function( txt, options ) {
+    options = options || {};
+
+    // Temp variables.
+    var arr,
+    i,
+    link,
+    href,
+
+      // Output HTML.
+      html = '',
+
+      // Store text / link parts, in order, for re-combination.
+      parts = [],
+
+      // Used for keeping track of indices in the text.
+      idx_prev,
+      idx_last,
+      idx,
+      link_last,
+
+      // Used for trimming trailing punctuation and quotes from links.
+      matches_begin,
+      matches_end,
+      quote_begin,
+      quote_end;
+
+    // Initialize options.
+    for ( i in default_options ) {
+      if ( options[ i ] === undefined ) {
+        options[ i ] = default_options[ i ];
+      }
+    }
+
+    // Find links.
+    while ( arr = URI_RE.exec( txt ) ) {
+
+      link = arr[0];
+      idx_last = URI_RE.lastIndex;
+      idx = idx_last - link.length;
+
+      // Not a link if preceded by certain characters.
+      if ( /[\/:]/.test( txt.charAt( idx - 1 ) ) ) {
+        continue;
+      }
+
+      // Trim trailing punctuation.
+      do {
+        // If no changes are made, we don't want to loop forever!
+        link_last = link;
+
+        quote_end = link.substr( -1 )
+        quote_begin = quotes[ quote_end ];
+
+        // Ending quote character?
+        if ( quote_begin ) {
+          matches_begin = link.match( new RegExp( '\\' + quote_begin + '(?!$)', 'g' ) );
+          matches_end = link.match( new RegExp( '\\' + quote_end, 'g' ) );
+
+          // If quotes are unbalanced, remove trailing quote character.
+          if ( ( matches_begin ? matches_begin.length : 0 ) < ( matches_end ? matches_end.length : 0 ) ) {
+            link = link.substr( 0, link.length - 1 );
+            idx_last--;
+          }
+        }
+
+        // Ending non-quote punctuation character?
+        if ( options.punct_regexp ) {
+          link = link.replace( options.punct_regexp, function(a){
+            idx_last -= a.length;
+            return '';
+          });
+        }
+      } while ( link.length && link !== link_last );
+
+      href = link;
+
+      // Add appropriate protocol to naked links.
+      if ( !SCHEME_RE.test( href ) ) {
+        href = ( href.indexOf( '@' ) !== -1 ? ( !href.indexOf( MAILTO ) ? '' : MAILTO )
+          : !href.indexOf( 'irc.' ) ? 'irc://'
+          : !href.indexOf( 'ftp.' ) ? 'ftp://'
+          : 'http://' )
+        + href;
+      }
+
+      // Push preceding non-link text onto the array.
+      if ( idx_prev != idx ) {
+        parts.push([ txt.slice( idx_prev, idx ) ]);
+        idx_prev = idx_last;
+      }
+
+      // Push massaged link onto the array
+      parts.push([ link, href ]);
+    };
+
+    // Push remaining non-link text onto the array.
+    parts.push([ txt.substr( idx_prev ) ]);
+
+    // Process the array items.
+    for ( i = 0; i < parts.length; i++ ) {
+      html += options.callback.apply( window, parts[i] );
+    }
+
+    // In case of catastrophic failure, return the original text;
+    return html || txt;
+  };
+
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
new file mode 100644
index 0000000..79db969
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -0,0 +1,39 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<script src="ba-linkify.js"></script>
+<script src="link-text-parser.js"></script>
+<dom-module id="gr-linked-text">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([pre]) span {
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
+      :host([disabled]) a {
+        color: inherit;
+        text-decoration: none;
+        pointer-events: none;
+      }
+    </style>
+    <span id="output"></span>
+  </template>
+  <script src="gr-linked-text.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
new file mode 100644
index 0000000..cb852fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -0,0 +1,76 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-linked-text',
+
+    properties: {
+      content: {
+        type: String,
+        observer: '_contentChanged',
+      },
+      pre: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      config: Object,
+    },
+
+    observers: [
+      '_contentOrConfigChanged(content, config)',
+    ],
+
+    _contentChanged: function(content) {
+      // In the case where the config may not be set (perhaps due to the
+      // request for it still being in flight), set the content anyway to
+      // prevent waiting on the config to display the text.
+      if (this.config != null) { return; }
+      this.$.output.textContent = content;
+    },
+
+    _contentOrConfigChanged: function(content, config) {
+      var output = Polymer.dom(this.$.output);
+      output.textContent = '';
+      var parser = new GrLinkTextParser(config, function(text, href, html) {
+        if (href) {
+          var a = document.createElement('a');
+          a.href = href;
+          a.textContent = text;
+          a.target = '_blank';
+          output.appendChild(a);
+        } else if (html) {
+          var fragment = document.createDocumentFragment();
+          // Create temporary div to hold the nodes in.
+          var div = document.createElement('div');
+          div.innerHTML = html;
+          while (div.firstChild) {
+            fragment.appendChild(div.firstChild);
+          }
+          output.appendChild(fragment);
+        } else {
+          output.appendChild(document.createTextNode(text));
+        }
+      });
+      parser.parse(content);
+    }
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
new file mode 100644
index 0000000..5203520
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-linked-text</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-linked-text.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-linked-text>
+      <div id="output"></div>
+    </gr-linked-text>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-linked-text tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.config = {
+        ph: {
+          match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2'
+        },
+        changeid: {
+          match: '(I[0-9a-f]{8,40})',
+          link: '#/q/$1'
+        },
+        googlesearch: {
+          match: 'google:(.+)',
+          link: 'https://bing.com/search?q=$1',  // html should supercede link.
+          html: '<a href="https://google.com/search?q=$1">$1</a>',
+        },
+        hashedhtml: {
+          match: 'hash:(.+)',
+          html: '<a href="#/awesomesauce">$1</a>',
+        },
+        disabledconfig: {
+          match: 'foo:(.+)',
+          link: 'https://google.com/search?q=$1',
+          enabled: false,
+        },
+      };
+    });
+
+    test('URL pattern was parsed and linked.', function() {
+      // Reguar inline link.
+      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      element.content = url;
+      var linkEl = element.$.output.childNodes[0];
+      assert.equal(linkEl.target, '_blank');
+      assert.equal(linkEl.href, url);
+      assert.equal(linkEl.textContent, url);
+    });
+
+    test('Bug pattern was parsed and linked', function() {
+      // "Issue/Bug" pattern.
+      element.content = 'Issue 3650';
+
+      var linkEl = element.$.output.childNodes[0];
+      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      assert.equal(linkEl.target, '_blank');
+      assert.equal(linkEl.href, url);
+      assert.equal(linkEl.textContent, 'Issue 3650');
+
+      element.content = 'Bug 3650';
+      linkEl = element.$.output.childNodes[0];
+      assert.equal(linkEl.target, '_blank');
+      assert.equal(linkEl.href, url);
+      assert.equal(linkEl.textContent, 'Bug 3650');
+    });
+
+    test('Change-Id pattern was parsed and linked', function() {
+      // "Change-Id:" pattern.
+      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      var prefix = 'Change-Id: ';
+      element.content = prefix + changeID;
+
+      var textNode = element.$.output.childNodes[0];
+      var linkEl = element.$.output.childNodes[1];
+      assert.equal(textNode.textContent, prefix);
+      var url = '/q/' + changeID;
+      assert.equal(linkEl.target, '_blank');
+      // Since url is a path, the host is added automatically.
+      assert.isTrue(linkEl.href.endsWith(url));
+      assert.equal(linkEl.textContent, changeID);
+    });
+
+    test('Multiple matches', function() {
+      element.content = 'Issue 3650\nIssue 3450';
+      var linkEl1 = element.$.output.childNodes[0];
+      var linkEl2 = element.$.output.childNodes[2];
+
+      assert.equal(linkEl1.target, '_blank');
+      assert.equal(linkEl1.href,
+          'https://code.google.com/p/gerrit/issues/detail?id=3650');
+      assert.equal(linkEl1.textContent, 'Issue 3650');
+
+      assert.equal(linkEl2.target, '_blank');
+      assert.equal(linkEl2.href,
+          'https://code.google.com/p/gerrit/issues/detail?id=3450');
+      assert.equal(linkEl2.textContent, 'Issue 3450');
+    });
+
+    test('html field in link config', function() {
+      element.content = 'google:do a barrel roll';
+      var linkEl = element.$.output.childNodes[0];
+      assert.equal(linkEl.getAttribute('href'),
+          'https://google.com/search?q=do a barrel roll');
+      assert.equal(linkEl.textContent, 'do a barrel roll');
+    });
+
+    test('removing hash from links', function() {
+      element.content = 'hash:foo';
+      var linkEl = element.$.output.childNodes[0];
+      assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+      assert.equal(linkEl.textContent, 'foo');
+    });
+
+    test('disabled config', function() {
+      element.content = 'foo:baz';
+      assert.equal(element.$.output.innerHTML, 'foo:baz');
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
new file mode 100644
index 0000000..b4b1678
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -0,0 +1,87 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+'use strict';
+
+function GrLinkTextParser(linkConfig, callback) {
+  this.linkConfig = linkConfig;
+  this.callback = callback;
+  Object.preventExtensions(this);
+}
+
+GrLinkTextParser.prototype.addText = function(text, href) {
+  if (!text) {
+    return;
+  }
+  this.callback(text, href);
+};
+
+GrLinkTextParser.prototype.addHTML = function(html) {
+  this.callback(null, null, html);
+};
+
+GrLinkTextParser.prototype.parse = function(text) {
+  linkify(text, {
+    callback: this.parseChunk.bind(this)
+  });
+};
+
+GrLinkTextParser.prototype.parseChunk = function(text, href) {
+  if (href) {
+    this.addText(text, href);
+  } else {
+    this.parseLinks(text, this.linkConfig);
+  }
+};
+
+GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
+  for (var p in patterns) {
+    if (patterns[p].enabled != null && patterns[p].enabled == false) {
+      continue;
+    }
+    // PolyGerrit doesn't use hash-based navigation like GWT.
+    // Account for this.
+    // TODO(andybons): Support Gerrit being served from a base other than /,
+    // e.g. https://git.eclipse.org/r/
+    if (patterns[p].html) {
+      patterns[p].html =
+          patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
+    } else if (patterns[p].link) {
+      if (patterns[p].link[0] == '#') {
+        patterns[p].link = patterns[p].link.substr(1);
+      }
+    }
+
+    var pattern = new RegExp(patterns[p].match, 'g');
+
+    var match;
+    while ((match = pattern.exec(text)) != null) {
+      var before = text.substr(0, match.index);
+      this.addText(before);
+      text = text.substr(match.index + match[0].length);
+      var result = match[0].replace(pattern,
+          patterns[p].html || patterns[p].link);
+
+      if (patterns[p].html) {
+        this.addHTML(result);
+      } else if (patterns[p].link) {
+        this.addText(match[0], result);
+      } else {
+        throw Error('linkconfig entry ' + p +
+            ' doesn’t contain a link or html attribute.');
+      }
+    }
+  }
+  this.addText(text);
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
new file mode 100644
index 0000000..817d8c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -0,0 +1,32 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+
+<dom-module id="gr-overlay">
+  <template>
+    <style>
+      :host {
+        background: #fff;
+        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+      }
+    </style>
+    <content></content>
+  </template>
+  <script src="gr-overlay.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
new file mode 100644
index 0000000..da28e49
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -0,0 +1,76 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var AWAIT_MAX_ITERS = 10;
+  var AWAIT_STEP = 5;
+
+  Polymer({
+    is: 'gr-overlay',
+
+    behaviors: [
+      Polymer.IronOverlayBehavior,
+    ],
+
+    detached: function() {
+      // For good measure.
+      Gerrit.KeyboardShortcutBehavior.enabled = true;
+    },
+
+    open: function() {
+      return new Promise(function(resolve) {
+        Gerrit.KeyboardShortcutBehavior.enabled = false;
+        Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
+        this._awaitOpen(resolve);
+      }.bind(this));
+    },
+
+    close: function() {
+      Gerrit.KeyboardShortcutBehavior.enabled = true;
+      Polymer.IronOverlayBehaviorImpl.close.apply(this, arguments);
+    },
+
+    cancel: function() {
+      Gerrit.KeyboardShortcutBehavior.enabled = true;
+      Polymer.IronOverlayBehaviorImpl.cancel.apply(this, arguments);
+    },
+
+    /**
+     * Override the focus stops that iron-overlay-behavior tries to find.
+     */
+    setFocusStops: function(stops) {
+      this.__firstFocusableNode = stops.start;
+      this.__lastFocusableNode = stops.end;
+    },
+
+    /**
+     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+     * opening. Eventually replace with a direct way to listen to the overlay.
+     */
+    _awaitOpen: function(fn) {
+      var iters = 0;
+      var step = function() {
+        this.async(function() {
+          if (this.style.display !== 'none') {
+            fn.call(this);
+          } else if (iters++ < AWAIT_MAX_ITERS) {
+            step.call(this);
+          }
+        }.bind(this), AWAIT_STEP);
+      }.bind(this);
+      step.call(this);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
new file mode 100644
index 0000000..4980cba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -0,0 +1,24 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
+<script src="../../../bower_components/fetch/fetch.js"></script>
+
+<dom-module id="gr-rest-api-interface">
+  <script src="gr-rest-api-interface.js"></script>
+</dom-module>
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
new file mode 100644
index 0000000..2f109c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -0,0 +1,881 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var JSON_PREFIX = ')]}\'';
+  var PARENT_PATCH_NUM = 'PARENT';
+
+  // Must be kept in sync with the ListChangesOption enum and protobuf.
+  var ListChangesOption = {
+    LABELS: 0,
+    DETAILED_LABELS: 8,
+
+    // Return information on the current patch set of the change.
+    CURRENT_REVISION: 1,
+    ALL_REVISIONS: 2,
+
+    // If revisions are included, parse the commit object.
+    CURRENT_COMMIT: 3,
+    ALL_COMMITS: 4,
+
+    // If a patch set is included, include the files of the patch set.
+    CURRENT_FILES: 5,
+    ALL_FILES: 6,
+
+    // If accounts are included, include detailed account info.
+    DETAILED_ACCOUNTS: 7,
+
+    // Include messages associated with the change.
+    MESSAGES: 9,
+
+    // Include allowed actions client could perform.
+    CURRENT_ACTIONS: 10,
+
+    // Set the reviewed boolean for the caller.
+    REVIEWED: 11,
+
+    // Include download commands for the caller.
+    DOWNLOAD_COMMANDS: 13,
+
+    // Include patch set weblinks.
+    WEB_LINKS: 14,
+
+    // Include consistency check results.
+    CHECK: 15,
+
+    // Include allowed change actions client could perform.
+    CHANGE_ACTIONS: 16,
+
+    // Include a copy of commit messages including review footers.
+    COMMIT_FOOTERS: 17,
+
+    // Include push certificate information along with any patch sets.
+    PUSH_CERTIFICATES: 18
+  };
+
+  Polymer({
+    is: 'gr-rest-api-interface',
+
+    /**
+     * Fired when an server error occurs.
+     *
+     * @event server-error
+     */
+
+    /**
+     * Fired when a network error occurs.
+     *
+     * @event network-error
+     */
+
+    properties: {
+      _cache: {
+        type: Object,
+        value: {},  // Intentional to share the object accross instances.
+      },
+      _sharedFetchPromises: {
+        type: Object,
+        value: {},  // Intentional to share the object accross instances.
+      },
+    },
+
+    fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params,
+        opt_opts) {
+      opt_opts = opt_opts || {};
+
+      var fetchOptions = {
+        credentials: 'same-origin',
+        headers: opt_opts.headers,
+      };
+
+      var urlWithParams = this._urlWithParams(url, opt_params);
+      return fetch(urlWithParams, fetchOptions).then(function(response) {
+        if (opt_cancelCondition && opt_cancelCondition()) {
+          response.body.cancel();
+          return;
+        }
+
+        if (!response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+            return;
+          }
+          this.fire('server-error', {response: response});
+          return;
+        }
+
+        return this.getResponseObject(response);
+      }.bind(this)).catch(function(err) {
+        if (opt_errFn) {
+          opt_errFn.call(null, null, err);
+        } else {
+          this.fire('network-error', {error: err});
+          throw err;
+        }
+        throw err;
+      }.bind(this));
+    },
+
+    _urlWithParams: function(url, opt_params) {
+      if (!opt_params) { return url; }
+
+      var params = [];
+      for (var p in opt_params) {
+        if (opt_params[p] == null) {
+          params.push(encodeURIComponent(p));
+          continue;
+        }
+        var values = [].concat(opt_params[p]);
+        for (var i = 0; i < values.length; i++) {
+          params.push(
+            encodeURIComponent(p) + '=' +
+            encodeURIComponent(values[i]));
+        }
+      }
+      return url + '?' + params.join('&');
+    },
+
+    getResponseObject: function(response) {
+      return response.text().then(function(text) {
+        var result;
+        try {
+          result = JSON.parse(text.substring(JSON_PREFIX.length));
+        } catch (_) {
+          result = null;
+        }
+        return result;
+      });
+    },
+
+    getConfig: function() {
+      return this._fetchSharedCacheURL('/config/server/info');
+    },
+
+    getProjectConfig: function(project) {
+      return this._fetchSharedCacheURL(
+          '/projects/' + encodeURIComponent(project) + '/config');
+    },
+
+    getVersion: function() {
+      return this._fetchSharedCacheURL('/config/server/version');
+    },
+
+    getDiffPreferences: function() {
+      return this.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
+        }
+        // These defaults should match the defaults in
+        // gerrit-extension-api/src/main/jcg/gerrit/extensions/client/DiffPreferencesInfo.java
+        // NOTE: There are some settings that don't apply to PolyGerrit
+        // (Render mode being at least one of them).
+        return Promise.resolve({
+          auto_hide_diff_table_header: true,
+          context: 10,
+          cursor_blink_rate: 0,
+          ignore_whitespace: 'IGNORE_NONE',
+          intraline_difference: true,
+          line_length: 100,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+          theme: 'DEFAULT',
+        });
+      }.bind(this));
+    },
+
+    savePreferences: function(prefs, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/preferences', prefs, opt_errFn,
+          opt_ctx);
+    },
+
+    saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/preferences.diff', prefs,
+          opt_errFn, opt_ctx);
+    },
+
+    getAccount: function() {
+      return this._fetchSharedCacheURL('/accounts/self/detail', function(resp) {
+        if (resp.status === 403) {
+          this._cache['/accounts/self/detail'] = null;
+        }
+      }.bind(this));
+    },
+
+    getAccountEmails: function() {
+      return this._fetchSharedCacheURL('/accounts/self/emails');
+    },
+
+    addAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/emails/' +
+          encodeURIComponent(email), null, opt_errFn, opt_ctx);
+    },
+
+    deleteAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('DELETE', '/accounts/self/emails/' +
+          encodeURIComponent(email), null, opt_errFn, opt_ctx);
+    },
+
+    setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/emails/' +
+          encodeURIComponent(email) + '/preferred', null, opt_errFn, opt_ctx);
+    },
+
+    setAccountName: function(name, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
+          opt_ctx);
+    },
+
+    getAccountGroups: function() {
+      return this._fetchSharedCacheURL('/accounts/self/groups');
+    },
+
+    getLoggedIn: function() {
+      return this.getAccount().then(function(account) {
+        return account != null;
+      });
+    },
+
+    refreshCredentials: function() {
+      this._cache = {};
+      return this.getLoggedIn();
+    },
+
+    getPreferences: function() {
+      return this.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          return this._fetchSharedCacheURL('/accounts/self/preferences');
+        }
+
+        return Promise.resolve({
+          changes_per_page: 25,
+          diff_view: 'SIDE_BY_SIDE',
+        });
+      }.bind(this));
+    },
+
+    getWatchedProjects: function() {
+      return this._fetchSharedCacheURL('/accounts/self/watched.projects');
+    },
+
+    saveWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+      return this.send('POST', '/accounts/self/watched.projects', projects,
+          opt_errFn, opt_ctx)
+          .then(function(response) {
+            return this.getResponseObject(response);
+          }.bind(this));
+    },
+
+    deleteWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+      return this.send('POST', '/accounts/self/watched.projects:delete',
+          projects, opt_errFn, opt_ctx);
+    },
+
+    _fetchSharedCacheURL: function(url, opt_errFn) {
+      if (this._sharedFetchPromises[url]) {
+        return this._sharedFetchPromises[url];
+      }
+      // TODO(andybons): Periodic cache invalidation.
+      if (this._cache[url] !== undefined) {
+        return Promise.resolve(this._cache[url]);
+      }
+      this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn).then(
+        function(response) {
+          if (response !== undefined) {
+            this._cache[url] = response;
+          }
+          this._sharedFetchPromises[url] = undefined;
+          return response;
+        }.bind(this)).catch(function(err) {
+          this._sharedFetchPromises[url] = undefined;
+          throw err;
+        }.bind(this));
+      return this._sharedFetchPromises[url];
+    },
+
+    getChanges: function(changesPerPage, opt_query, opt_offset) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS
+      );
+      var params = {
+        n: changesPerPage,
+        O: options,
+        S: opt_offset || 0,
+      };
+      if (opt_query && opt_query.length > 0) {
+        params.q = opt_query;
+      }
+      return this.fetchJSON('/changes/', null, null, params);
+    },
+
+    getDashboardChanges: function() {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.REVIEWED
+      );
+      var params = {
+        O: options,
+        q: [
+          'is:open owner:self',
+          'is:open reviewer:self -owner:self',
+          'is:closed (owner:self OR reviewer:self) -age:4w limit:10',
+        ],
+      };
+      return this.fetchJSON('/changes/', null, null, params);
+    },
+
+    getChangeActionURL: function(changeNum, opt_patchNum, endpoint) {
+      return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
+    },
+
+    getChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DOWNLOAD_COMMANDS
+      );
+      return this._getChangeDetail(changeNum, options, opt_errFn,
+          opt_cancelCondition);
+    },
+
+    getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.ALL_REVISIONS
+      );
+      return this._getChangeDetail(changeNum, options, opt_errFn,
+          opt_cancelCondition);
+    },
+
+    _getChangeDetail: function(changeNum, options, opt_errFn,
+        opt_cancelCondition) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, null, '/detail'),
+          opt_errFn,
+          opt_cancelCondition,
+          {O: options});
+    },
+
+    getChangeCommitInfo: function(changeNum, patchNum) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, patchNum, '/commit?links'));
+    },
+
+    getChangeFiles: function(changeNum, patchRange) {
+      var endpoint = '/files';
+      if (patchRange.basePatchNum !== 'PARENT') {
+        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+      }
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, patchRange.patchNum, endpoint));
+    },
+
+    getChangeFilesAsSpeciallySortedArray: function(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(
+          this._normalizeChangeFilesResponse.bind(this));
+    },
+
+    getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(function(files) {
+        return Object.keys(files).sort(this._specialFilePathCompare.bind(this));
+      }.bind(this));
+    },
+
+    _normalizeChangeFilesResponse: function(response) {
+      var paths = Object.keys(response).sort(
+          this._specialFilePathCompare.bind(this));
+      var files = [];
+      for (var i = 0; i < paths.length; i++) {
+        var info = response[paths[i]];
+        info.__path = paths[i];
+        info.lines_inserted = info.lines_inserted || 0;
+        info.lines_deleted = info.lines_deleted || 0;
+        files.push(info);
+      }
+      return files;
+    },
+
+    _specialFilePathCompare: function(a, b) {
+      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+      // The commit message always goes first.
+      if (a === COMMIT_MESSAGE_PATH) {
+        return -1;
+      }
+      if (b === COMMIT_MESSAGE_PATH) {
+        return 1;
+      }
+
+      var aLastDotIndex = a.lastIndexOf('.');
+      var aExt = a.substr(aLastDotIndex + 1);
+      var aFile = a.substr(0, aLastDotIndex);
+
+      var bLastDotIndex = b.lastIndexOf('.');
+      var bExt = b.substr(bLastDotIndex + 1);
+      var bFile = a.substr(0, bLastDotIndex);
+
+      // Sort header files above others with the same base name.
+      var headerExts = ['h', 'hxx', 'hpp'];
+      if (aFile.length > 0 && aFile === bFile) {
+        if (headerExts.indexOf(aExt) !== -1 &&
+            headerExts.indexOf(bExt) !== -1) {
+          return a.localeCompare(b);
+        }
+        if (headerExts.indexOf(aExt) !== -1) {
+          return -1;
+        }
+        if (headerExts.indexOf(bExt) !== -1) {
+          return 1;
+        }
+      }
+
+      return a.localeCompare(b);
+    },
+
+    getChangeRevisionActions: function(changeNum, patchNum) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, patchNum, '/actions')).then(
+              function(revisionActions) {
+                // The rebase button on change screen is always enabled.
+                if (revisionActions.rebase) {
+                  revisionActions.rebase.enabled = true;
+                }
+                return revisionActions;
+              });
+    },
+
+    getChangeSuggestedReviewers: function(changeNum, inputVal, opt_errFn,
+        opt_ctx) {
+      var url = this.getChangeActionURL(changeNum, null, '/suggest_reviewers');
+      return this.fetchJSON(url, opt_errFn, opt_ctx, {
+        n: 10,  // Return max 10 results
+        q: inputVal,
+      });
+    },
+
+    getSuggestedProjects: function(inputVal, opt_errFn, opt_ctx) {
+      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, {p: inputVal});
+    },
+
+    addChangeReviewer: function(changeNum, reviewerID) {
+      return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
+    },
+
+    removeChangeReviewer: function(changeNum, reviewerID) {
+      return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
+    },
+
+    _sendChangeReviewerRequest: function(method, changeNum, reviewerID) {
+      var url = this.getChangeActionURL(changeNum, null, '/reviewers');
+      var body;
+      switch (method) {
+        case 'POST':
+          body = {reviewer: reviewerID};
+          break;
+        case 'DELETE':
+          url += '/' + reviewerID;
+          break;
+        default:
+          throw Error('Unsupported HTTP method: ' + method);
+      }
+
+      return this.send(method, url, body);
+    },
+
+    getRelatedChanges: function(changeNum, patchNum) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, patchNum, '/related'));
+    },
+
+    getChangesSubmittedTogether: function(changeNum) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, null, '/submitted_together'));
+    },
+
+    getChangeConflicts: function(changeNum) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.CURRENT_REVISION,
+          ListChangesOption.CURRENT_COMMIT
+      );
+      var params = {
+        O: options,
+        q: 'status:open is:mergeable conflicts:' + changeNum,
+      };
+      return this.fetchJSON('/changes/', null, null, params);
+    },
+
+    getChangeCherryPicks: function(project, changeID, changeNum) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.CURRENT_REVISION,
+          ListChangesOption.CURRENT_COMMIT
+      );
+      var query = [
+        'project:' + project,
+        'change:' + changeID,
+        '-change:' + changeNum,
+        '-is:abandoned',
+      ].join(' ');
+      var params = {
+        O: options,
+        q: query
+      };
+      return this.fetchJSON('/changes/', null, null, params);
+    },
+
+    getChangesWithSameTopic: function(topic) {
+      var options = this._listChangesOptionsToHex(
+          ListChangesOption.LABELS,
+          ListChangesOption.CURRENT_REVISION,
+          ListChangesOption.CURRENT_COMMIT,
+          ListChangesOption.DETAILED_LABELS
+      );
+      var params = {
+        O: options,
+        q: 'status:open topic:' + topic,
+      };
+      return this.fetchJSON('/changes/', null, null, params);
+    },
+
+    getReviewedFiles: function(changeNum, patchNum) {
+      return this.fetchJSON(
+          this.getChangeActionURL(changeNum, patchNum, '/files?reviewed'));
+    },
+
+    saveFileReviewed: function(changeNum, patchNum, path, reviewed, opt_errFn,
+        opt_ctx) {
+      var method = reviewed ? 'PUT' : 'DELETE';
+      var url = this.getChangeActionURL(changeNum, patchNum,
+          '/files/' + encodeURIComponent(path) + '/reviewed');
+
+      return this.send(method, url, null, opt_errFn, opt_ctx);
+    },
+
+    saveChangeReview: function(changeNum, patchNum, review, opt_errFn,
+        opt_ctx) {
+      var url = this.getChangeActionURL(changeNum, patchNum, '/review');
+      return this.send('POST', url, review, opt_errFn, opt_ctx);
+    },
+
+    saveChangeCommitMessageEdit: function(changeNum, message) {
+      var url = this.getChangeActionURL(changeNum, null, '/edit:message');
+      return this.send('PUT', url, {message: message});
+    },
+
+    publishChangeEdit: function(changeNum) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null, '/edit:publish'));
+    },
+
+    saveChangeStarred: function(changeNum, starred) {
+      var url = '/accounts/self/starred.changes/' + changeNum;
+      var method = starred ? 'PUT' : 'DELETE';
+      return this.send(method, url);
+    },
+
+    send: function(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
+      var headers = new Headers({
+        'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'),
+      });
+      var options = {
+        method: method,
+        headers: headers,
+        credentials: 'same-origin',
+      };
+      if (opt_body) {
+        headers.append('Content-Type', opt_contentType || 'application/json');
+        if (typeof opt_body !== 'string') {
+          opt_body = JSON.stringify(opt_body);
+        }
+        options.body = opt_body;
+      }
+      return fetch(url, options).then(function(response) {
+        if (!response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+            return undefined;
+          }
+          this.fire('server-error', {response: response});
+        }
+
+        return response;
+      }.bind(this)).catch(function(err) {
+        this.fire('network-error', {error: err});
+        if (opt_errFn) {
+          opt_errFn.call(opt_ctx, null, err);
+        } else {
+          throw err;
+        }
+      }.bind(this));
+    },
+
+    getDiff: function(changeNum, basePatchNum, patchNum, path,
+        opt_errFn, opt_cancelCondition) {
+      var url = this._getDiffFetchURL(changeNum, patchNum, path);
+      var params = {
+        context: 'ALL',
+        intraline: null,
+        whitespace: 'IGNORE_NONE',
+      };
+      if (basePatchNum != PARENT_PATCH_NUM) {
+        params.base = basePatchNum;
+      }
+
+      return this.fetchJSON(url, opt_errFn, opt_cancelCondition, params);
+    },
+
+    _getDiffFetchURL: function(changeNum, patchNum, path) {
+      return this._changeBaseURL(changeNum, patchNum) + '/files/' +
+          encodeURIComponent(path) + '/diff';
+    },
+
+    getDiffComments: function(changeNum, opt_basePatchNum, opt_patchNum,
+        opt_path) {
+      return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
+          opt_patchNum, opt_path);
+    },
+
+    getDiffDrafts: function(changeNum, opt_basePatchNum, opt_patchNum,
+        opt_path) {
+      return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
+          opt_patchNum, opt_path);
+    },
+
+    _getDiffComments: function(changeNum, endpoint, opt_basePatchNum,
+        opt_patchNum, opt_path) {
+      if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
+        return this.fetchJSON(
+            this._getDiffCommentsFetchURL(changeNum, endpoint));
+      }
+
+      function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
+      function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
+      function setPath(c) { c.path = opt_path; }
+
+      var promises = [];
+      var comments;
+      var baseComments;
+      var url =
+          this._getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum);
+      promises.push(this.fetchJSON(url).then(function(response) {
+        comments = response[opt_path] || [];
+        if (opt_basePatchNum == PARENT_PATCH_NUM) {
+          baseComments = comments.filter(onlyParent);
+          baseComments.forEach(setPath);
+        }
+        comments = comments.filter(withoutParent);
+
+        comments.forEach(setPath);
+      }.bind(this)));
+
+      if (opt_basePatchNum != PARENT_PATCH_NUM) {
+        var baseURL = this._getDiffCommentsFetchURL(changeNum, endpoint,
+            opt_basePatchNum);
+        promises.push(this.fetchJSON(baseURL).then(function(response) {
+          baseComments = (response[opt_path] || []).filter(withoutParent);
+          baseComments.forEach(setPath);
+        }));
+      }
+
+      return Promise.all(promises).then(function() {
+        return Promise.resolve({
+          baseComments: baseComments,
+          comments: comments,
+        });
+      });
+    },
+
+    _getDiffCommentsFetchURL: function(changeNum, endpoint, opt_patchNum) {
+      return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
+    },
+
+    saveDiffDraft: function(changeNum, patchNum, draft) {
+      return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
+    },
+
+    deleteDiffDraft: function(changeNum, patchNum, draft) {
+      return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
+    },
+
+    _sendDiffDraftRequest: function(method, changeNum, patchNum, draft) {
+      var url = this.getChangeActionURL(changeNum, patchNum, '/drafts');
+      if (draft.id) {
+        url += '/' + draft.id;
+      }
+      var body;
+      if (method === 'PUT') {
+        body = draft;
+      }
+
+      return this.send(method, url, body);
+    },
+
+    _changeBaseURL: function(changeNum, opt_patchNum) {
+      var v = '/changes/' + changeNum;
+      if (opt_patchNum) {
+        v += '/revisions/' + opt_patchNum;
+      }
+      return v;
+    },
+
+    // Derived from
+    // gerrit-extension-api/src/main/j/c/g/gerrit/extensions/client/ListChangesOption.java
+    _listChangesOptionsToHex: function() {
+      var v = 0;
+      for (var i = 0; i < arguments.length; i++) {
+        v |= 1 << arguments[i];
+      }
+      return v.toString(16);
+    },
+
+    _getCookie: function(name) {
+      var key = name + '=';
+      var cookies = document.cookie.split(';');
+      for (var i = 0; i < cookies.length; i++) {
+        var c = cookies[i];
+        while (c.charAt(0) == ' ') {
+          c = c.substring(1);
+        }
+        if (c.indexOf(key) == 0) {
+          return c.substring(key.length, c.length);
+        }
+      }
+      return '';
+    },
+
+    getCommitInfo: function(project, commit) {
+      return this.fetchJSON(
+          '/projects/' + encodeURIComponent(project) +
+          '/commits/' + encodeURIComponent(commit));
+    },
+
+    _fetchB64File: function(url) {
+      return fetch(url).then(function(response) {
+        var type = response.headers.get('X-FYI-Content-Type');
+        return response.text()
+          .then(function(text) {
+            return {body: text, type: type};
+          });
+      });
+    },
+
+    getChangeFileContents: function(changeId, patchNum, path) {
+      return this._fetchB64File(
+          '/changes/' + encodeURIComponent(changeId) +
+          '/revisions/' + encodeURIComponent(patchNum) +
+          '/files/' + encodeURIComponent(path) +
+          '/content');
+    },
+
+    getCommitFileContents: function(projectName, commit, path) {
+      return this._fetchB64File(
+          '/projects/' + encodeURIComponent(projectName) +
+          '/commits/' + encodeURIComponent(commit) +
+          '/files/' + encodeURIComponent(path) +
+          '/content');
+    },
+
+    getImagesForDiff: function(project, commit, changeNum, diff, patchRange) {
+      var promiseA;
+      var promiseB;
+
+      if (diff.meta_a && diff.meta_a.content_type.indexOf('image/') === 0) {
+        if (patchRange.basePatchNum === 'PARENT') {
+          // Need the commit info know the parent SHA.
+          promiseA = this.getCommitInfo(project, commit).then(function(info) {
+            if (info.parents.length !== 1) {
+              return Promise.reject('Change commit has multiple parents.');
+            }
+            var parent = info.parents[0].commit;
+            return this.getCommitFileContents(project, parent,
+                diff.meta_a.name);
+          }.bind(this));
+
+        } else {
+          promiseA = this.getChangeFileContents(changeNum,
+              patchRange.basePatchNum, diff.meta_a.name);
+        }
+      } else {
+        promiseA = Promise.resolve(null);
+      }
+
+      if (diff.meta_b && diff.meta_b.content_type.indexOf('image/') === 0) {
+        promiseB = this.getChangeFileContents(changeNum, patchRange.patchNum,
+            diff.meta_b.name);
+      } else {
+        promiseB = Promise.resolve(null);
+      }
+
+      return Promise.all([promiseA, promiseB])
+        .then(function(results) {
+          var baseImage = results[0];
+          var revisionImage = results[1];
+
+          // Sometimes the server doesn't send back the content type.
+          if (baseImage) {
+            baseImage._expectedType = diff.meta_a.content_type;
+          }
+          if (revisionImage) {
+            revisionImage._expectedType = diff.meta_b.content_type;
+          }
+
+          return {baseImage: baseImage, revisionImage: revisionImage};
+        }.bind(this));
+    },
+
+    setChangeTopic: function(changeNum, topic) {
+      return this.send('PUT', '/changes/' + encodeURIComponent(changeNum) +
+          '/topic', {topic: topic});
+    },
+
+    getAccountHttpPassword: function(opt_errFn) {
+      return this._fetchSharedCacheURL('/accounts/self/password.http',
+          opt_errFn);
+    },
+
+    deleteAccountHttpPassword: function() {
+      return this.send('DELETE', '/accounts/self/password.http');
+    },
+
+    generateAccountHttpPassword: function() {
+      return this.send('PUT', '/accounts/self/password.http', {generate: true})
+          .then(this.getResponseObject);
+    },
+
+    getAccountSSHKeys: function() {
+      return this._fetchSharedCacheURL('/accounts/self/sshkeys');
+    },
+
+    addAccountSSHKey: function(key) {
+      return this.send('POST', '/accounts/self/sshkeys', key, null, null,
+          'plain/text')
+          .then(function(response) {
+            if (response.status < 200 && response.status >= 300) {
+              return Promise.reject();
+            }
+            return this.getResponseObject(response);
+          }.bind(this))
+          .then(function(obj) {
+            if (!obj.valid) { return Promise.reject(); }
+            return obj;
+          });
+    },
+
+    deleteAccountSSHKey: function(id) {
+      return this.send('DELETE', '/accounts/self/sshkeys/' + id);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
new file mode 100644
index 0000000..8dda2ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -0,0 +1,302 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-rest-api-interface</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-rest-api-interface.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-rest-api-interface></gr-rest-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-rest-api-interface tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('JSON prefix is properly removed', function(done) {
+      var testJSON = ')]}\'\n{"hello": "bonjour"}';
+
+      sandbox.stub(window, 'fetch', function() {
+        return Promise.resolve({
+          ok: true,
+          text: function() {
+            return Promise.resolve(testJSON);
+          },
+        });
+      });
+      element.fetchJSON('/dummy/url').then(function(obj) {
+        assert.deepEqual(obj, {hello: 'bonjour'});
+        done();
+      });
+    });
+
+    test('cached results', function(done) {
+      var n = 0;
+      sandbox.stub(element, 'fetchJSON', function() {
+        return Promise.resolve(++n);
+      });
+      var promises = [];
+      promises.push(element._fetchSharedCacheURL('/foo'));
+      promises.push(element._fetchSharedCacheURL('/foo'));
+      promises.push(element._fetchSharedCacheURL('/foo'));
+
+      Promise.all(promises).then(function(results) {
+        assert.deepEqual(results, [1, 1, 1]);
+        element._fetchSharedCacheURL('/foo').then(function(foo) {
+          assert.equal(foo, 1);
+          done();
+        });
+      });
+    });
+
+    test('cached promise', function(done) {
+      var promise = Promise.reject('foo');
+      element._cache['/foo'] = promise;
+      element._fetchSharedCacheURL('/foo').catch(function(p) {
+        assert.equal(p, 'foo');
+        done();
+      });
+    });
+
+    test('params are properly encoded', function() {
+      var url = element._urlWithParams('/path/', {
+        sp: 'hola',
+        gr: 'guten tag',
+        noval: null,
+      });
+      assert.equal(url, '/path/?sp=hola&gr=guten%20tag&noval');
+
+      url = element._urlWithParams('/path/', {
+        sp: 'hola',
+        en: ['hey', 'hi'],
+      });
+      assert.equal(url, '/path/?sp=hola&en=hey&en=hi');
+
+      // Order must be maintained with array params.
+      url = element._urlWithParams('/path/', {
+        l: ['c', 'b', 'a'],
+      });
+      assert.equal(url, '/path/?l=c&l=b&l=a');
+    });
+
+    test('request callbacks can be canceled', function(done) {
+      var cancelCalled = false;
+      sandbox.stub(window, 'fetch', function() {
+        return Promise.resolve({
+          body: {
+            cancel: function() { cancelCalled = true; }
+          },
+        });
+      });
+      element.fetchJSON('/dummy/url', null, function() { return true; }).then(
+        function(obj) {
+          assert.isUndefined(obj);
+          assert.isTrue(cancelCalled);
+          done();
+        });
+    });
+
+    test('parent diff comments are properly grouped', function(done) {
+      sandbox.stub(element, 'fetchJSON', function() {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'this isn’t quite right',
+            },
+            {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+            },
+          ],
+        });
+      });
+      element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+        function(obj) {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            side: 'PARENT',
+            message: 'how did this work in the first place?',
+            path: 'sieve.go',
+          });
+          assert.equal(obj.comments.length, 1);
+          assert.deepEqual(obj.comments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+          });
+          done();
+        });
+    });
+
+    test('differing patch diff comments are properly grouped', function(done) {
+      sandbox.stub(element, 'fetchJSON', function(url) {
+        if (url == '/changes/42/revisions/1') {
+          return Promise.resolve({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                message: 'this isn’t quite right',
+              },
+              {
+                side: 'PARENT',
+                message: 'how did this work in the first place?',
+              },
+            ],
+          });
+        } else if (url == '/changes/42/revisions/2') {
+          return Promise.resolve({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                message: 'What on earth are you thinking, here?',
+              },
+              {
+                side: 'PARENT',
+                message: 'Yeah not sure how this worked either?',
+              },
+              {
+                message: '¯\\_(ツ)_/¯',
+              },
+            ],
+          });
+        }
+      });
+      element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+        function(obj) {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+          });
+          assert.equal(obj.comments.length, 2);
+          assert.deepEqual(obj.comments[0], {
+            message: 'What on earth are you thinking, here?',
+            path: 'sieve.go',
+          });
+          assert.deepEqual(obj.comments[1], {
+            message: '¯\\_(ツ)_/¯',
+            path: 'sieve.go',
+          });
+          done();
+        });
+    });
+
+    test('special file path sorting', function() {
+      assert.deepEqual(
+          ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
+              element._specialFilePathCompare),
+          ['/COMMIT_MSG', '.a', '.b', 'file']);
+
+      assert.deepEqual(
+          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+              element._specialFilePathCompare),
+          ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
+
+      assert.deepEqual(
+          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+              element._specialFilePathCompare),
+          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
+
+      assert.deepEqual(
+          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+              element._specialFilePathCompare),
+          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
+
+      assert.deepEqual(
+          ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
+              element._specialFilePathCompare),
+          ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+    });
+
+    test('rebase always enabled', function(done) {
+      var resolveFetchJSON;
+      sandbox.stub(element, 'fetchJSON').returns(
+          new Promise(function(resolve) {
+            resolveFetchJSON = resolve;
+          }));
+      element.getChangeRevisionActions('42', '1337').then(
+          function(response) {
+            assert.isTrue(response.rebase.enabled);
+            done();
+          });
+      resolveFetchJSON({rebase: {}});
+    });
+
+    test('server error', function(done) {
+      var getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+      sandbox.stub(window, 'fetch', function() {
+        return Promise.resolve({ok: false});
+      });
+      var serverErrorEventPromise = new Promise(function(resolve) {
+        element.addEventListener('server-error', function() { resolve(); });
+      });
+
+      element.fetchJSON().then(
+          function(response) {
+            assert.isUndefined(response);
+            assert.isTrue(getResponseObjectStub.notCalled);
+            serverErrorEventPromise.then(function() {
+              done();
+            });
+          });
+    });
+
+    test('refreshCredentials', function(done) {
+      var responses = [
+        {
+          ok: false,
+          status: 403,
+          text: function() { return Promise.resolve(); }
+        },
+        {
+          ok: true,
+          status: 200,
+          text: function() { return Promise.resolve(')]}\'{}'); }
+        },
+      ];
+      var fetchStub = sandbox.stub(window, 'fetch', function(url) {
+        if (url === '/accounts/self/detail') {
+          return Promise.resolve(responses.shift());
+        }
+      });
+      element.getLoggedIn().then(function(isLoggedIn) {
+        assert.isFalse(isLoggedIn);
+        element.refreshCredentials().then(function(isRefreshed) {
+          assert.isTrue(isRefreshed);
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
new file mode 100644
index 0000000..8141c8a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
@@ -0,0 +1,166 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="mock-diff-response">
+  <template></template>
+  <script>
+    (function() {
+      'use strict';
+
+      var RESPONSE = {
+        'meta_a': {
+          'name': 'lorem-ipsum.txt',
+          'content_type': 'text/plain',
+          'lines': 45,
+        },
+        'meta_b': {
+          'name': 'lorem-ipsum.txt',
+          'content_type': 'text/plain',
+          'lines': 48,
+        },
+        'intraline_status': 'OK',
+        'change_type': 'MODIFIED',
+        'diff_header': [
+          'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+          'index b2adcf4..554ae49 100644',
+          '--- a/lorem-ipsum.txt',
+          '+++ b/lorem-ipsum.txt',
+        ],
+        'content': [
+          {
+            'ab': [
+              'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+                'nulla phasellus.',
+              'Mattis lectus.',
+              'Sodales duis.',
+              'Orci a faucibus.',
+            ]
+          },
+          {
+            'b': [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+          },
+          {
+            'ab': [
+              'Sem nascetur, erat ut, non in.',
+              'A donec, venenatis pellentesque dis.',
+              'Mauris mauris.',
+              'Quisque nisl duis, facilisis viverra.',
+              'Justo purus, semper eget et.',
+            ],
+          },
+          {
+            'a': [
+              'Est amet, vestibulum pellentesque.',
+              'Erat ligula.',
+              'Justo eros.',
+              'Fringilla quisque.',
+            ],
+          },
+          {
+            'ab': [
+              'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+              'Eros suspendisse.',
+            ],
+          },
+          {
+            'a': [
+              'Rhoncus tempor, ultricies aliquam ipsum.',
+            ],
+            'b': [
+              'Rhoncus tempor, ultricies praesent ipsum.',
+            ],
+            'edit_a': [
+              [
+                26,
+                7,
+              ],
+            ],
+            'edit_b': [
+              [
+                26,
+                8,
+              ],
+            ],
+          },
+          {
+            'ab': [
+              'Sollicitudin duis.',
+              'Blandit blandit, ante nisl fusce.',
+              'Felis ac at, tellus consectetuer.',
+              'Sociis ligula sapien, egestas leo.',
+              'Cum pulvinar, sed mauris, cursus neque velit.',
+              'Augue porta lobortis.',
+              'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+              'Id quam ipsum, id urna et, massa suspendisse.',
+              'Ac nec, nibh praesent.',
+              'Rutrum vestibulum.',
+              'Est tellus, bibendum habitasse.',
+              'Justo facilisis, vel nulla.',
+              'Donec eu, vulputate neque aliquam, nulla dui.',
+              'Risus adipiscing in.',
+              'Lacus arcu arcu.',
+              'Urna velit.',
+              'Urna a dolor.',
+              'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+                'consequat.',
+              'Etiam dui, blandit wisi.',
+              'Mi nec.',
+              'Vitae eget vestibulum.',
+              'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+              'Ac eget.',
+              'Vel fringilla, interdum pellentesque placerat, proin ante.',
+            ],
+          },
+          {
+            'b': [
+              'Eu congue risus.',
+              'Enim ac, quis elementum.',
+              'Non et elit.',
+              'Etiam aliquam, diam vel nunc.',
+            ],
+          },
+          {
+            'ab': [
+              'Nec at.',
+              'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+              'Pellentesque amet et, tellus duis.',
+              'Ipsum arcu vitae, justo elit, sed libero tellus.',
+              'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+            ],
+          },
+        ],
+      };
+
+      Polymer({
+        is: 'mock-diff-response',
+        properties: {
+          diffResponse: {
+            type: Object,
+            value: function() {
+              return RESPONSE;
+            },
+          },
+        },
+      });
+    })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
new file mode 100644
index 0000000..fed81bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
@@ -0,0 +1,20 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<dom-module id="gr-select">
+  <script src="gr-select.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
new file mode 100644
index 0000000..9e14f08
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -0,0 +1,55 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-select',
+
+    extends: 'select',
+
+    properties: {
+      bindValue: {
+        type: String,
+        notify: true,
+      },
+    },
+
+    observers: [
+      '_valueChanged(bindValue)',
+    ],
+
+    attached: function() {
+      this.addEventListener('change', function() {
+        this.bindValue = this.value;
+      });
+    },
+
+    ready: function() {
+      // If not set via the property, set bind-value to the element value.
+      if (!this.bindValue) { this.bindValue = this.value; }
+    },
+
+    _valueChanged: function(bindValue) {
+      var options = Polymer.dom(this.root).querySelectorAll('option');
+      for (var i = 0; i < options.length; i++) {
+        if (options[i].getAttribute('value') === bindValue + '') {
+          options[i].setAttribute('selected', true);
+          this.value = bindValue;
+          break;
+        }
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
new file mode 100644
index 0000000..abdff64
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-select</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-select.html">
+
+<test-fixture id="basic">
+  <template>
+    <select is="gr-select">
+      <option value="1">One</option>
+      <option value="2">Two</option>
+      <option value="3">Three</option>
+    </select>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-select tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('bidirectional binding property-to-attribute', function() {
+      var changeStub = sinon.stub();
+      element.addEventListener('bind-value-changed', changeStub);
+
+      // The selected element should be the first one by default.
+      assert.equal(element.value, '1');
+      assert.equal(element.bindValue, '1');
+      assert.isFalse(changeStub.called);
+
+      // Now change the value.
+      element.bindValue = '2';
+
+      // It should be updated.
+      assert.equal(element.value, '2');
+      assert.equal(element.bindValue, '2');
+      assert.isTrue(changeStub.called);
+    });
+
+    test('bidirectional binding attribute-to-property', function() {
+      var changeStub = sinon.stub();
+      element.addEventListener('bind-value-changed', changeStub);
+
+      // The selected element should be the first one by default.
+      assert.equal(element.value, '1');
+      assert.equal(element.bindValue, '1');
+      assert.isFalse(changeStub.called);
+
+      // Now change the value.
+      element.value = '3';
+      element.fire('change');
+
+      // It should be updated.
+      assert.equal(element.value, '3');
+      assert.equal(element.bindValue, '3');
+      assert.isTrue(changeStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
new file mode 100644
index 0000000..74bfcdf
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
@@ -0,0 +1,19 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<dom-module id="gr-storage">
+  <script src="gr-storage.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
new file mode 100644
index 0000000..ff41a74
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // Date cutoff is one day:
+  var DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
+
+  // Clean up old entries no more frequently than one day.
+  var CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
+
+  Polymer({
+    is: 'gr-storage',
+
+    properties: {
+      _lastCleanup: Number,
+      _storage: {
+        type: Object,
+        value: function() {
+          return window.localStorage;
+        },
+      },
+    },
+
+    getDraftComment: function(location) {
+      this._cleanupDrafts();
+      return this._getObject(this._getDraftKey(location));
+    },
+
+    setDraftComment: function(location, message) {
+      var key = this._getDraftKey(location);
+      this._setObject(key, {message: message, updated: Date.now()});
+    },
+
+    eraseDraftComment: function(location) {
+      var key = this._getDraftKey(location);
+      this._storage.removeItem(key);
+    },
+
+    getPreferences: function() {
+      return this._getObject('localPrefs');
+    },
+
+    savePreferences: function(localPrefs) {
+      this._setObject('localPrefs', localPrefs || null);
+    },
+
+    _getDraftKey: function(location) {
+      return ['draft', location.changeNum, location.patchNum, location.path,
+          location.line || ''].join(':');
+    },
+
+    _cleanupDrafts: function() {
+      // Throttle cleanup to the throttle interval.
+      if (this._lastCleanup &&
+          Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
+        return;
+      }
+      this._lastCleanup = Date.now();
+
+      var draft;
+      for (var key in this._storage) {
+        if (key.indexOf('draft:') === 0) {
+          draft = this._getObject(key);
+          if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
+            this._storage.removeItem(key);
+          }
+        }
+      }
+    },
+
+    _getObject: function(key) {
+      var serial = this._storage.getItem(key);
+      if (!serial) { return null; }
+      return JSON.parse(serial);
+    },
+
+    _setObject: function(key, obj) {
+      this._storage.setItem(key, JSON.stringify(obj));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
new file mode 100644
index 0000000..54e5577
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-storage</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-storage.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-storage></gr-storage>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-storage tests', function() {
+    var element;
+    var storage;
+
+    function cleanupStorage() {
+      // Make sure there are no entries in storage.
+      for (var key in window.localStorage) {
+        window.localStorage.removeItem(key);
+      }
+    }
+
+    setup(function() {
+      element = fixture('basic');
+      storage = element._storage;
+      cleanupStorage();
+    });
+
+    test('storing, retrieving and erasing drafts', function() {
+      var changeNum = 1234;
+      var patchNum = 5;
+      var path = 'my_source_file.js';
+      var line = 123;
+      var location = {
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: path,
+        line: line,
+      };
+
+      // The key is in the expected format.
+      var key = element._getDraftKey(location);
+      assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
+
+      // There should be no draft initially.
+      var draft = element.getDraftComment(location);
+      assert.isNotOk(draft);
+
+      // Setting the draft stores it under the expected key.
+      element.setDraftComment(location, 'my comment');
+      assert.isOk(storage.getItem(key));
+      assert.equal(JSON.parse(storage.getItem(key)).message, 'my comment');
+      assert.isOk(JSON.parse(storage.getItem(key)).updated);
+
+      // Erasing the draft removes the key.
+      element.eraseDraftComment(location);
+      assert.isNotOk(storage.getItem(key));
+
+      cleanupStorage();
+    });
+
+    test('automatically removes old drafts', function() {
+      var changeNum = 1234;
+      var patchNum = 5;
+      var path = 'my_source_file.js';
+      var line = 123;
+      var location = {
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: path,
+        line: line,
+      };
+      var key = element._getDraftKey(location);
+
+      // Make sure that the call to cleanup doesn't get throttled.
+      element._lastCleanup = 0;
+
+      var cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+
+      // Create a message with a timestamp that is a second behind the max age.
+      storage.setItem(key, JSON.stringify({
+        message: 'old message',
+        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
+      }));
+
+      // Getting the draft should cause it to be removed.
+      var draft = element.getDraftComment(location);
+
+      assert.isTrue(cleanupSpy.called);
+      assert.isNotOk(draft);
+      assert.isNotOk(storage.getItem(key));
+
+      cleanupSpy.restore();
+      cleanupStorage();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
new file mode 100644
index 0000000..57a7272
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -0,0 +1,50 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-tooltip">
+  <template>
+    <style>
+      :host {
+        --gr-tooltip-arrow-size: .5em;
+        --gr-tooltip-arrow-center-offset: 0;
+
+        background-color: #333;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        color: #fff;
+        font-size: .75rem;
+        padding: .5em .85em;
+        position: absolute;
+        z-index: 1000;
+      }
+      .arrow {
+        border-left: var(--gr-tooltip-arrow-size) solid transparent;
+        border-right: var(--gr-tooltip-arrow-size) solid transparent;
+        border-top: var(--gr-tooltip-arrow-size) solid #333;
+        bottom: -var(--gr-tooltip-arrow-size);
+        height: 0;
+        position: absolute;
+        left: calc(50% - var(--gr-tooltip-arrow-size));
+        margin-left: var(--gr-tooltip-arrow-center-offset);
+        width: 0;
+      }
+    </style>
+    [[text]]
+    <i class="arrow"></i>
+  </template>
+  <script src="gr-tooltip.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
new file mode 100644
index 0000000..76372ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-tooltip',
+
+    properties: {
+      text: String,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/favicon.ico b/polygerrit-ui/app/favicon.ico
new file mode 100644
index 0000000..155217b
--- /dev/null
+++ b/polygerrit-ui/app/favicon.ico
Binary files differ
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
new file mode 100644
index 0000000..0e00f77
--- /dev/null
+++ b/polygerrit-ui/app/index.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<html lang="en">
+<meta charset="utf-8">
+<meta name="description" content="Gerrit Code Review">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
+
+<link rel="stylesheet" href="/styles/fonts.css">
+<link rel="stylesheet" href="/styles/main.css">
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="/elements/gr-app.html">
+
+<body unresolved>
+<gr-app id="app"></gr-app>
diff --git a/polygerrit-ui/app/polygerrit_wct_tests.py b/polygerrit-ui/app/polygerrit_wct_tests.py
new file mode 100644
index 0000000..eb34fef
--- /dev/null
+++ b/polygerrit-ui/app/polygerrit_wct_tests.py
@@ -0,0 +1,118 @@
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import atexit
+from distutils import spawn
+import json
+import os
+import pkg_resources
+import shlex
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import zipfile
+
+
+def _write_wct_conf(root, exports):
+  with open(os.path.join(root, 'wct.conf.js'), 'w') as f:
+    f.write('module.exports = %s;\n' % json.dumps(exports))
+
+
+def _wct_cmd():
+  return ['wct'] + shlex.split(os.environ.get('WCT_ARGS', ''))
+
+
+class PolyGerritWctTests(unittest.TestCase):
+
+  # Should really be setUpClass/tearDownClass, but Buck's test runner doesn't
+  # produce sane stack traces from those methods. There's only one test method
+  # anyway, so just use setUp.
+
+  def _check_wct(self):
+    self.assertTrue(
+        spawn.find_executable('wct'),
+        msg='wct not found; try `npm install -g web-component-tester`')
+
+  def _extract_resources(self):
+    tmpdir = tempfile.mkdtemp()
+    atexit.register(lambda: shutil.rmtree(tmpdir))
+    root = os.path.join(tmpdir, 'polygerrit')
+    os.mkdir(root)
+
+    tr = 'test_resources.zip'
+    zip_path = os.path.join(tmpdir, tr)
+    s = pkg_resources.resource_stream(__name__, tr)
+    with open(zip_path, 'w') as f:
+      shutil.copyfileobj(s, f)
+
+    with zipfile.ZipFile(zip_path, 'r') as z:
+      z.extractall(root)
+
+    return tmpdir, root
+
+  def test_wct(self):
+    self._check_wct()
+    tmpdir, root = self._extract_resources()
+
+    cmd = _wct_cmd()
+    print('Running %s in %s' % (cmd, root), file=sys.stderr)
+
+    _write_wct_conf(root, {
+      'suites': ['test'],
+      'webserver': {
+        'pathMappings': [
+          {'/components/bower_components': 'bower_components'},
+        ],
+      },
+      'plugins': {
+        'local': {
+          # For some reason wct tries to install selenium into its node_modules
+          # directory on first run. If you've installed into /usr/local and
+          # aren't running wct as root, you're screwed. Turning this option off
+          # seems to still work, so there's that.
+          'skipSeleniumInstall': True,
+        },
+        'sauce': {
+          # Disabled by default in order to run local tests only.
+          # Run it with (saucelabs.com account required; free for open source):
+          # WCT_ARGS='--plugin sauce' buck test --no-results-cache --include web
+          'disabled': True,
+          'browsers': [
+            'OS X 10.11/chrome',
+            'Windows 10/chrome',
+            'Linux/firefox',
+            'OS X 10.11/safari',
+            'Windows 10/microsoftedge',
+          ],
+        },
+      },
+    })
+
+    p = subprocess.Popen(cmd, cwd=root,
+                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    out, err = p.communicate()
+    sys.stdout.write(out)
+    sys.stderr.write(err)
+    self.assertEquals(0, p.returncode)
+
+    # Only remove tmpdir if successful, to allow debugging.
+    shutil.rmtree(tmpdir)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/polygerrit-ui/app/robots.txt b/polygerrit-ui/app/robots.txt
new file mode 100644
index 0000000..eb05362
--- /dev/null
+++ b/polygerrit-ui/app/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
new file mode 100644
index 0000000..13f3243
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util.js
@@ -0,0 +1,59 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  var util = window.util || {};
+
+  util.parseDate = function(dateStr) {
+    // Timestamps are given in UTC and have the format
+    // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
+    // nanoseconds.
+    // Munge the date into an ISO 8061 format and parse that.
+    return new Date(dateStr.replace(' ', 'T') + 'Z');
+  };
+
+  util.htmlEntityMap = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;',
+    '\'': '&#39;',
+    '/': '&#x2F;',
+    '`': '&#96;',
+  };
+
+  util.escapeHTML = function(str) {
+    return str.replace(/[&<>"'`\/]/g, function(s) {
+      return util.htmlEntityMap[s];
+    });
+  };
+
+  util.getCookie = function(name) {
+    var key = name + '=';
+    var cookies = document.cookie.split(';');
+    for (var i = 0; i < cookies.length; i++) {
+      var c = cookies[i];
+      while (c.charAt(0) == ' ') {
+        c = c.substring(1);
+      }
+      if (c.indexOf(key) == 0) {
+        return c.substring(key.length, c.length);
+      }
+    }
+    return '';
+  };
+
+  window.util = util;
+})(window);
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
new file mode 100644
index 0000000..ecf4ac6
--- /dev/null
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -0,0 +1,31 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<style is="custom-style">
+:root {
+  --primary-text-color: #000;
+  --search-border-color: #ddd;
+  --selection-background-color: #ebf5fb;
+  --default-text-color: #000;
+  --view-background-color: #fff;
+  --default-horizontal-margin: 1.25rem;
+  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  --monospace-font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
+
+  --iron-overlay-backdrop: {
+    transition: none;
+  };
+}
+</style>
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
new file mode 100644
index 0000000..b5bf9ae
--- /dev/null
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -0,0 +1,20 @@
+/* latin-ext */
+@font-face {
+  font-family: 'Source Code Pro';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Source Code Pro'), local('SourceCodePro-Regular'),
+       url(../fonts/SourceCodePro-Regular.woff2) format('woff2'),
+       url(../fonts/SourceCodePro-Regular.woff) format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Source Code Pro';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Source Code Pro'), local('SourceCodePro-Regular'),
+       url(../fonts/SourceCodePro-Regular.woff2) format('woff2'),
+       url(../fonts/SourceCodePro-Regular.woff) format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
new file mode 100644
index 0000000..d367a75
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -0,0 +1,144 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-change-list-styles">
+  <template>
+    <style>
+      .headerRow {
+        display: flex;
+      }
+      .topHeader,
+      .groupHeader {
+        border-bottom: 1px solid #eee;
+        font-weight: bold;
+        padding: .3em .5em;
+      }
+      .topHeader {
+        background-color: #ddd;
+        flex-shrink: 0;
+      }
+      .noChanges {
+        border-bottom: 1px solid #eee;
+        padding: .3em .5em;
+      }
+      .keyboard,
+      .star {
+        align-items: center;
+        display: flex;
+        justify-content: center;
+        padding: 0;
+        width: 2em;
+      }
+      .star {
+        padding-top: .05em;
+      }
+      .number {
+        width: 4em;
+      }
+      .subject {
+        flex-grow: 1;
+        flex-shrink: 1;
+        word-break: break-word;
+      }
+      .status {
+        width: 9em;
+      }
+      .owner {
+        width: 15em;
+      }
+      .project,
+      .branch {
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+      .project {
+        width: 10em;
+      }
+      .branch {
+        width: 7em;
+      }
+      .updated {
+        width: 9em;
+        text-align: right;
+      }
+      .size {
+        width: 9em;
+        text-align: right;
+      }
+      .label {
+        width: 2.6em;
+        text-align: center;
+      }
+      :host {
+        font-size: 11px;
+      }
+      @media only screen and (max-width: 50em) {
+        :host {
+          font-size: 14px;
+        }
+        gr-change-list-item {
+          flex-wrap: wrap;
+          justify-content: space-between;
+          padding: .25em .5em;
+        }
+        gr-change-list-item[selected] {
+          background-color: transparent;
+        }
+        .topHeader,
+        .keyboard,
+        .status,
+        .project,
+        .branch,
+        .updated,
+        .label {
+          display: none;
+        }
+        .star {
+          align-items: flex-start;
+          padding-left: .35em;
+          padding-top: .4em;
+        }
+        .subject {
+          margin-bottom: .25em;
+          text-decoration: underline;
+          width: calc(100% - 2em);
+        }
+        .owner,
+        .size {
+          width: auto;
+        }
+      }
+      @media only screen and (min-width: 1240px) {
+        :host {
+          font-size: 12px;
+        }
+      }
+      @media only screen and (min-width: 1340px) {
+        :host {
+          font-size: 13px;
+        }
+      }
+      @media only screen and (min-width: 1450px) {
+        :host {
+          font-size: 14px;
+        }
+        .project {
+          width: 20em;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-settings-styles.html b/polygerrit-ui/app/styles/gr-settings-styles.html
new file mode 100644
index 0000000..fcda1b4
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-settings-styles.html
@@ -0,0 +1,58 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-settings-styles">
+  <template>
+    <style>
+      .gr-settings-styles fieldset {
+        border: none;
+        margin: 0 0 2em 2em;
+      }
+      .gr-settings-styles section {
+        margin-bottom: .5em;
+      }
+      .gr-settings-styles .title,
+      .gr-settings-styles .value {
+        display: inline-block;
+        vertical-align: top;
+      }
+      .gr-settings-styles .title {
+        color: #666;
+        font-weight: bold;
+        padding-right: .5em;
+        width: 11em;
+      }
+      .gr-settings-styles input {
+        font-size: 1em;
+      }
+      .gr-settings-styles th {
+        color: #666;
+        text-align: left;
+      }
+      .gr-settings-styles tbody tr:nth-child(even) {
+        background-color: #f4f4f4;
+      }
+      @media only screen and (max-width: 40em) {
+        .gr-settings-styles section {
+          margin-bottom: 1em;
+        }
+        .gr-settings-styles .title,
+        .gr-settings-styles .value {
+          display: block;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
new file mode 100644
index 0000000..39be0ff
--- /dev/null
+++ b/polygerrit-ui/app/styles/main.css
@@ -0,0 +1,40 @@
+/*
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+*,
+*::after,
+*::before {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+html {
+  -webkit-text-size-adjust: none;
+}
+html,
+body {
+  height: 100%;
+  transition: none; /* Override the default Polymer fade-in. */
+}
+body {
+  /*
+   * IE has shoddy support for the font shorthand property.
+   * Work around this using font-size and font-family.
+   */
+  font-size: 13px;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  line-height: 1.4;
+}
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
new file mode 100644
index 0000000..d3cb316
--- /dev/null
+++ b/polygerrit-ui/app/test/index.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>Elements Test Runner</title>
+<meta charset="utf-8">
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../bower_components/web-component-tester/browser.js"></script>
+<script>
+  var testFiles = [];
+  var basePath = '../elements/';
+
+  [
+    'change-list/gr-change-list-item/gr-change-list-item_test.html',
+    'change-list/gr-change-list/gr-change-list_test.html',
+    'change/gr-account-entry/gr-account-entry_test.html',
+    'change/gr-account-list/gr-account-list_test.html',
+    'change/gr-change-actions/gr-change-actions_test.html',
+    'change/gr-change-metadata/gr-change-metadata_test.html',
+    'change/gr-change-view/gr-change-view_test.html',
+    'change/gr-comment-list/gr-comment-list_test.html',
+    'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
+    'change/gr-download-dialog/gr-download-dialog_test.html',
+    'change/gr-file-list/gr-file-list_test.html',
+    'change/gr-message/gr-message_test.html',
+    'change/gr-messages-list/gr-messages-list_test.html',
+    'change/gr-related-changes-list/gr-related-changes-list_test.html',
+    'change/gr-reply-dialog/gr-reply-dialog_test.html',
+    'change/gr-reviewer-list/gr-reviewer-list_test.html',
+    'core/gr-account-dropdown/gr-account-dropdown_test.html',
+    'core/gr-error-manager/gr-error-manager_test.html',
+    'core/gr-main-header/gr-main-header_test.html',
+    'core/gr-search-bar/gr-search-bar_test.html',
+    'diff/gr-diff-builder/gr-diff-builder_test.html',
+    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
+    'diff/gr-diff-comment/gr-diff-comment_test.html',
+    'diff/gr-diff-cursor/gr-diff-cursor_test.html',
+    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
+    'diff/gr-diff-highlight/gr-annotation_test.html',
+    'diff/gr-diff-preferences/gr-diff-preferences_test.html',
+    'diff/gr-diff-processor/gr-diff-processor_test.html',
+    'diff/gr-diff-selection/gr-diff-selection_test.html',
+    'diff/gr-diff-view/gr-diff-view_test.html',
+    'diff/gr-diff/gr-diff-group_test.html',
+    'diff/gr-diff/gr-diff_test.html',
+    'diff/gr-patch-range-select/gr-patch-range-select_test.html',
+    'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
+    'diff/gr-selection-action-box/gr-selection-action-box_test.html',
+    'diff/gr-syntax-layer/gr-syntax-layer_test.html',
+    'settings/gr-account-info/gr-account-info_test.html',
+    'settings/gr-email-editor/gr-email-editor_test.html',
+    'settings/gr-group-list/gr-group-list_test.html',
+    'settings/gr-http-password/gr-http-password_test.html',
+    'settings/gr-menu-editor/gr-menu-editor_test.html',
+    'settings/gr-settings-view/gr-settings-view_test.html',
+    'settings/gr-ssh-editor/gr-ssh-editor_test.html',
+    'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
+    'shared/gr-autocomplete/gr-autocomplete_test.html',
+    'shared/gr-account-label/gr-account-label_test.html',
+    'shared/gr-account-link/gr-account-link_test.html',
+    'shared/gr-alert/gr-alert_test.html',
+    'shared/gr-avatar/gr-avatar_test.html',
+    'shared/gr-change-star/gr-change-star_test.html',
+    'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
+    'shared/gr-cursor-manager/gr-cursor-manager_test.html',
+    'shared/gr-date-formatter/gr-date-formatter_test.html',
+    'shared/gr-editable-content/gr-editable-content_test.html',
+    'shared/gr-editable-label/gr-editable-label_test.html',
+    'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
+    'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
+    'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-linked-text/gr-linked-text_test.html',
+    'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
+    'shared/gr-select/gr-select_test.html',
+    'shared/gr-storage/gr-storage_test.html',
+  ].forEach(function(file) {
+    file = basePath + file;
+    testFiles.push(file);
+    testFiles.push(file + '?dom=shadow');
+  });
+
+  WCT.loadSuites(testFiles);
+</script>
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
new file mode 100755
index 0000000..e6d782f
--- /dev/null
+++ b/polygerrit-ui/run-server.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+while [[ ! -f .buckconfig && "$PWD" != / ]]; do
+  cd ..
+done
+if [[ ! -f .buckconfig ]]; then
+  echo "$(basename "$0"): must be run from a gerrit checkout" 1>&2
+  exit 1
+fi
+
+buck build \
+  //polygerrit-ui/app:test_components \
+  //polygerrit-ui:fonts
+
+cd polygerrit-ui/app
+rm -rf bower_components
+unzip -q ../../buck-out/gen/polygerrit-ui/app/test_components/test_components.bower_components.zip
+rm -rf fonts
+unzip -q ../../buck-out/gen/polygerrit-ui/fonts/fonts.zip -d fonts
+cd ..
+exec go run server.go "$@"
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
new file mode 100644
index 0000000..cb6d236
--- /dev/null
+++ b/polygerrit-ui/server.go
@@ -0,0 +1,134 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"bufio"
+	"compress/gzip"
+	"errors"
+	"flag"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+)
+
+var (
+	restHost = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	port     = flag.String("port", ":8081", "Port to serve HTTP requests on")
+	prod     = flag.Bool("prod", false, "Serve production assets")
+)
+
+func main() {
+	flag.Parse()
+
+	if *prod {
+		http.Handle("/", http.FileServer(http.Dir("dist")))
+	} else {
+		http.Handle("/", http.FileServer(http.Dir("app")))
+	}
+
+	http.HandleFunc("/changes/", handleRESTProxy)
+	http.HandleFunc("/accounts/", handleRESTProxy)
+	http.HandleFunc("/config/", handleRESTProxy)
+	http.HandleFunc("/projects/", handleRESTProxy)
+	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
+	log.Println("Serving on port", *port)
+	log.Fatal(http.ListenAndServe(*port, &server{}))
+}
+
+func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	req := &http.Request{
+		Method: "GET",
+		URL: &url.URL{
+			Scheme:   "https",
+			Host:     *restHost,
+			Opaque:   r.URL.EscapedPath(),
+			RawQuery: r.URL.RawQuery,
+		},
+	}
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer res.Body.Close()
+	w.WriteHeader(res.StatusCode)
+	if _, err := io.Copy(w, res.Body); err != nil {
+		log.Println("Error copying response to ResponseWriter:", err)
+		return
+	}
+}
+
+func handleAccountDetail(w http.ResponseWriter, r *http.Request) {
+	http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+}
+
+type gzipResponseWriter struct {
+	io.WriteCloser
+	http.ResponseWriter
+}
+
+func newGzipResponseWriter(w http.ResponseWriter) *gzipResponseWriter {
+	gz := gzip.NewWriter(w)
+	return &gzipResponseWriter{WriteCloser: gz, ResponseWriter: w}
+}
+
+func (w gzipResponseWriter) Write(b []byte) (int, error) {
+	return w.WriteCloser.Write(b)
+}
+
+func (w gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	h, ok := w.ResponseWriter.(http.Hijacker)
+	if !ok {
+		return nil, nil, errors.New("gzipResponseWriter: ResponseWriter does not satisfy http.Hijacker interface")
+	}
+	return h.Hijack()
+}
+
+type server struct{}
+
+// Any path prefixes that should resolve to index.html.
+var (
+	fePaths    = []string{"/q/", "/c/", "/dashboard/"}
+	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
+)
+
+func (_ *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	log.Printf("%s %s %s %s\n", r.Proto, r.Method, r.RemoteAddr, r.URL)
+	for _, prefix := range fePaths {
+		if strings.HasPrefix(r.URL.Path, prefix) {
+			r.URL.Path = "/"
+			log.Println("Redirecting to /")
+			break
+		} else if match := issueNumRE.Find([]byte(r.URL.Path)); match != nil {
+			r.URL.Path = "/"
+			log.Println("Redirecting to /")
+			break
+		}
+	}
+	if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
+		http.DefaultServeMux.ServeHTTP(w, r)
+		return
+	}
+	w.Header().Set("Content-Encoding", "gzip")
+	gzw := newGzipResponseWriter(w)
+	defer gzw.Close()
+	http.DefaultServeMux.ServeHTTP(gzw, r)
+}
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/wct.conf.js
new file mode 100644
index 0000000..b6a6251
--- /dev/null
+++ b/polygerrit-ui/wct.conf.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+var ret = {
+  suites: ['app/test'],
+  webserver: {
+    pathMappings: []
+  }
+};
+
+var mapping = {};
+var rootPath = (__dirname).split(path.sep).slice(-1)[0];
+
+mapping['/components/' + rootPath  + '/app/bower_components'] = 'bower_components';
+
+ret.webserver.pathMappings.push(mapping);
+
+module.exports = ret;
diff --git a/tools/BUILD b/tools/BUILD
new file mode 100644
index 0000000..ff64faa
--- /dev/null
+++ b/tools/BUILD
@@ -0,0 +1,6 @@
+py_binary(
+  name = 'merge_jars',
+  srcs = ['merge_jars.py'],
+  main = 'merge_jars.py',
+  visibility = ['//visibility:public'],
+)
diff --git a/tools/build.defs b/tools/build.defs
index 893abba..3ea506c 100644
--- a/tools/build.defs
+++ b/tools/build.defs
@@ -61,15 +61,20 @@
   )
 
 def gerrit_war(name, ui = 'ui_optdbg', context = [], docs = False, visibility = []):
+  ui_deps = []
+  if ui:
+    if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
+      ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
+    if ui != 'polygerrit':
+      ui_deps.append('//gerrit-gwtui:%s' % ui)
   war(
     name = name,
     libs = LIBS + ['//gerrit-war:version'],
     pgmlibs = PGMLIBS,
-    context = [
+    context = ui_deps + context + [
       '//gerrit-main:main_bin',
       '//gerrit-war:webapp_assets',
-    ] + (['//gerrit-gwtui:' + ui] if ui else []) +
-    context,
+    ],
     docs = docs,
     visibility = visibility,
   )
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/bzl/BUILD
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
new file mode 100644
index 0000000..e67ee30
--- /dev/null
+++ b/tools/bzl/genrule2.bzl
@@ -0,0 +1,29 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Syntactic sugar for native genrule() rule:
+#   expose ROOT shell variable
+#   expose TMP shell variable
+#   accept single output
+
+def genrule2(out, cmd, **kwargs):
+  cmd = ' && '.join([
+    'ROOT=$$PWD',
+    'TMP=$$(mktemp -d)',
+    '(' + cmd + ')',
+  ])
+  native.genrule(
+    cmd = cmd,
+    outs = [out],
+    **kwargs)
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
new file mode 100644
index 0000000..d16cecd
--- /dev/null
+++ b/tools/bzl/gwt.bzl
@@ -0,0 +1,28 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# GWT Rules Skylark rules for building [GWT](http://www.gwtproject.org/)
+# modules using Bazel.
+load('//tools/bzl:java.bzl', 'java_library2')
+
+def gwt_module(gwt_xml=None, resources=[], srcs=[], **kwargs):
+  if gwt_xml:
+    resources = resources + [gwt_xml]
+  if srcs:
+    resources = resources + srcs
+
+  java_library2(
+    srcs = srcs,
+    resources = resources,
+    **kwargs)
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
new file mode 100644
index 0000000..5fca724
--- /dev/null
+++ b/tools/bzl/java.bzl
@@ -0,0 +1,25 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Syntactic sugar for native java_library() rule:
+#   accept exported_deps attributes
+
+def java_library2(deps=[], exported_deps=[], exports=[], **kwargs):
+  if exported_deps:
+    deps = deps + exported_deps
+    exports = exports + exported_deps
+  native.java_library(
+    deps = deps,
+    exports = exports,
+    **kwargs)
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
new file mode 100644
index 0000000..19974a7
--- /dev/null
+++ b/tools/bzl/junit.bzl
@@ -0,0 +1,73 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Skylark rule to generate a Junit4 TestSuite
+# Assumes srcs are all .java Test files
+# Assumes junit4 is already added to deps by the user.
+
+# See https://github.com/bazelbuild/bazel/issues/1017 for background.
+
+_OUTPUT = """import org.junit.runners.Suite;
+import org.junit.runner.RunWith;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({%s})
+public class %s {}
+"""
+
+_PREFIXES = ("org", "com", "edu")
+
+def _SafeIndex(l, val):
+    for i, v in enumerate(l):
+        if val == v:
+            return i
+    return -1
+
+def _AsClassName(fname):
+    fname = [x.path for x in fname.files][0]
+    toks = fname[:-5].split("/")
+    findex = -1
+    for s in _PREFIXES:
+        findex = _SafeIndex(toks, s)
+        if findex != -1:
+            break
+    if findex == -1:
+        fail("%s does not contain any of %s",
+                         fname, _PREFIXES)
+    return ".".join(toks[findex:]) + ".class"
+
+def _impl(ctx):
+    classes = ",".join(
+        [_AsClassName(x) for x in ctx.attr.srcs])
+    ctx.file_action(output=ctx.outputs.out, content=_OUTPUT % (
+            classes, ctx.attr.outname))
+
+_GenSuite = rule(
+    attrs = {
+        "srcs": attr.label_list(allow_files = True),
+        "outname": attr.string(),
+    },
+    outputs = {"out": "%{name}.java"},
+    implementation = _impl,
+)
+
+def junit_tests(name, srcs, **kwargs):
+    s_name = name + "TestSuite"
+    _GenSuite(name = s_name,
+              srcs = srcs,
+              outname = s_name)
+    native.java_test(name = name,
+                     test_class = s_name,
+                     srcs = srcs + [":"+s_name],
+                     **kwargs)
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
new file mode 100644
index 0000000..ce2f483
--- /dev/null
+++ b/tools/bzl/maven.bzl
@@ -0,0 +1,35 @@
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Merge maven files
+
+def cmd(jars):
+  return ('$(location //tools:merge_jars) $@ '
+          + ' '.join(['$(location %s)' % j for j in jars]))
+
+def merge_maven_jars(
+    name,
+    srcs,
+    visibility = []):
+  native.genrule(
+    name = '%s__merged_bin' % name,
+    cmd = cmd(srcs),
+    tools = srcs + ['//tools:merge_jars'],
+    outs = ['%s__merged.jar' % name],
+  )
+  native.java_import(
+    name = name,
+    jars = [':%s__merged_bin' % name],
+    visibility = visibility,
+  )
diff --git a/tools/checkstyle.xml b/tools/checkstyle.xml
index 1bb40f7..cb24e8f 100644
--- a/tools/checkstyle.xml
+++ b/tools/checkstyle.xml
@@ -93,6 +93,10 @@
       <property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR "/>
       <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
     </module>
+    <module name="RedundantImport"/>
+    <module name="RedundantModifier"/>
+    <module name="ExplicitInitialization"/>
+    <module name="ArrayTrailingComma"/>
   </module>
   <module name="FileTabCharacter">
     <property name="severity" value="ignore"/>
diff --git a/tools/default.defs b/tools/default.defs
index 90096b2..191dfe5 100644
--- a/tools/default.defs
+++ b/tools/default.defs
@@ -27,7 +27,6 @@
 # Set defaults on java rules:
 #  - Add AutoValue annotation processing support.
 #  - Treat source files as UTF-8.
-#  - std_out_log_level = info (the default is too spammy)
 
 _buck_java_library = java_library
 def java_library(*args, **kwargs):
@@ -37,9 +36,9 @@
 _buck_java_test = java_test
 def java_test(*args, **kwargs):
   _munge_args(kwargs)
-  _do_not_spam_std_out(kwargs)
   _buck_java_test(*args, **kwargs)
 
+
 # Munge kwargs to set Gerrit-specific defaults.
 def _munge_args(kwargs):
   _set_auto_value(kwargs)
@@ -57,11 +56,6 @@
 
   extra_args.extend(['-encoding', 'UTF-8'])
 
-def _do_not_spam_std_out(kwargs):
-  level = 'std_out_log_level'
-  if level not in kwargs:
-    kwargs[level] = 'INFO'
-
 def _set_auto_value(kwargs):
   apk = 'annotation_processors'
   if apk not in kwargs:
@@ -79,6 +73,18 @@
     apds.extend(AUTO_VALUE_PROCESSOR_DEPS)
 
 
+# Add 'license' argument to genrule.
+_buck_genrule = genrule
+def genrule(*args, **kwargs):
+  license = kwargs.pop('license', None)
+  if license:
+    license = '//lib:LICENSE-%s' % license
+    # genrule has no deps attribute, but locations listed in the command show
+    # up as deps of the target with buck audit.
+    kwargs['cmd'] = 'true $(location %s); %s' % (license, kwargs['cmd'])
+  _buck_genrule(*args, **kwargs)
+
+
 def genantlr(
     name,
     srcs,
diff --git a/tools/download_file.py b/tools/download_file.py
index 97d982f..bd67b50 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -21,7 +21,7 @@
 import shutil
 from subprocess import check_call, CalledProcessError
 from sys import stderr
-from util import resolve_url
+from util import hash_file, resolve_url
 from zipfile import ZipFile, BadZipfile, LargeZipFile
 
 GERRIT_HOME = path.expanduser('~/.gerritcodereview')
@@ -33,17 +33,6 @@
 LOCAL_PROPERTIES = 'local.properties'
 
 
-def hashfile(p):
-  d = sha1()
-  with open(p, 'rb') as f:
-    while True:
-      b = f.read(8192)
-      if not b:
-        break
-      d.update(b)
-  return d.hexdigest()
-
-
 def safe_mkdirs(d):
   if path.isdir(d):
     return
@@ -148,7 +137,7 @@
     exit(1)
 
 if args.v:
-  have = hashfile(cache_ent)
+  have = hash_file(sha1(), cache_ent).hexdigest()
   if args.v != have:
     print((
       '%s:\n' +
diff --git a/tools/eclipse/BUCK b/tools/eclipse/BUCK
index 1e13515..0bcde9d 100644
--- a/tools/eclipse/BUCK
+++ b/tools/eclipse/BUCK
@@ -25,6 +25,7 @@
     '//lib/gwt:javax-validation_src',
     '//lib/jetty:servlets',
     '//lib/prolog:compiler_lib',
+    '//polygerrit-ui:polygerrit_components',
     '//Documentation:index_lib',
   ] + scan_plugins(),
 )
diff --git a/tools/eclipse/gerrit_gwt_debug.launch b/tools/eclipse/gerrit_gwt_debug.launch
index c3c58ff..b2ab320 100644
--- a/tools/eclipse/gerrit_gwt_debug.launch
+++ b/tools/eclipse/gerrit_gwt_debug.launch
@@ -16,7 +16,7 @@
 </listAttribute>
 <booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gerrit.gwtdebug.GerritGwtDebugLauncher"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/buck-out/gen/gerrit-gwtui com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/buck-out/gen/gerrit-gwtui com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx1024M&#10;-XX:MaxPermSize=256M&#10;-Dgerrit.disable-gwtui-recompile=true"/>
 </launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 754c1c8..46f5680 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -36,9 +36,14 @@
   ROOT = path.dirname(ROOT)
 
 opts = OptionParser()
-opts.add_option('--src', action='store_true')
+opts.add_option('--src', action='store_true',
+                help='(deprecated) attach sources')
+opts.add_option('--no-src', dest='no_src', action='store_true',
+                help='do not attach sources')
 opts.add_option('--plugins', help='create eclipse projects for plugins',
                 action='store_true')
+opts.add_option('--name', help='name of the generated project',
+                action='store', default='gerrit', dest='project_name')
 args, _ = opts.parse_args()
 
 def _query_classpath(targets):
@@ -76,7 +81,7 @@
     if path.exists(path.join(root, 'src', 'test', 'java')):
       testpath = """
   <classpathentry kind="src" path="src/test/java"\
- out="buck-out/eclipse/test"/>"""
+ out="eclipse-out/test"/>"""
     else:
       testpath = ""
     print("""\
@@ -85,7 +90,7 @@
   <classpathentry kind="src" path="src/main/java"/>%(testpath)s
   <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
   <classpathentry combineaccessrules="false" kind="src" path="/gerrit"/>
-  <classpathentry kind="output" path="buck-out/eclipse/classes"/>
+  <classpathentry kind="output" path="eclipse-out/classes"/>
 </classpath>""" % {"testpath": testpath}, file=fd)
 
 def gen_classpath():
@@ -112,7 +117,8 @@
   gwt_lib = set()
   plugins = set()
 
-  java_library = re.compile(r'[^/]+/gen/(.*)/lib__[^/]+__output/[^/]+[.]jar$')
+  # Classpath entries are absolute for cross-cell support
+  java_library = re.compile('.*/buck-out/gen/(.*)/lib__[^/]+__output/[^/]+[.]jar$')
   for p in _query_classpath(MAIN):
     if p.endswith('-src.jar'):
       # gwt_module() depends on -src.jar for Java to JavaScript compiles.
@@ -141,12 +147,12 @@
     out = None
 
     if s.startswith('lib/'):
-      out = 'buck-out/eclipse/lib'
+      out = 'eclipse-out/lib'
     elif s.startswith('plugins/'):
       if args.plugins:
         plugins.add(s)
         continue
-      out = 'buck-out/eclipse/' + s
+      out = 'eclipse-out/' + s
 
     p = path.join(s, 'java')
     if path.exists(p):
@@ -158,7 +164,7 @@
       if out:
         o = out + '/' + env
       elif env == 'test':
-        o = 'buck-out/eclipse/test'
+        o = 'eclipse-out/test'
 
       for srctype in ['java', 'resources']:
         p = path.join(s, 'src', env, srctype)
@@ -179,10 +185,10 @@
   for s in sorted(gwt_src):
     p = path.join(ROOT, s, 'src', 'main', 'java')
     if path.exists(p):
-      classpathentry('lib', p, out='buck-out/eclipse/gwtsrc')
+      classpathentry('lib', p, out='eclipse-out/gwtsrc')
 
   classpathentry('con', JRE)
-  classpathentry('output', 'buck-out/eclipse/classes')
+  classpathentry('output', 'eclipse-out/classes')
 
   p = path.join(ROOT, '.classpath')
   with open(p, 'w') as fd:
@@ -213,13 +219,13 @@
     doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
 
 try:
-  if args.src:
+  if not args.no_src:
     try:
       check_call([path.join(ROOT, 'tools', 'download_all.py'), '--src'])
     except CalledProcessError as err:
       exit(1)
 
-  gen_project()
+  gen_project(args.project_name)
   gen_classpath()
   gen_factorypath()
 
diff --git a/tools/java_doc.defs b/tools/java_doc.defs
index 514a730..41a8730 100644
--- a/tools/java_doc.defs
+++ b/tools/java_doc.defs
@@ -7,11 +7,13 @@
     deps = [],
     visibility = [],
     do_it_wrong = False,
+    external_docs = [],
   ):
   if do_it_wrong:
     sourcepath = paths
   else:
     sourcepath = ['$SRCDIR/' + n for n in paths]
+  external_docs.insert(0, 'http://docs.oracle.com/javase/7/docs/api')
   genrule(
     name = name,
     cmd = ' '.join([
@@ -23,13 +25,13 @@
       '-charset UTF-8',
       '-notimestamp',
       '-windowtitle "' + title + '"',
-      '-link http://docs.oracle.com/javase/7/docs/api',
+      ' '.join(['-link %s' % url for url in external_docs]),
       '-subpackages ',
       ':'.join(pkgs),
       '-sourcepath ',
       ':'.join(sourcepath),
       ' -classpath ',
-      ':'.join(['$(location %s)' % n for n in deps]),
+      ':'.join(['$(classpath %s)' % n for n in deps]),
       '-d $TMP',
     ]) + ';jar cf $OUT -C $TMP .',
     srcs = srcs,
diff --git a/tools/js/BUCK b/tools/js/BUCK
new file mode 100644
index 0000000..ba4f19c
--- /dev/null
+++ b/tools/js/BUCK
@@ -0,0 +1,20 @@
+python_binary(
+  name = 'bower2buck',
+  main = 'bower2buck.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
+
+python_binary(
+  name = 'download_bower',
+  main = 'download_bower.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
+
+python_binary(
+  name = 'run_npm_binary',
+  main = 'run_npm_binary.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
diff --git a/tools/js/bower2buck.py b/tools/js/bower2buck.py
new file mode 100755
index 0000000..81072da
--- /dev/null
+++ b/tools/js/bower2buck.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import atexit
+import collections
+import json
+import hashlib
+import optparse
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from tools import util
+
+
+# This script is run with `buck run`, but needs to shell out to buck; this is
+# only possible if we avoid buckd.
+BUCK_ENV = dict(os.environ)
+BUCK_ENV['NO_BUCKD'] = '1'
+
+HEADER = """\
+include_defs('//lib/js.defs')
+
+# AUTOGENERATED BY BOWER2BUCK
+#
+# This file should be merged with an existing BUCK file containing these rules.
+#
+# This comment SHOULD NOT be copied to the existing BUCK file, and you should
+# leave alone any non-bower_component contents of the file.
+#
+# Generally, the following attributes SHOULD be copied from this file to the
+# existing BUCK file:
+#  - package: the normalized package name
+#  - version: the exact version number
+#  - deps: direct dependencies of the package
+#  - sha1: a hash of the package contents
+#
+# The following fields SHOULD NOT be copied to the existing BUCK file:
+#  - semver: manually-specified semantic version, not included in autogenerated
+#    output.
+#
+# The following fields require SPECIAL HANDLING:
+#  - license: all licenses in this file are specified as TODO. You must replace
+#    this text with one of the existing licenses defined in lib/BUCK, or
+#    define a new one if necessary. Leave existing licenses alone.
+
+"""
+
+
+def usage():
+  print(('Usage: %s -o <outfile> [//path/to:bower_components_rule...]'
+         % sys.argv[0]),
+        file=sys.stderr)
+  return 1
+
+
+class Rule(object):
+  def __init__(self, bower_json_path):
+    with open(bower_json_path) as f:
+      bower_json = json.load(f)
+    self.name = bower_json['name']
+    self.version = bower_json['version']
+    self.deps = bower_json.get('dependencies', {})
+    self.license = bower_json.get('license', 'NO LICENSE')
+    self.sha1 = util.hash_bower_component(
+        hashlib.sha1(), os.path.dirname(bower_json_path)).hexdigest()
+
+  def to_rule(self, packages):
+    if self.name not in packages:
+      raise ValueError('No package name found for %s' % self.name)
+
+    lines = [
+        'bower_component(',
+        "  name = '%s'," % self.name,
+        "  package = '%s'," % packages[self.name],
+        "  version = '%s'," % self.version,
+        ]
+    if self.deps:
+      if len(self.deps) == 1:
+        lines.append("  deps = [':%s']," % next(self.deps.iterkeys()))
+      else:
+        lines.append('  deps = [')
+        lines.extend("    ':%s'," % d for d in sorted(self.deps.iterkeys()))
+        lines.append('  ],')
+    lines.extend([
+        "  license = 'TODO: %s'," % self.license,
+        "  sha1 = '%s'," % self.sha1,
+        ')'])
+    return '\n'.join(lines)
+
+
+def build_bower_json(targets, buck_out):
+  bower_json = collections.OrderedDict()
+  bower_json['name'] = 'bower2buck-output'
+  bower_json['version'] = '0.0.0'
+  bower_json['description'] = 'Auto-generated bower.json for dependency management'
+  bower_json['private'] = True
+  bower_json['dependencies'] = {}
+
+  deps = subprocess.check_output(
+      ['buck', 'query', '-v', '0',
+       "filter('__download_bower', deps(%s))" % '+'.join(targets)],
+      env=BUCK_ENV)
+  deps = deps.replace('__download_bower', '__bower_version').split()
+  subprocess.check_call(['buck', 'build'] + deps, env=BUCK_ENV)
+
+  for dep in deps:
+    dep = dep.replace(':', '/').lstrip('/')
+    depout = os.path.basename(dep)
+    version_json = os.path.join(buck_out, 'gen', dep, depout)
+    with open(version_json) as f:
+      bower_json['dependencies'].update(json.load(f))
+
+  tmpdir = tempfile.mkdtemp()
+  atexit.register(lambda: shutil.rmtree(tmpdir))
+  ret = os.path.join(tmpdir, 'bower.json')
+  with open(ret, 'w') as f:
+    json.dump(bower_json, f, indent=2)
+  return ret
+
+
+def get_package_name(name, package_version):
+  v = package_version.lower()
+  if '#' in v:
+    return v[:v.find('#')]
+  return name
+
+
+def get_packages(path):
+  with open(path) as f:
+    bower_json = json.load(f)
+  return dict((n, get_package_name(n, v))
+              for n, v in bower_json.get('dependencies', {}).iteritems())
+
+
+def collect_rules(packages):
+  # TODO(dborowitz): Use run_npm_binary instead of system bower.
+  rules = {}
+  subprocess.check_call(['bower', 'install'])
+  for dirpath, dirnames, filenames in os.walk('.', topdown=True):
+    if '.bower.json' not in filenames:
+      continue
+    del dirnames[:]
+    rule = Rule(os.path.join(dirpath, '.bower.json'))
+    rules[rule.name] = rule
+
+    # Oddly, the package name referred to in the deps section of dependents,
+    # e.g. 'PolymerElements/iron-ajax', is not found anywhere in this
+    # bower.json, which only contains 'iron-ajax'. Build up a map of short name
+    # to package name so we can resolve them later.
+    # TODO(dborowitz): We can do better:
+    #  - Infer 'user/package' from GitHub URLs (i.e. a simple subset of Bower's package
+    #    resolution logic).
+    #  - Resolve aliases using https://bower.herokuapp.com/packages/shortname
+    #    (not currently biting us but it might in the future.)
+    for n, v in rule.deps.iteritems():
+      p = get_package_name(n, v)
+      old = packages.get(n)
+      if old is not None and old != p:
+        raise ValueError('multiple packages named %s: %s != %s' % (n, p, old))
+      packages[n] = p
+
+  return rules
+
+
+def find_buck_out():
+  dir = os.getcwd()
+  while not os.path.isfile(os.path.join(dir, '.buckconfig')):
+    dir = os.path.dirname(dir)
+  return os.path.join(dir, 'buck-out')
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-o', help='output file location')
+  opts, args = opts.parse_args()
+
+  if not opts.o or not all(a.startswith('//') for a in args):
+    return usage()
+  outfile = os.path.abspath(opts.o)
+  buck_out = find_buck_out()
+
+  targets = args if args else ['//polygerrit-ui/...']
+  bower_json_path = build_bower_json(targets, buck_out)
+  os.chdir(os.path.dirname(bower_json_path))
+  packages = get_packages(bower_json_path)
+  rules = collect_rules(packages)
+
+  with open(outfile, 'w') as f:
+    f.write(HEADER)
+    for _, r in sorted(rules.iteritems()):
+      f.write('\n\n%s' % r.to_rule(packages))
+
+  print('Wrote bower_components rules to:\n  %s' % outfile)
+
+
+if __name__ == '__main__':
+  main(sys.argv[1:])
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
new file mode 100644
index 0000000..bcc417c
--- /dev/null
+++ b/tools/js/download_bower.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import hashlib
+import json
+import optparse
+import os
+import shutil
+import subprocess
+import sys
+
+from tools import util
+
+
+CACHE_DIR = os.path.expanduser(os.path.join(
+    '~', '.gerritcodereview', 'buck-cache', 'downloaded-artifacts'))
+
+
+def bower_cmd(bower, *args):
+  cmd = bower.split(' ')
+  cmd.extend(args)
+  return cmd
+
+
+def bower_info(bower, name, package, version):
+  cmd = bower_cmd(bower, '-l=error', '-j',
+                  'info', '%s#%s' % (package, version))
+  p = subprocess.Popen(cmd , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  out, err = p.communicate()
+  if p.returncode:
+    sys.stderr.write(err)
+    raise OSError('Command failed: %s' % cmd)
+
+  try:
+    info = json.loads(out)
+  except ValueError:
+    raise ValueError('invalid JSON from %s:\n%s' % (cmd, out))
+  info_name = info.get('name')
+  if info_name != name:
+    raise ValueError('expected package name %s, got: %s' % (name, info_name))
+  return info
+
+
+def ignore_deps(info):
+  # Tell bower to ignore dependencies so we just download this component. This
+  # is just an optimization, since we only pick out the component we need, but
+  # it's important when downloading sizable dependency trees.
+  #
+  # As of 1.6.5 I don't think ignoredDependencies can be specified on the
+  # command line with --config, so we have to create .bowerrc.
+  deps = info.get('dependencies')
+  if deps:
+    with open(os.path.join('.bowerrc'), 'w') as f:
+      json.dump({'ignoredDependencies': deps.keys()}, f)
+
+
+def cache_entry(name, package, version, sha1):
+  if not sha1:
+    sha1 = hashlib.sha1('%s#%s' % (package, version)).hexdigest()
+  return os.path.join(CACHE_DIR, '%s-%s.zip-%s' % (name, version, sha1))
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-n', help='short name of component')
+  opts.add_option('-b', help='bower command')
+  opts.add_option('-p', help='full package name of component')
+  opts.add_option('-v', help='version number')
+  opts.add_option('-s', help='expected content sha1')
+  opts.add_option('-o', help='output file location')
+  opts, _ = opts.parse_args()
+
+  cwd = os.getcwd()
+  outzip = os.path.join(cwd, opts.o)
+  cached = cache_entry(opts.n, opts.p, opts.v, opts.s)
+
+  if not os.path.exists(cached):
+    info = bower_info(opts.b, opts.n, opts.p, opts.v)
+    ignore_deps(info)
+    subprocess.check_call(
+        bower_cmd(opts.b, '--quiet', 'install', '%s#%s' % (opts.p, opts.v)))
+    bc = os.path.join(cwd, 'bower_components')
+    subprocess.check_call(
+        ['zip', '-q', '--exclude', '.bower.json', '-r', cached, opts.n],
+        cwd=bc)
+
+    if opts.s:
+      path = os.path.join(bc, opts.n)
+      sha1 = util.hash_bower_component(hashlib.sha1(), path).hexdigest()
+      if opts.s != sha1:
+        print((
+          '%s#%s:\n'
+          'expected %s\n'
+          'received %s\n') % (opts.p, opts.v, opts.s, sha1), file=sys.stderr)
+        try:
+          os.remove(cached)
+        except OSError as err:
+          if path.exists(cached):
+            print('error removing %s: %s' % (cached, err), file=sys.stderr)
+        return 1
+
+  shutil.copyfile(cached, outzip)
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
new file mode 100755
index 0000000..9eb6e34
--- /dev/null
+++ b/tools/js/npm_pack.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import atexit
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+
+
+def is_bundled(tar):
+  # No entries for directories, so scan for a matching prefix.
+  for entry in tar.getmembers():
+    if entry.name.startswith('package/node_modules/'):
+      return True
+  return False
+
+
+def bundle_dependencies():
+  with open('package.json') as f:
+    package = json.load(f)
+  package['bundledDependencies'] = package['dependencies'].keys()
+  with open('package.json', 'w') as f:
+    json.dump(package, f)
+
+
+def main(args):
+  if len(args) != 2:
+    print('Usage: %s <package> <version>' % sys.argv[0], file=sys.stderr)
+    return 1
+
+  name, version = args
+  filename = '%s-%s.tgz' % (name, version)
+  url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+
+  tmpdir = tempfile.mkdtemp();
+  tgz = os.path.join(tmpdir, filename)
+  atexit.register(lambda: shutil.rmtree(tmpdir))
+
+  subprocess.check_call(['curl', '--proxy-anyauth', '-ksfo', tgz, url])
+  with tarfile.open(tgz, 'r:gz') as tar:
+    if is_bundled(tar):
+      print('%s already has bundled node_modules' % filename)
+      return 1
+    tar.extractall(path=tmpdir)
+
+  oldpwd = os.getcwd()
+  os.chdir(os.path.join(tmpdir, 'package'))
+  bundle_dependencies()
+  subprocess.check_call(['npm', 'install'])
+  subprocess.check_call(['npm', 'pack'])
+  shutil.copy(filename, os.path.join(oldpwd, filename))
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
new file mode 100644
index 0000000..d76eff5
--- /dev/null
+++ b/tools/js/run_npm_binary.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import atexit
+from distutils import spawn
+import hashlib
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+
+from tools import util
+
+
+def extract(path, outdir, bin):
+  if os.path.exists(os.path.join(outdir, bin)):
+    return # Another process finished extracting, ignore.
+
+  # Use a temp directory adjacent to outdir so shutil.move can use the same
+  # device atomically.
+  tmpdir = tempfile.mkdtemp(dir=os.path.dirname(outdir))
+  def cleanup():
+    try:
+      shutil.rmtree(tmpdir)
+    except OSError:
+      pass # Too late now
+  atexit.register(cleanup)
+
+  def extract_one(mem):
+    dest = os.path.join(outdir, mem.name)
+    tar.extract(mem, path=tmpdir)
+    try:
+      os.makedirs(os.path.dirname(dest))
+    except OSError:
+      pass # Either exists, or will fail on the next line.
+    shutil.move(os.path.join(tmpdir, mem.name), dest)
+
+  with tarfile.open(path, 'r:gz') as tar:
+    for mem in tar.getmembers():
+      if mem.name != bin:
+        extract_one(mem)
+    # Extract bin last so other processes only short circuit when extraction is
+    # finished.
+    extract_one(tar.getmember(bin))
+
+
+def main(args):
+  path = args[0]
+  suffix = '.npm_binary.tgz'
+  tgz = os.path.basename(path)
+  parts = tgz[:-len(suffix)].split('@')
+
+  if not tgz.endswith(suffix) or len(parts) != 2:
+    print('usage: %s <path/to/npm_binary>' % sys.argv[0], file=sys.stderr)
+    return 1
+
+  name, version = parts
+  sha1 = util.hash_file(hashlib.sha1(), path).hexdigest()
+  outdir = '%s-%s' % (path[:-len(suffix)], sha1)
+  rel_bin = os.path.join('package', 'bin', name)
+  bin = os.path.join(outdir, rel_bin)
+  if not os.path.isfile(bin):
+    extract(path, outdir, rel_bin)
+
+  nodejs = spawn.find_executable('nodejs')
+  if nodejs:
+    # Debian installs Node.js as 'nodejs', due to a conflict with another
+    # package.
+    subprocess.check_call([nodejs, bin] + args[1:])
+  else:
+    subprocess.check_call([bin] + args[1:])
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/maven/BUCK b/tools/maven/BUCK
index fcd77c0..322b5a2 100644
--- a/tools/maven/BUCK
+++ b/tools/maven/BUCK
@@ -31,8 +31,3 @@
   },
   war = {'gerrit-war': '//:release'},
 )
-
-python_binary(
-  name = 'mvn',
-  main = 'mvn.py',
-)
diff --git a/tools/maven/api.py b/tools/maven/api.py
deleted file mode 100755
index 600de6a..0000000
--- a/tools/maven/api.py
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2015 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from __future__ import print_function
-from argparse import ArgumentParser
-from json import loads
-from os import environ, path, remove
-from subprocess import check_call, check_output, Popen, PIPE
-from sys import stderr
-from tempfile import mkstemp
-
-
-def locations():
-  d = Popen('buck audit dependencies api'.split(),
-            stdin=None, stdout=PIPE, stderr=PIPE)
-  t = Popen('xargs buck targets --show_output'.split(),
-            stdin=d.stdout, stdout=PIPE, stderr=PIPE)
-  out = t.communicate()[0]
-  d.wait()
-  targets = []
-  outs = []
-  for e in out.strip().split('\n'):
-    t, o = e.split()
-    targets.append(t)
-    outs.append(o)
-  return dict(zip(targets, outs))
-
-parser = ArgumentParser()
-parser.add_argument('-n', '--dryrun', action='store_true')
-parser.add_argument('-v', '--verbose', action='store_true')
-
-subparsers = parser.add_subparsers(help='action', dest='action')
-subparsers.add_parser('deploy', help='Deploy to Maven (remote)')
-subparsers.add_parser('install', help='Install to Maven (local)')
-
-args = parser.parse_args()
-
-root = path.abspath(__file__)
-while not path.exists(path.join(root, '.buckconfig')):
-  root = path.dirname(root)
-
-if not args.dryrun:
-  check_call('buck build api'.split())
-target = check_output(('buck targets --json api_%s' % args.action).split())
-
-s = loads(target)[0]['cmd']
-
-fd, tempfile = mkstemp()
-s = s.replace('$(exe //tools/maven:mvn)', path.join(root, 'tools/maven/mvn.py'))
-s = s.replace('-o $OUT', '-o %s' % tempfile)
-
-locations = locations()
-
-while '$(location' in s:
-  start = s.index('$(location')
-  end = s.index(')', start)
-  target = s[start+11:end]
-  s = s.replace(s[start:end+1], locations[target])
-
-try:
-  if args.verbose or args.dryrun or environ.get('VERBOSE'):
-    print(s, file=stderr)
-  if not args.dryrun:
-    check_call(s.split())
-finally:
-  remove(tempfile)
diff --git a/tools/maven/api.sh b/tools/maven/api.sh
new file mode 100755
index 0000000..c7ce65e
--- /dev/null
+++ b/tools/maven/api.sh
@@ -0,0 +1,69 @@
+#!/bin/bash -e
+
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+if [[ "$#" == "0" ]] ; then
+  cat <<EOF
+Usage: run "$0 COMMAND" from the top of your workspace, where
+COMMAND is one of
+
+  install
+  deploy
+  war_install
+  war_deploy
+
+Set VERBOSE in the environment to get more information.
+
+EOF
+
+  exit 1
+fi
+
+set -o errexit
+set -o nounset
+
+
+case "$1" in
+install)
+    command="api_install"
+    ;;
+deploy)
+    command="api_deploy"
+    ;;
+war_install)
+    command="war_install"
+    ;;
+war_deploy)
+    command="war_deploy"
+    ;;
+*)
+    echo "unknown command $1"
+    exit 1
+    ;;
+esac
+
+if [[ "${VERBOSE:-x}" != "x" ]]; then
+  set -o xtrace
+fi
+
+buck build //tools/maven:gen_${command} || \
+  { echo "buck failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
+
+script="./buck-out/gen/tools/maven/gen_${command}/${command}.sh"
+
+# The PEX wrapper does some funky exit handling, so even if the script
+# does "exit(0)", the return status is '1'. So we can't tell if the
+# following invocation was successful.
+${script}
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index 7017406..4011d71 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -65,10 +65,16 @@
       print(' '.join(exe), file=stderr)
     check_output(exe)
   except Exception as e:
-    print('%s command failed: %s' % (args.a, e), file=stderr)
+    print('%s command failed: %s\n%s' % (args.a, ' '.join(exe), e),
+      file=stderr)
     exit(1)
 
-with open(args.o, 'w') as fd:
+
+out = stderr
+if args.o:
+  out = open(args.o, 'w')
+
+with out as fd:
   if args.repository:
     print('Repository: %s' % args.repository, file=fd)
   if args.url:
diff --git a/tools/maven/package.defs b/tools/maven/package.defs
index 8fe9a13..c412ebd 100644
--- a/tools/maven/package.defs
+++ b/tools/maven/package.defs
@@ -12,6 +12,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+sh_bang_template = (' && '.join([
+  "echo '#!/bin/bash -eu' > $OUT",
+  'echo "# this script should run from the root of your workspace." >> $OUT',
+  'echo "" >> $OUT',
+  "echo 'if [[ -n \"$${VERBOSE:-}\" ]]; then set -x ; fi' >> $OUT",
+  'echo "" >> $OUT',
+  'echo %s >> $OUT',
+  'echo "" >> $OUT',
+  'echo %s >> $OUT',
+  # This is supposed to be handled by executable=True, but it doesn't
+  # work. Bug?
+  'chmod +x $OUT' ]))
+
 def maven_package(
     version,
     repository = None,
@@ -20,44 +33,63 @@
     src = {},
     doc = {},
     war = {}):
-  cmd = ['$(exe //tools/maven:mvn)', '-v', version, '-o', '$OUT']
-  api_cmd = []
+
+  build_cmd = ['buck', 'build']
+
+  # This is not using python_binary() to avoid the baggage and bugs
+  # that PEX brings along.
+  mvn_cmd = ['python', 'tools/maven/mvn.py', '-v', version]
+  api_cmd = mvn_cmd[:]
+  api_targets = []
   for type,d in [('jar', jar), ('java-source', src), ('javadoc', doc)]:
-    for a,t in d.iteritems():
+    for a,t in sorted(d.iteritems()):
       api_cmd.append('-s %s:%s:$(location %s)' % (a,type,t))
+      api_targets.append(t)
 
   genrule(
-    name = 'api_install',
-    cmd = ' '.join(cmd + api_cmd + ['-a', 'install']),
-    out = 'api_install.info',
+    name = 'gen_api_install',
+    cmd = sh_bang_template % (
+      ' '.join(build_cmd + api_targets),
+      ' '.join(api_cmd + ['-a', 'install'])),
+    out = 'api_install.sh',
+    executable = True,
   )
 
   if repository and url:
     genrule(
-      name = 'api_deploy',
-      cmd = ' '.join(cmd + api_cmd + [
-        '-a', 'deploy',
-        '--repository', repository,
-        '--url', url]),
-      out = 'api_deploy.info',
+      name = 'gen_api_deploy',
+      cmd = sh_bang_template % (
+        ' '.join(build_cmd + api_targets),
+        ' '.join(api_cmd + ['-a', 'deploy',
+                            '--repository', repository,
+                            '--url', url])),
+      out = 'api_deploy.sh',
+      executable = True,
     )
 
-  war_cmd = []
-  for a,t in war.iteritems():
+  war_cmd = mvn_cmd[:]
+  war_targets = []
+  for a,t in sorted(war.iteritems()):
     war_cmd.append('-s %s:war:$(location %s)' % (a,t))
+    war_targets.append(t)
 
   genrule(
-    name = 'war_install',
-    cmd = ' '.join(cmd + war_cmd + ['-a', 'install']),
-    out = 'war_install.info',
+    name = 'gen_war_install',
+    cmd = sh_bang_template % (' '.join(build_cmd + war_targets),
+                              ' '.join(war_cmd + ['-a', 'install'])),
+    out = 'war_install.sh',
+    executable = True,
   )
 
   if repository and url:
     genrule(
-      name = 'war_deploy',
-      cmd = ' '.join(cmd + war_cmd + [
+      name = 'gen_war_deploy',
+      cmd = sh_bang_template % (
+          ' '.join(build_cmd + war_targets),
+          ' '.join(war_cmd + [
         '-a', 'deploy',
         '--repository', repository,
-        '--url', url]),
-      out = 'war_deploy.info',
+        '--url', url])),
+      out = 'war_deploy.sh',
+      executable = True,
     )
diff --git a/tools/pack_war.py b/tools/pack_war.py
index 8525a56..ca21790 100755
--- a/tools/pack_war.py
+++ b/tools/pack_war.py
@@ -15,7 +15,7 @@
 
 from __future__ import print_function
 from optparse import OptionParser
-from os import chdir, makedirs, path, symlink
+from os import makedirs, path, symlink
 from subprocess import check_call
 import sys
 
@@ -27,23 +27,20 @@
 args, ctx = opts.parse_args()
 
 war = args.tmp
-root = war[:war.index('buck-out')]
 jars = set()
 
 def prune(l):
- return [j[j.find('buck-out'):] for e in l for j in e.split(':')]
+  return [j for e in l for j in e.split(':')]
 
 def link_jars(libs, directory):
   makedirs(directory)
-  while not path.isfile('.buckconfig'):
-    chdir('..')
   for j in libs:
     if j not in jars:
       jars.add(j)
       n = path.basename(j)
-      if j.startswith('buck-out/gen/gerrit-'):
-        n = j.split('/')[2] + '-' + n
-      symlink(path.join(root, j), path.join(directory, n))
+      if j.find('buck-out/gen/gerrit-') > 0:
+        n = j[j.find('buck-out'):].split('/')[2] + '-' + n
+      symlink(j, path.join(directory, n))
 
 if args.lib:
   link_jars(prune(args.lib), path.join(war, 'WEB-INF', 'lib'))
diff --git a/tools/plugin_archetype_deploy.sh b/tools/plugin_archetype_deploy.sh
index b5d1a66..b16ce95 100755
--- a/tools/plugin_archetype_deploy.sh
+++ b/tools/plugin_archetype_deploy.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 # Copyright (C) 2014 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -63,21 +63,9 @@
     -Dfile=target/$module-$ver.jar
 }
 
-function confirm
-{
-  read -n1 -p "Are you sure you want to deploy? [N/y]: " ready
-  if [[ ! $ready == [Yy] ]]; then
-    if [[ $ready == [Nn] || -z $ready ]]; then
-      echo; exit
-    else
-      echo; confirm
-    fi
-  fi
-}
-
 function run
 {
-  test ${dryRun:-'false'} == 'false'  && confirm
+  test ${dryRun:-'false'} == 'false'
   root=$(instroot)
   cd "$root"
   ver=$(getver GERRIT_VERSION)
diff --git a/tools/util.py b/tools/util.py
index ec895dd..08a803f 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
 from os import path
 
 REPO_ROOTS = {
@@ -19,6 +20,7 @@
   'GERRIT_API': 'https://gerrit-api.commondatastorage.googleapis.com/release',
   'MAVEN_CENTRAL': 'http://repo1.maven.org/maven2',
   'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
+  'MAVEN_SNAPSHOT': 'https://oss.sonatype.org/content/repositories/snapshots',
 }
 
 
@@ -49,3 +51,53 @@
   root = root.rstrip('/')
   rest = rest.lstrip('/')
   return '/'.join([root, rest])
+
+
+def hash_file(hash_obj, path):
+  """Hash the contents of a file.
+
+  Args:
+    hash_obj: an open hash object, e.g. hashlib.sha1().
+    path: path to the file to hash.
+
+  Returns:
+    The passed-in hash_obj.
+  """
+  with open(path, 'rb') as f:
+    while True:
+      b = f.read(8192)
+      if not b:
+        break
+      hash_obj.update(b)
+  return hash_obj
+
+
+def hash_bower_component(hash_obj, path):
+  """Hash the contents of a bower component directory.
+
+  This is a stable hash of a directory downloaded with `bower install`, minus
+  the .bower.json file, which is autogenerated each time by bower. Used in lieu
+  of hashing a zipfile of the contents, since zipfiles are difficult to hash in
+  a stable manner.
+
+  Args:
+    hash_obj: an open hash object, e.g. hashlib.sha1().
+    path: path to the directory to hash.
+
+  Returns:
+    The passed-in hash_obj.
+  """
+  if not os.path.isdir(path):
+    raise ValueError('Not a directory: %s' % path)
+
+  path = os.path.abspath(path)
+  for root, dirs, files in os.walk(path):
+    dirs.sort()
+    for f in sorted(files):
+      if f == '.bower.json':
+        continue
+      p = os.path.join(root, f)
+      hash_obj.update(p[len(path)+1:])
+      hash_file(hash_obj, p)
+
+  return hash_obj
diff --git a/tools/util/query_tester.sh b/tools/util/query_tester.sh
index 25646c2..db1680b 100755
--- a/tools/util/query_tester.sh
+++ b/tools/util/query_tester.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 
 usage() {
     cat <<EOF
diff --git a/website/releases/index.html b/website/releases/index.html
index 456f0f9..582b495 100644
--- a/website/releases/index.html
+++ b/website/releases/index.html
@@ -31,7 +31,7 @@
 
 <h1>Gerrit Code Review - Releases</h1>
 <a href="https://www.gerritcodereview.com/">
-  <img id="diffy_logo" src="https://gerrit-review.googlesource.com/static/diffy1.cache.png">
+  <img id="diffy_logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAtCAYAAADoSujCAAAABGdBTUEAALGPC/xhBQAACkFpQ0NQSUNDIFByb2ZpbGUAAEgNnZZ3VFPZFofPvTe90BIiICX0GnoJINI7SBUEUYlJgFAChoQmdkQFRhQRKVZkVMABR4ciY0UUC4OCYtcJ8hBQxsFRREXl3YxrCe+tNfPemv3HWd/Z57fX2Wfvfde6AFD8ggTCdFgBgDShWBTu68FcEhPLxPcCGBABDlgBwOFmZgRH+EQC1Py9PZmZqEjGs/buLoBku9ssv1Amc9b/f5EiN0MkBgAKRdU2PH4mF+UClFOzxRky/wTK9JUpMoYxMhahCaKsIuPEr2z2p+Yru8mYlybkoRpZzhm8NJ6Mu1DemiXho4wEoVyYJeBno3wHZb1USZoA5fco09P4nEwAMBSZX8znJqFsiTJFFBnuifICAAiUxDm8cg6L+TlongB4pmfkigSJSWKmEdeYaeXoyGb68bNT+WIxK5TDTeGIeEzP9LQMjjAXgK9vlkUBJVltmWiR7a0c7e1Z1uZo+b/Z3x5+U/09yHr7VfEm7M+eQYyeWd9s7KwvvRYA9iRamx2zvpVVALRtBkDl4axP7yAA8gUAtN6c8x6GbF6SxOIMJwuL7OxscwGfay4r6Df7n4Jvyr+GOfeZy+77VjumFz+BI0kVM2VF5aanpktEzMwMDpfPZP33EP/jwDlpzcnDLJyfwBfxhehVUeiUCYSJaLuFPIFYkC5kCoR/1eF/GDYnBxl+nWsUaHVfAH2FOVC4SQfIbz0AQyMDJG4/egJ961sQMQrIvrxorZGvc48yev7n+h8LXIpu4UxBIlPm9gyPZHIloiwZo9+EbMECEpAHdKAKNIEuMAIsYA0cgDNwA94gAISASBADlgMuSAJpQASyQT7YAApBMdgBdoNqcADUgXrQBE6CNnAGXARXwA1wCwyAR0AKhsFLMAHegWkIgvAQFaJBqpAWpA+ZQtYQG1oIeUNBUDgUA8VDiZAQkkD50CaoGCqDqqFDUD30I3Qaughdg/qgB9AgNAb9AX2EEZgC02EN2AC2gNmwOxwIR8LL4ER4FZwHF8Db4Uq4Fj4Ot8IX4RvwACyFX8KTCEDICAPRRlgIG/FEQpBYJAERIWuRIqQCqUWakA6kG7mNSJFx5AMGh6FhmBgWxhnjh1mM4WJWYdZiSjDVmGOYVkwX5jZmEDOB+YKlYtWxplgnrD92CTYRm40txFZgj2BbsJexA9hh7DscDsfAGeIccH64GFwybjWuBLcP14y7gOvDDeEm8Xi8Kt4U74IPwXPwYnwhvgp/HH8e348fxr8nkAlaBGuCDyGWICRsJFQQGgjnCP2EEcI0UYGoT3QihhB5xFxiKbGO2EG8SRwmTpMUSYYkF1IkKZm0gVRJaiJdJj0mvSGTyTpkR3IYWUBeT64knyBfJQ+SP1CUKCYUT0ocRULZTjlKuUB5QHlDpVINqG7UWKqYup1aT71EfUp9L0eTM5fzl+PJrZOrkWuV65d7JU+U15d3l18unydfIX9K/qb8uAJRwUDBU4GjsFahRuG0wj2FSUWaopViiGKaYolig+I1xVElvJKBkrcST6lA6bDSJaUhGkLTpXnSuLRNtDraZdowHUc3pPvTk+nF9B/ovfQJZSVlW+Uo5RzlGuWzylIGwjBg+DNSGaWMk4y7jI/zNOa5z+PP2zavaV7/vCmV+SpuKnyVIpVmlQGVj6pMVW/VFNWdqm2qT9QwaiZqYWrZavvVLquNz6fPd57PnV80/+T8h+qwuol6uPpq9cPqPeqTGpoavhoZGlUalzTGNRmabprJmuWa5zTHtGhaC7UEWuVa57VeMJWZ7sxUZiWzizmhra7tpy3RPqTdqz2tY6izWGejTrPOE12SLls3Qbdct1N3Qk9LL1gvX69R76E+UZ+tn6S/R79bf8rA0CDaYItBm8GooYqhv2GeYaPhYyOqkavRKqNaozvGOGO2cYrxPuNbJrCJnUmSSY3JTVPY1N5UYLrPtM8Ma+ZoJjSrNbvHorDcWVmsRtagOcM8yHyjeZv5Kws9i1iLnRbdFl8s7SxTLessH1kpWQVYbbTqsPrD2sSaa11jfceGauNjs86m3ea1rakt33a/7X07ml2w3Ra7TrvP9g72Ivsm+zEHPYd4h70O99h0dii7hH3VEevo4bjO8YzjByd7J7HTSaffnVnOKc4NzqMLDBfwF9QtGHLRceG4HHKRLmQujF94cKHUVduV41rr+sxN143ndsRtxN3YPdn9uPsrD0sPkUeLx5Snk+cazwteiJevV5FXr7eS92Lvau+nPjo+iT6NPhO+dr6rfS/4Yf0C/Xb63fPX8Of61/tPBDgErAnoCqQERgRWBz4LMgkSBXUEw8EBwbuCHy/SXyRc1BYCQvxDdoU8CTUMXRX6cxguLDSsJux5uFV4fnh3BC1iRURDxLtIj8jSyEeLjRZLFndGyUfFRdVHTUV7RZdFS5dYLFmz5EaMWowgpj0WHxsVeyR2cqn30t1Lh+Ps4grj7i4zXJaz7NpyteWpy8+ukF/BWXEqHhsfHd8Q/4kTwqnlTK70X7l35QTXk7uH+5LnxivnjfFd+GX8kQSXhLKE0USXxF2JY0muSRVJ4wJPQbXgdbJf8oHkqZSQlKMpM6nRqc1phLT4tNNCJWGKsCtdMz0nvS/DNKMwQ7rKadXuVROiQNGRTChzWWa7mI7+TPVIjCSbJYNZC7Nqst5nR2WfylHMEeb05JrkbssdyfPJ+341ZjV3dWe+dv6G/ME17msOrYXWrlzbuU53XcG64fW+649tIG1I2fDLRsuNZRvfbore1FGgUbC+YGiz7+bGQrlCUeG9Lc5bDmzFbBVs7d1ms61q25ciXtH1YsviiuJPJdyS699ZfVf53cz2hO29pfal+3fgdgh33N3puvNYmWJZXtnQruBdreXM8qLyt7tX7L5WYVtxYA9pj2SPtDKosr1Kr2pH1afqpOqBGo+a5r3qe7ftndrH29e/321/0wGNA8UHPh4UHLx/yPdQa61BbcVh3OGsw8/rouq6v2d/X39E7Ujxkc9HhUelx8KPddU71Nc3qDeUNsKNksax43HHb/3g9UN7E6vpUDOjufgEOCE58eLH+B/vngw82XmKfarpJ/2f9rbQWopaodbc1om2pDZpe0x73+mA050dzh0tP5v/fPSM9pmas8pnS8+RzhWcmzmfd37yQsaF8YuJF4c6V3Q+urTk0p2usK7ey4GXr17xuXKp2737/FWXq2euOV07fZ19ve2G/Y3WHruell/sfmnpte9tvelws/2W462OvgV95/pd+y/e9rp95Y7/nRsDiwb67i6+e/9e3D3pfd790QepD14/zHo4/Wj9Y+zjoicKTyqeqj+t/dX412apvfTsoNdgz7OIZ4+GuEMv/5X5r0/DBc+pzytGtEbqR61Hz4z5jN16sfTF8MuMl9Pjhb8p/rb3ldGrn353+71nYsnE8GvR65k/St6ovjn61vZt52To5NN3ae+mp4req74/9oH9oftj9MeR6exP+E+Vn40/d3wJ/PJ4Jm1m5t/3hPP7MjpZfgAAAAlwSFlzAAAN1wAADdcBQiibeAAADVtJREFUaAXNWQl0FEUa/qu7ekKGJJBkQg4SQsKhCZHlCCJKkKCIoBtWJCggCusK+9xdUFFUEJldn3sIeLAYH1lkOfbBI0BwMUIEISEi4TCGmxBIyA05h4RJJjPT3bV/dc9MghKut0r+96qrp7qO7/uvqu4B+P8IYYwR11RBx8+dW52bl/epe+rTp08b3PedsXYDh51ZWZMriovzWX4+a75czfbknXjVDTgtjSEJRrFIQ4d+L2VlMYqkJV6ysrKw/S4J17xb+zt3716A94z9kMdY5pdMsTWysppzf80yw00B4ijBbDYLd4UGLuwBeLasZL61wSJbdmazspOX2IHDtexPLx/ZLIqls/veU/6iKO6cnpT01vRdu1a/UFqaM/nw4QMJCLqbG/jtkvCY3z3BHdQcvOwa5x/v/9zvRv8+abEdfH33Z1Q7rE1+Bm/fYCg46wOUBoMs+yn9+7fAY49ZhIQEh/3++43NPj7GqvPnK5cNHz5kPVqCzJkzh6ampvI52R3guZ0hsyV3b1EUn9+zN6uwvKKKvf7GSkUUk1VRXMBEcaEsiksUX98PGKVrmMGQh21MKzExjL3//iV26lRBY3V1WeXRowWT3fPxmsdGcnKa2L7tx/d3agEyerRZzM42cy11W/b3T5aMHjdyrtXbKp6/fA7uC74PrBUqe+ftXeRofgN26c569waFsRZoaWnB3wlgt/+GNTX5oIZVDO7yfZmZF2v9/WV7fPzYJdihDIuKRZO0tDRxypQp/PdPLHInBHBMGgbbFGXw4OmxJ044Uj5LefbhiZMS4Gh5rqOwplB4atgksXdANCkuLoOt23Jg4dvHcW0nFj8NEALG+nEskxCUhHM5bIxlFC1bKpseHRtjM3o7S47lV5Y+M/WbfYqyYjt2tHLXIuTPuLbZQ4xPdrsEPOAl6fFhAHEbVNV0z4BYGcKjBOfQeJP08Mg46NMnHCIjegIhelLhRHJzT8H+/efgwAErWK3N0KuXEYYOfRrCwwPU+vpKIXOrCcpKQuHNf1CYmCRDvz4EKsurYVv62dwFC1a8KctffcsBIwGctI3EbRLg2cYsS9K4Iap632ZCgvp26eKQbbZWnNiBzwzQkxrgwSQfiPuVCYYMiYLY2CgIDQ0Cg4GCzWaHZmsLKCqDVrsNqi83wtGjBkhPD1L3HwwgmpHAhm5pEd5bKojJk7tD34gu8G3OD/VjHlkxTZbX7dY46IrX3Ok2CPCATUU/6B9N6RNo1tCBAK1OxhQxNFQUunYV4MIF7iZ2LDw0dJf19fWGZ54NhcGDekJUVA/o1s0HidjgwvkSyNrXFzZvidcwJQeWsafq9xLDjJNg63uFeS0JZfkwWumzcSBMnRgk1dQUVu7NOTR+1nOzTqI7iehPCh94iwR0zWN/b0rnbgIIm4gaR5QKmpMIfC9DwMTXh0BgoAiCSKCpSYW6Or6Gm5SDr4fijcWCpSeWP2AxwTtRe+DVi+NBGYTUcXYhFOAiHkSMiwBWwx711Ix4+dO3JEPT1QPrhw0b+wIO0jJUYmKirDspb+lYuM9xlWIef+kVgEAEz0FxDetOTggjgkDgqhWgpFSG4mIn+rmKriNCZKQ39IroDv6BQdg/EIsRC5LwGYBGNAF0BSg1RkItPAHCKHTCkCjYn9MPRiyKAflJgFd91wpZG1QhdkAXWPZez/EAiyJxAtxlfLnyyQ1zLO8IsAp9O0OVpAnxwCJXAOoZCXB1evYArRteCE7JiaB5wYkcOYnGRixNDLwog2iTAHWqBK8NaIC1D+yAEQYjpF+IhhNqCBTZRkFAjRcYB2ZAUV0D2L6og+kVAAGOk2AZ+DQJHO8kYcYqY8IjxedyD+bk4UanMqYt6V7+enUyEtyi+ZokzV3NWPiL6PdoDYbtntPn9QZqbZwQjzRcCPoggSInhX70Cmz/9W64N/iMZsidpR9D0jezEakBwhqa4NHQfBg8+luILzgDfidNsEmeCiEfV8DMWVNBwlVtrb3tTU0lR/btExbMmuU8dJMYWIVanuOk9LkHAcIzALz8cVWMUi8vHVqH2D0POIEoA4OLdjQk2CB7Qg6MDM8FVe6NsVICDD1x7dlN8NLBZP1E1AgQDA6IIc2QHYTGrpEgK/sTljByPvdZXhCTCJWVSvHKlco45NShoO9naNoXhPgpjDmGADRfJaRrAEYDH8Sx3VABvEM4ar6MZ1gElZ6YB2Mj9+NIA1qlHlS1LxPEBoj228aYPEY9WNGLZ2JoRgOX+HkzaBARsFV9Z9EG8Pc/hlgHYbnMMSl+fsQUEiIilY6FBzjHIIiiXxxAj3hCwqJ1zHacWMDn18fPW/nAMARfKfMlFFiXcBye7peJIwSFqYIiYCUQPGawSOhiaBSGBeUI/X16Qo3Fn1W0YmTjxovDcA1JmDq1TPD1zQRFkTEWW3BCQcNttzNMEx2KGZ9layYThEkL0IsTGLMUowYshHihFdCLQECcP40F7vMBmFlrFGSAZv90+Bl15r3/VXFZAQSDQAQH3qEC+G/xCvINRhIVMChkC0mOOkQeDRVIsNiVlDX7QZPsgBPHjWcuXJCW+PvvT7XZglsIaQ43GqFrSwvpdn0VaqTMaEwzZpswE6XT9iIO9L2yLxnr/Swh3XuhVpEc41a6Rjji8B4qyA5RvWwR4G+Di4Q34jdofZzV3kC62UA2EgW3QItDFqrtdvWSXYYyuxrWahCViACvahMqQD5eDzUrTnxY9NWl2COKYsnBs1ete6HFi2HA889L86xWZroRAXRcnv+nBlPaPJsQ3zBV7fkY1tGYidAPnWihLjgnNyKHzYUoEm5rDnQQbvp5/avg3eGfg1G1t9SGdWXO/i3GioNs1eF1Quq/nc66UwD1OIhv3Vqs8RkAVqBpQ3DCKe6dT2vlKRMgWSBEz4paI1/EdXOdyszdB2VTNSF+NYyFzcQawQciKV9EzTcz93Dc1hlxSAYiOhQ89HDfYnVVI2JyMq8aW5d9tqn3qAMjbXPDZwHp/QEJ88pyOv5Tp2UUfrbWwLMsPTMAzEVCbvDcPbWUjcCxJ2xRzWY+tyd2nW4E/OmPxOx+hu+8gRMwiFHdcbhgJALka2KKw4DECzIRRF8/L4MDl46Jqb+yddsPsDVt4+bk9OPjI1LUN+Y5i/ICfZjJUgstJm9IemEgPSVW0YLsfVLq9u3SUL4wSQQ5C9+dzZw8guQ16IKKJFyZPKwZElB5eHOLcDIIpiPJdj1YRdFsRaoaUUWIN6I+i+140AE/NLOClvDC4sBDWn15YyP7MDb2y8IJ44vSHQ427uXX+kSkfFhUuWK5NFNtZV3yj0GSjzfzMwULo69Y2CibXR1DKem3caO0fto054ZEM8gcFEGQ+uIdezgng31+mkFcqHnFR2vOLUnScMYWvIuug5a4im0iFooa4o+tBUajdWtT05G1ZnNe+bBh4vTBg32WmUzOgLo6e83lKlJy4hQ5tGOHc2l6OlTwibm88gqEjhgujcfT9QOtrWDEI0fawoXKDv1p29qu3x1WHVPUhuiZCAkMYWzGLoCgHhhz6CjdMdAcBQDVn1O6b0tr68VS3n3evF5PjhnT+GBoaMtFb29Q/P1hlMWiJlZVgWS1kkLskiVJ8takJDitTY+XmTMhJD8f/B56CMSUFLiITfzlwqM8d787qNs+NknSmn9SmoIv5ctbKc3B+pBTFP+Fp1K3JIvh4cneycnaWdndqNVxcRD89dfS7G3bpLQ1a2jmunV0KYLuxx+iu1zjwjge3fGaNkzd2qGRt7vLTZSOPXVhfDAeoTMTKL10hdIGBF7bTPF7GqWW71B33V39rgGhpzv9SfsrzxwIPOSjjyC6XRbRuriAt+/OA9gdxO3bb/VeB4+9fSkt+kYHzexYq/r9scW3OtPP3O961mhzHUrzP3CBR63L6D78e07dZUnaFP8zA7vV6blbtRd+/tdFkvbOpNSKwLnLqHikZrwwSbqw1t2nE9QevIiFb1z62YbStY9QWmfRwXPgGgEkcsVKacY4HbjHze4mD0+coC8xVzC+HieKVUUu8A5KFW4Fl/aLNrahvfEnv7Z+P+udRqAdeIigtOxom+abEbyCJLTMgxbZnKjD6RTa51A4ge+1dIk3PSi9kO0Cj9mmCrVeysFrmUeSClL4CF24u3UK8cSAP6WFmS7wqPU69PUjtZTaNe2LYnUJwF/wOwiXTqN9DkZTZDdKC75oA29rwd32MJKo09tUJPT927w3gud5l5fOIgR32uPtwHOwhzMoPZvbRqjyIKLFrxFc+FcKj9xtItr6eFQWWnFrd0lhJiGNFxkb8oTe0ORg7ORyvMdvgdx1CH+LcQv3P+2LnbvhF67d6y+7F90Ffb22QJKW/1YUG8rd2pekc+vbQLVtcq42TwC19flF79rWl6Q9f5Sk1BmSVJTmBo9HhlKA+bE6JE+m+kUR3tZilH73phs8nnswFg7N1yfQdue77e8dcXH9haI99nIdj/mPyr2y/MBKfdQWDt4TJXqbdu0sewF/69ckkNLKY5Q2ofbXP6w33dB1OgMBrty2XVUUtyVRuvN9HTy/3vSdua3rXbr7H0SXfo3+OPT1AAAAAElFTkSuQmCC" />
 </a>
 
 <div id='download_container'>